11 Commits

Author SHA1 Message Date
LyAhn c1070649fa feat(notifications): report completed background tasks
Register the Tauri notification plugin and request notification permission during startup. Send completion notifications for folder scans, embeddings, AI tagging, and duplicate scans, including failure counts where available.
2026-06-06 19:52:24 +01:00
LyAhn 2cdab000fb feat(discovery): surface all tags and guard folder removal
Include tags used by a single image in Explore and invalidate the tag cache after edits so changes appear immediately. Add an accessible, time-limited confirmation step before removing indexed folders from the sidebar.
2026-06-06 19:52:05 +01:00
LyAhn 6824dcffb3 fix(indexer): recover cleanly from indexing failures
Move sqlite-vec embedding deletions outside the image transaction to avoid transactional virtual-table failures. Emit a terminal indexing progress event on errors so the frontend reloads partial state and clears its active scan state.
2026-06-06 19:51:44 +01:00
LyAhn d7595703de docs: rewrite README to reflect current features, add CLAUDE.md 2026-06-02 19:40:00 +01:00
LyAhn 5b35bc5b6e chore: set app title to Phokus, remove default Vite favicon 2026-06-02 19:39:44 +01:00
LyAhn df17497808 feat: add region-based similarity search
Adds a "Search within image" button to the lightbox that lets the user
draw a crop region on an image and find visually similar results using
that crop's embedding. The crop is embedded in-memory without a temp
file via a new embed_image_crop method on ClipImageEmbedder.

Introduces a folder-scoped cosine search (search_image_ids_by_embedding_in_folder)
to support the current-folder scope option, and wires up the new
find_similar_by_region Tauri command end-to-end from Rust through to
the Zustand store and Lightbox UI.
2026-06-02 19:39:31 +01:00
LyAhn ff4a568b57 feat: expand media exploration and tagging controls 2026-04-12 12:18:47 +01:00
LyAhn b2826d1143 feat: add WD tagger with CSV tag support and model download via ureq/zip
Introduces tagger.rs for WD-based image tagging with CSV label support.
Adds csv, ureq, and zip dependencies; updates gitignore to exclude Python/JSON files.
2026-04-08 20:04:37 +01:00
LyAhn f93a80bc87 fix: refresh lightbox tags live after AI tagging, hide AI tags button for video 2026-04-08 17:15:44 +01:00
LyAhn a9dd2b2797 feat: add WD tagger, remove caption UI, add per-image tag queuing in lightbox
- Add WD SwinV2 tagger v3 backend (tagger.rs) with DirectML acceleration
- Expose tagger commands (model status, download, queue, tags CRUD)
- Add queueTaggingForImage store action and AI tags button in lightbox sidebar
- Remove all caption UI surfaces (Lightbox, BackgroundTasks, SettingsModal)
- Disable caption worker in lib.rs; keep Rust backend intact for future use
- Make lightbox sidebar scrollable with sticky header/footer
- Collapse long tag lists (show 8, expandable) in lightbox sidebar
2026-04-08 16:33:14 +01:00
LyAhn bee6adc61a feat: add local AI captions and queue controls 2026-04-07 06:11:01 +01:00
116 changed files with 1469 additions and 12156 deletions
-20
View File
@@ -1,20 +0,0 @@
# 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.
-116
View File
@@ -1,116 +0,0 @@
name: CI
on:
push:
branches: [main]
paths:
- 'src/**'
- 'src-tauri/**'
pull_request:
paths:
- 'src/**'
- 'src-tauri/**'
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
# windows-latest to match the release target — the ML crates (ort, candle)
# and the NSIS bundle only ever ship from Windows.
runs-on: windows-latest
env:
GITEA_STATUS_TOKEN: ${{ secrets.GITEA_STATUS_TOKEN }}
steps:
- name: Report pending status to Gitea
if: github.event_name != 'pull_request' && env.GITEA_STATUS_TOKEN != ''
continue-on-error: true
shell: pwsh
env:
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
$headers = @{
Authorization = "token $env:GITEA_STATUS_TOKEN"
Accept = "application/json"
}
$body = @{
state = "pending"
context = "github/actions/ci"
description = "GitHub Actions CI is running"
target_url = $env:GITHUB_RUN_URL
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$env:GITHUB_SHA" `
-Headers $headers `
-ContentType "application/json" `
-Body $body
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 11
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
# tsc + vite build; also produces dist/ which tauri's build script expects
- name: Type-check and build frontend
run: pnpm build:vite
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Rustfmt
working-directory: src-tauri
run: cargo fmt --check
# --no-default-features: default enables candle-cuda for the main dev
# machine; CI runners have no CUDA toolkit and must build CPU-only.
- name: Clippy
working-directory: src-tauri
run: cargo clippy --all-targets --locked --no-default-features -- -D warnings
- 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
-113
View File
@@ -1,113 +0,0 @@
name: Release
# Tag pushes mirrored from Gitea (git.jezz.wtf) trigger this on the GitHub
# mirror. Builds the NSIS installer and attaches it to a DRAFT GitHub Release —
# review and publish manually.
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
release:
permissions:
contents: write
runs-on: windows-latest
env:
GITEA_STATUS_TOKEN: ${{ secrets.GITEA_STATUS_TOKEN }}
steps:
- name: Report pending status to Gitea
if: env.GITEA_STATUS_TOKEN != ''
continue-on-error: true
shell: pwsh
env:
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
$headers = @{
Authorization = "token $env:GITEA_STATUS_TOKEN"
Accept = "application/json"
}
$body = @{
state = "pending"
context = "github/actions/release"
description = "GitHub Actions release is running"
target_url = $env:GITHUB_RUN_URL
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$env:GITHUB_SHA" `
-Headers $headers `
-ContentType "application/json" `
-Body $body
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 11
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Build and create draft release
uses: tauri-apps/tauri-action@v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Updater artifact signing (Phase 3) — set these repo secrets once
# the updater keypair exists; empty values are ignored until then.
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
tagName: ${{ github.ref_name }}
releaseName: 'Phokus v__VERSION__'
releaseBody: 'See the assets below to download and install this version.'
releaseDraft: true
prerelease: false
includeUpdaterJson: true
updaterJsonPreferNsis: true
# Cargo args after `--`: ship the CPU/DirectML build — default
# features enable candle-cuda, which runners (and most users) lack.
args: '-- --no-default-features'
- 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
+2 -4
View File
@@ -32,8 +32,6 @@ dist-ssr
# Misc
*.py
*.pyc
*.json
# Bundled CUDA runtime DLLs for the CUDA build (copied from the toolkit
# locally; ~600 MB, never committed). See RELEASE_PLAN.md "CUDA release variant".
src-tauri/cuda-redist/
*.pyc
-125
View File
@@ -1,125 +0,0 @@
# Changelog
All notable changes to Phokus are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
(0.x: anything may change between minor versions).
## [0.1.1] — 2026-06-23
### Added
- **Custom multi-folder picker** — replaces the native OS dialog with an
in-app folder browser that lets you navigate, stage folders from multiple
locations, and add them all in one go. Duplicate roots are skipped
automatically; partially-failed batches remove successfully-added entries
from the staging panel so only failed folders remain to retry.
- **Rebuild semantic index** maintenance action in Settings — drops and
recreates the vector tables at the current model dimension, then re-queues
every image for embedding. Fixes "dimension mismatch" search errors that
occur after switching between CLIP models with different output sizes.
- **Video playback settings** — new Video Playback group in Settings with two
persisted toggles: "Autoplay in lightbox" (default on) and "Start muted"
(default off). Settings apply to the next opened video rather than the
current one.
- **Timeline scrubber** — a year/month rail on the Timeline view that jumps to
any period in the library. Timeline now loads the full filtered set so the
scrubber spans the whole library instead of just the first page.
- **Folder reordering** in the sidebar — drag-and-drop (with edge auto-scroll)
or keyboard (↑/↓ on the drag handle), with the custom order persisted across
sessions; the Libraries list also gains AZ / ZA / Custom sort.
- Failed AI-tagging jobs can now be located from the background worker prompt,
including a gallery filter for images with failed tags and an expanded list
of failed filenames/errors.
- A new theme system adds Phokus, Subtle Light, and Conventional Dark chrome
options across the app.
- First-run onboarding now includes an inline theme picker so new users can
choose their preferred app chrome before continuing the tour.
### Changed
- Settings sections are reordered — General is now the first and default
section instead of AI Workspace.
- The Duplicate Finder group list is now virtualised — only on-screen cards
mount, so large result sets (e.g. 5,000+ pairs) scroll without lag rather
than mounting every thumbnail at once.
- The gallery grid is now row-virtualised, so very large libraries scroll
smoothly and only on-screen thumbnails are rendered.
- Polished the new theme surfaces before release, including readable
subtle-light secondary buttons, failed-worker action buttons, and onboarding
controls.
- Onboarding preview media keeps the dark gallery/media surface regardless of
the active chrome theme.
### Fixed
- **AVIF thumbnails** — AVIF files are now processed correctly by routing
thumbnail generation through the bundled FFmpeg path instead of the Rust
image decoder (which has no dav1d dependency). Previously-failed AVIF jobs
are requeued on startup; JPEG derivatives are fed to the embedding and
tagging pipeline while the lightbox continues to display the original file.
- Accent text is now readable in the Subtle Light theme.
- Folder picker chevron tooltip now correctly shows "No subfolders" for leaf
entries instead of "Open folder" in both branches.
- Folder picker Unix breadcrumb root now shows "/" instead of always "Home"
for non-home paths such as `/mnt/data`.
- Video embedding jobs are no longer claimed before their thumbnail exists, and
any that previously failed for that reason are requeued on startup — videos no
longer churn through failed embeddings.
- Subtle Light theme consistency — the lightbox metadata panel now follows the
light chrome while the image canvas stays dark (matching Conventional Dark),
and gallery/timeline media badges, duplicate-finder thumbnails, and the window
restore icon now theme correctly instead of staying Phokus-dark.
- Timeline scrolling is now smooth on large libraries — it virtualizes per row
of tiles instead of per month, so a month with thousands of photos no longer
mounts every tile at once (thumbnails now load in incrementally as you scroll,
matching the All Media grid).
- Background worker updates (thumbnails, metadata, embeddings, tags) no longer
re-sort the entire loaded image set on every batch. In Timeline, which loads
the whole library, this re-sort caused severe lag and could crash the app
during background indexing.
## [0.1.0] — 2026-06-14
First public release. Windows desktop, distributed as an unsigned NSIS
installer with a built-in updater.
### Added
- **Local media library** — add folders, recursive background indexing with
live progress, and a filesystem watcher that keeps the library in sync as
files are added, edited, moved, renamed, or removed (thumbnails and
embeddings are preserved across renames).
- **Gallery** — virtualized grid (handles very large libraries), favorites,
star ratings, video durations; filter by folder, type, favorites, or
rating; sort by date added, date taken (EXIF), name, size, rating, or
duration; compact / comfortable / detail density.
- **Search** — filename, semantic (`/s`, via CLIP visual embeddings), and tag
(`/t`) search from one prefix-aware search bar.
- **Discovery** — similar-image search (by image or selected region), an
Explore view with a visual cluster map and tag cloud, and a Timeline grouped
by EXIF capture date. Explore, Timeline, and Duplicates are folder-scopable
from their headers.
- **Lightbox** — keyboard navigation, zoom, pan, inline tag editing, and
rating controls, plus a custom edge-to-edge video player (scrubbing, volume,
speed, loop, fullscreen, keyboard support).
- **AI tagging** — WD tagger (ONNX, CPU/DirectML) with adjustable confidence
threshold, batch size, and per-folder queue targeting. Optional.
- **Duplicate finder** — three-phase exact-duplicate scan
(size → sample hash → full hash) with live progress and bulk delete.
- **Background pipeline** — strict-priority workers (thumbnails → metadata →
embeddings → tags) with per-folder pausing from the sidebar context menu or
the background-tasks bar.
- **Guided first-run onboarding** — background FFmpeg provisioning with live
progress and retry, a walkthrough of the library, pipeline, search modes,
views, and updates, plus an optional AI-tagger download. Re-runnable from
Settings.
- **Updater** — checks GitHub Releases on launch and from Settings; a title-bar
indicator lights up when a new version is ready, and one click downloads,
installs the signed update, and relaunches.
- **Maintenance** — database compaction and orphaned-thumbnail cleanup from
Settings, with live size/reclaimable stats.
- **Window state** persistence and single-instance handling.
[0.1.1]: https://github.com/JezzWTF/phokus/releases/tag/v0.1.1
[0.1.0]: https://github.com/JezzWTF/phokus/releases/tag/v0.1.0
+1 -1
View File
@@ -80,7 +80,7 @@ Database: SQLite with WAL mode, stored in the Tauri app data directory as `galle
| `media-updated` | `ThumbnailBatch` |
| `caption-model-progress` | `CaptionModelProgress` |
| `tagger-model-progress` | `TaggerModelProgress` |
| `duplicate_scan_progress` | `{ phase, processed, total, skipped }` |
| `duplicate_scan_progress` | `[scanned, total]` |
### Key types
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 JezzWTF
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+12 -80
View File
@@ -4,95 +4,31 @@ A local-first desktop media library for browsing, filtering, and curating image
## Features
### Library
- Add and remove media folders; background indexing with live progress
- **Live file tracking** — a filesystem watcher keeps the library in sync as files are added, changed, or removed; renames and moves are handled in place, preserving thumbnails and embeddings
- Browse all media or filter by folder, type (image/video), favorites, or star rating
- Sort by date added, date taken (EXIF), name, size, rating, or duration
- Grid density controls (compact / comfortable / detail)
- Desktop notifications, batched per folder, with per-folder mute and a global pause
### Search & discovery
- **Filename search**, **semantic search** (`/s query`), and **tag search** (`/t tag`)
- **Similar image search** — find visually similar media by image or a selected region
- **Similar image search** — find visually similar media by image or selected region
- **AI tagging** via WD tagger (ONNX, CPU/DirectML) with confidence threshold control
- **Explore view** — visual cluster map and tag cloud for browsing by theme
- **Timeline view** — media grouped chronologically by capture date (EXIF)
- **Duplicate finder** — three-phase exact-duplicate scan (size → sample hash → full hash) with live progress and bulk delete
- Explore, Timeline, and Duplicates are folder-scopable directly from their headers — no need to bounce through the sidebar
### Viewing & curation
- Lightbox with keyboard navigation, zoom, inline tag editing, and rating controls
- **Custom video player** — immersive edge-to-edge playback with scrubbing, volume, playback speed, loop, and fullscreen; auto-hiding controls and full keyboard support
- **AI tagging** via WD tagger (ONNX, CPU/DirectML) with confidence threshold, batch size, and per-folder queue targeting
### Maintenance
- Database compaction and orphaned-thumbnail cleanup from Settings, with live size/reclaimable stats
- Per-folder pausing of background work (thumbnails, metadata, embeddings, tagging)
- **Duplicate finder** — scan for exact duplicates by file hash with bulk delete
- Lightbox preview with keyboard navigation, inline tag editing, and rating controls
- Sort by date, name, size, rating, or duration
- Grid density controls (compact / comfortable / detail)
## Supported formats
| Images | Videos |
|--------|--------|
| jpg, jpeg, png, gif, bmp | mp4, mov, m4v |
| tiff, tif, webp, avif | webm |
## Installation
Phokus is a **Windows desktop app**. Download the latest installer from the
[Releases page](https://github.com/JezzWTF/phokus/releases/latest) and run it.
**Requirements:** Windows 10 or 11. The installer will fetch the WebView2
runtime automatically if it isn't already present (it ships with Windows 11).
### A note on the unsigned build
Phokus 0.1.0 installers are **not code-signed**. Code-signing certificates are
an ongoing expense that's hard to justify for an unfunded open-source project,
so signing is on the roadmap rather than in place today.
In practice this means Windows SmartScreen will show a blue
**"Windows protected your PC"** warning the first time you run the installer.
To proceed: click **More info → Run anyway**. If a release publishes a SHA-256
checksum, you can verify the download against it first.
### First run
On first launch Phokus downloads a few tools and models — all one-time, and all
processed on your machine:
- **FFmpeg** (~tens of MB) for video thumbnails and metadata — downloaded in the
background; the guided first-run tour shows progress.
- **WD tagger model** (~1.3 GB) — **optional**, only if you enable AI tagging.
- **CLIP embedding model** (~580 MB) — downloaded automatically the first time
visual embeddings run (powers semantic search and similar images).
## Privacy & local-first
Phokus is local-first by design. **Your media and everything derived from it —
thumbnails, embeddings, tags, ratings — never leave your machine.** There is no
account, no telemetry, and no cloud sync.
The only network activity is:
- **One-time tool/model downloads** on first use (FFmpeg, the CLIP and WD
models, and the ONNX runtime), fetched from their official sources
(FFmpeg builds, Hugging Face, NuGet). These pull *tools to your machine*
none of your images are uploaded.
- **Update checks** against the GitHub Releases page, so the app can offer new
versions. This can be ignored if you never update.
Everything Phokus stores lives in its app-data directory (`gallery.db`,
`thumbnails/`, `models/`, `settings/`). Removing that directory resets the app
completely; your original media folders are never modified.
| tiff, tif, webp, avif, heic, heif | webm |
## Stack
- Tauri 2 + Rust backend
- React 19 + TypeScript + Zustand
- SQLite + `sqlite-vec` (vector search) + HNSW index
- SQLite + `sqlite-vec` (vector search)
- ONNX Runtime (`ort`) for AI tagging
- Candle (Rust ML) for CLIP visual embeddings
- mozjpeg for fast scaled JPEG decoding
- Candle (Rust ML) for visual embeddings
- FFmpeg sidecar for video thumbnails and metadata
- Vite + Tailwind CSS v4
@@ -111,16 +47,12 @@ pnpm dev:vite
# Production build
pnpm build:app
# Type-check the frontend
pnpm build:vite
```
## How it works
1. Add a folder from the sidebar — the Rust indexer walks it recursively.
2. Supported files are written to SQLite with metadata (path, dimensions, media type, EXIF capture date, etc.).
3. Background workers process the queue as a strict priority pipeline — thumbnails first, then video metadata, then visual embeddings, then AI tags — so each stage runs at full speed instead of competing for CPU, disk, and the database.
2. Supported files are written to SQLite with metadata (path, dimensions, media type, etc.).
3. Background workers generate thumbnails, compute visual embeddings, and run AI tagging.
4. Progress events stream back to the UI while the gallery updates incrementally.
5. A filesystem watcher picks up later changes (new files, edits, deletions, renames) and keeps the index current without rescans.
6. Embeddings power semantic search and the similar-images feature via an HNSW index.
5. Embeddings power semantic search and the similar images feature via an HNSW index.
-43
View File
@@ -1,43 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.2 KiB

-59
View File
@@ -1,59 +0,0 @@
# Phokus v0.1.0
> Draft for the GitHub Release body. Paste into the release, fill in the
> checksum, and trim as needed.
**Phokus is a local-first desktop media library for Windows** — point it at
your image and video folders and it builds a fast, searchable gallery with
thumbnails, semantic search, visual discovery, AI tagging, and duplicate
cleanup. Everything is processed on your machine; nothing is uploaded.
This is the **first public release**. Expect rough edges, and please file
issues.
## Install
1. Download `Phokus_0.1.0_x64-setup.exe` below.
2. Run it. **Windows SmartScreen will warn** that the publisher is
unrecognized — this build is **not code-signed** (cost; signing is on the
roadmap). Click **More info → Run anyway** to proceed.
3. Requires **Windows 10/11**. The installer fetches the WebView2 runtime
automatically if needed.
On first launch, Phokus runs a short guided tour and downloads FFmpeg in the
background (one-time, ~tens of MB). AI tagging (~1.3 GB) is optional; the CLIP
model for semantic search (~580 MB) downloads automatically the first time
embeddings run. **All of this stays on your machine.**
## Highlights
- Virtualized gallery for very large libraries; favorites, ratings, filters, sorts
- Filename, semantic (`/s`), and tag (`/t`) search
- Similar-image search, Explore clusters + tag cloud, EXIF Timeline
- Lightbox with zoom/pan and a custom video player
- Optional on-device AI tagging (WD tagger)
- Exact-duplicate finder with bulk delete
- Built-in updater
See the [changelog](https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md)
for the full list.
## Privacy
Your media and all derived data (thumbnails, embeddings, tags, ratings) never
leave your machine. No account, no telemetry, no cloud. The only network
activity is the one-time tool/model downloads above and update checks against
this Releases page.
## Known limitations
- **Unsigned installer** — SmartScreen warning as described above.
- **Windows only** for now.
- CPU/DirectML inference in the shipped build; very large libraries take time
to embed on first index.
## Verify your download (optional)
```
SHA-256 (Phokus_0.1.0_x64-setup.exe) = <fill in at publish time>
```
-64
View File
@@ -1,64 +0,0 @@
# 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) = <fill in>
SHA-256 (Phokus_0.1.1_x64-cuda-setup.exe) = <fill in>
```
+1 -11
View File
@@ -1,20 +1,12 @@
{
"name": "phokus",
"private": true,
"version": "0.1.1",
"license": "MIT",
"version": "0.1.0",
"type": "module",
"scripts": {
"build:app": "tauri build",
"build:vite": "tsc && vite build",
"build:web": "cd website && tsc && vite build",
"clean:app": "cd src-tauri && cargo clean",
"dev:app": "tauri dev",
"dev:app:cpu": "tauri dev -- --no-default-features",
"dev:web": "cd website && pnpm dev",
"build:app:cpu": "tauri build -- --no-default-features",
"build:app:cuda": "tauri build --config src-tauri/tauri.cuda.conf.json",
"changelog:add": "node tools/changelog-add.mjs",
"dev:vite": "vite",
"preview": "vite preview",
"tauri": "tauri"
@@ -26,8 +18,6 @@
"@tauri-apps/plugin-fs": "^2.5.0",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"d3-force": "^3.0.0",
"framer-motion": "^12.38.0",
"react": "^19.1.0",
-415
View File
@@ -26,12 +26,6 @@ importers:
'@tauri-apps/plugin-opener':
specifier: ^2
version: 2.5.3
'@tauri-apps/plugin-process':
specifier: ^2.3.1
version: 2.3.1
'@tauri-apps/plugin-updater':
specifier: ^2.10.1
version: 2.10.1
d3-force:
specifier: ^3.0.0
version: 3.0.0
@@ -76,49 +70,6 @@ importers:
specifier: ^7.0.4
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:
'@babel/code-frame@7.29.0':
@@ -204,9 +155,6 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@emnapi/runtime@1.11.1':
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
'@esbuild/aix-ppc64@0.27.7':
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
engines: {node: '>=18'}
@@ -363,165 +311,6 @@ packages:
cpu: [x64]
os: [win32]
'@fontsource-variable/inter@5.2.8':
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
'@fontsource-variable/space-grotesk@5.2.10':
resolution: {integrity: sha512-yJQO/o35/hAP3CFnpdFTwQku2yzJOae2HIpBmqkOVoxhhXJaQP3g+b6Jrz7u+eI7A5ZdCIf88uMWpBJdFiGr5w==}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -541,15 +330,6 @@ packages:
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
'@rollup/pluginutils@5.4.0':
resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==}
engines: {node: '>=14.0.0'}
peerDependencies:
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
peerDependenciesMeta:
rollup:
optional: true
'@rollup/rollup-android-arm-eabi@4.60.1':
resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==}
cpu: [arm]
@@ -882,12 +662,6 @@ packages:
'@tauri-apps/plugin-opener@2.5.3':
resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==}
'@tauri-apps/plugin-process@2.3.1':
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
'@tauri-apps/plugin-updater@2.10.1':
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -984,9 +758,6 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -1022,10 +793,6 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
imagetools-core@9.1.0:
resolution: {integrity: sha512-xQjs+2vrxLnAjCq+omuNkd5UQTld9/bP8+YT0LyYTlKfuSQtgUBvqhUwGugzSAh6sCdN+LnROMuLswn5hZ9Fhg==}
engines: {node: '>=20.0.0'}
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
@@ -1176,15 +943,6 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.8.4:
resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==}
engines: {node: '>=10'}
hasBin: true
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1214,12 +972,6 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
vite-imagetools@10.0.0:
resolution: {integrity: sha512-+83L32YPU/2BOHWhudO2+9T5HBvb3+0qHoUNN7fb0+XcAoXilx7aE25cDPWU5kBi5Yc750zYCvHxgfyR+tAuMA==}
engines: {node: '>=22.0.0'}
peerDependencies:
vite: '>=7.0.0'
vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1395,11 +1147,6 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@emnapi/runtime@1.11.1':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.27.7':
optional: true
@@ -1478,106 +1225,6 @@ snapshots:
'@esbuild/win32-x64@0.27.7':
optional: true
'@fontsource-variable/inter@5.2.8': {}
'@fontsource-variable/space-grotesk@5.2.10': {}
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.11.1
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -1599,14 +1246,6 @@ snapshots:
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/pluginutils@5.4.0(rollup@4.60.1)':
dependencies:
'@types/estree': 1.0.8
estree-walker: 2.0.2
picomatch: 4.0.4
optionalDependencies:
rollup: 4.60.1
'@rollup/rollup-android-arm-eabi@4.60.1':
optional: true
@@ -1823,14 +1462,6 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-process@2.3.1':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-updater@2.10.1':
dependencies:
'@tauri-apps/api': 2.10.1
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.2
@@ -1948,8 +1579,6 @@ snapshots:
escalade@3.2.0: {}
estree-walker@2.0.2: {}
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@@ -1970,8 +1599,6 @@ snapshots:
graceful-fs@4.2.11: {}
imagetools-core@9.1.0: {}
jiti@2.6.1: {}
js-tokens@4.0.0: {}
@@ -2103,39 +1730,6 @@ snapshots:
semver@6.3.1: {}
semver@7.8.4: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.4
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
source-map-js@1.2.1: {}
tailwindcss@4.2.2: {}
@@ -2157,15 +1751,6 @@ snapshots:
escalade: 3.2.0
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):
dependencies:
esbuild: 0.27.7
-6
View File
@@ -1,6 +0,0 @@
packages:
- website
allowBuilds:
esbuild: true
sharp: true
-43
View File
@@ -1,43 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.2 KiB

+20 -625
View File
@@ -8,17 +8,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"version_check",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -63,23 +52,6 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_log-sys"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
[[package]]
name = "android_logger"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
dependencies = [
"android_log-sys",
"env_filter 0.1.4",
"log",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -171,12 +143,6 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -406,18 +372,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "bitvec"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
dependencies = [
"funty",
"radium",
"tap",
"wyz",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -449,30 +403,6 @@ dependencies = [
"piper",
]
[[package]]
name = "borsh"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a"
dependencies = [
"borsh-derive",
"bytes",
"cfg_aliases",
]
[[package]]
name = "borsh-derive"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59"
dependencies = [
"once_cell",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "brotli"
version = "8.0.2"
@@ -500,40 +430,6 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "byte-unit"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d"
dependencies = [
"rust_decimal",
"schemars 1.2.1",
"serde",
"utf8-width",
]
[[package]]
name = "bytecheck"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
dependencies = [
"bytecheck_derive",
"ptr_meta",
"simdutf8",
]
[[package]]
name = "bytecheck_derive"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "bytemuck"
version = "1.25.0"
@@ -739,8 +635,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -1556,16 +1450,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "env_filter"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_filter"
version = "1.0.1"
@@ -1590,7 +1474,7 @@ checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
dependencies = [
"anstream",
"anstyle",
"env_filter 1.0.1",
"env_filter",
"jiff",
"log",
]
@@ -1724,15 +1608,6 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fern"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
dependencies = [
"log",
]
[[package]]
name = "ffmpeg-sidecar"
version = "2.5.0"
@@ -1875,21 +1750,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "funty"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futf"
version = "0.1.5"
@@ -2585,9 +2445,6 @@ name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash 0.7.8",
]
[[package]]
name = "hashbrown"
@@ -2595,7 +2452,7 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash 0.8.12",
"ahash",
]
[[package]]
@@ -3075,26 +2932,6 @@ dependencies = [
"cfb",
]
[[package]]
name = "inotify"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -3242,16 +3079,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.94"
@@ -3286,15 +3113,6 @@ dependencies = [
"serde_json",
]
[[package]]
name = "kamadak-exif"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef4fc70d0ab7e5b6bafa30216a6b48705ea964cdfc29c050f2412295eba58077"
dependencies = [
"mutate_once",
]
[[package]]
name = "keyboard-types"
version = "0.7.0"
@@ -3306,26 +3124,6 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "kqueue"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [
"bitflags 2.11.0",
"libc",
]
[[package]]
name = "kuchikiki"
version = "0.8.8-speedreader"
@@ -3471,9 +3269,6 @@ name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
dependencies = [
"value-bag",
]
[[package]]
name = "lzma-rust2"
@@ -3624,12 +3419,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -3640,18 +3429,6 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.48.0",
]
[[package]]
name = "mio"
version = "1.2.0"
@@ -3712,31 +3489,6 @@ dependencies = [
"pxfm",
]
[[package]]
name = "mozjpeg"
version = "0.10.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7891b80aaa86097d38d276eb98b3805d6280708c4e0a1e6f6aed9380c51fec9"
dependencies = [
"arrayvec",
"bytemuck",
"libc",
"mozjpeg-sys",
"rgb",
]
[[package]]
name = "mozjpeg-sys"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f0dc668bf9bf888c88e2fb1ab16a406d2c380f1d082b20d51dd540ab2aa70c1"
dependencies = [
"cc",
"dunce",
"libc",
"nasm-rs",
]
[[package]]
name = "muda"
version = "0.17.2"
@@ -3758,22 +3510,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "mutate_once"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
[[package]]
name = "nasm-rs"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149"
dependencies = [
"jobserver",
"log",
]
[[package]]
name = "native-tls"
version = "0.2.18"
@@ -3870,25 +3606,6 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "notify"
version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
dependencies = [
"bitflags 2.11.0",
"crossbeam-channel",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio 0.8.11",
"walkdir",
"windows-sys 0.48.0",
]
[[package]]
name = "notify-rust"
version = "4.17.0"
@@ -4025,15 +3742,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]]
name = "objc2"
version = "0.6.4"
@@ -4130,18 +3838,6 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -4311,20 +4007,6 @@ dependencies = [
"ureq",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -4595,7 +4277,7 @@ dependencies = [
[[package]]
name = "phokus"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"anyhow",
"candle-core",
@@ -4608,11 +4290,8 @@ dependencies = [
"hf-hub",
"hnsw_rs",
"image",
"kamadak-exif",
"log",
"memmap2",
"mozjpeg",
"notify",
"ort",
"r2d2",
"r2d2_sqlite",
@@ -4626,16 +4305,12 @@ dependencies = [
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-log",
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tokenizers",
"tokio",
"ureq",
"uuid",
"walkdir",
"xxhash-rust",
"zip 4.6.1",
@@ -4846,26 +4521,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "ptr_meta"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
dependencies = [
"ptr_meta_derive",
]
[[package]]
name = "ptr_meta_derive"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "pulp"
version = "0.21.5"
@@ -4976,12 +4631,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rand"
version = "0.7.3"
@@ -5255,15 +4904,6 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rend"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
dependencies = [
"bytecheck",
]
[[package]]
name = "reqwest"
version = "0.12.28"
@@ -5321,20 +4961,15 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -5370,15 +5005,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "rgb"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
dependencies = [
"bytemuck",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -5393,35 +5019,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rkyv"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
dependencies = [
"bitvec",
"bytecheck",
"bytes",
"hashbrown 0.12.3",
"ptr_meta",
"rend",
"rkyv_derive",
"seahash",
"tinyvec",
"uuid",
]
[[package]]
name = "rkyv_derive"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
@@ -5436,23 +5033,6 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rust_decimal"
version = "1.42.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a"
dependencies = [
"arrayvec",
"borsh",
"bytes",
"num-traits",
"rand 0.8.5",
"rkyv",
"serde",
"serde_json",
"wasm-bindgen",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -5496,18 +5076,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
@@ -5517,33 +5085,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.10"
@@ -5672,12 +5213,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -5975,12 +5510,6 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "0.3.11"
@@ -6332,12 +5861,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.45"
@@ -6529,28 +6052,6 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-log"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93"
dependencies = [
"android_logger",
"byte-unit",
"fern",
"log",
"objc2",
"objc2-foundation",
"serde",
"serde_json",
"serde_repr",
"swift-rs",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"time",
]
[[package]]
name = "tauri-plugin-notification"
version = "2.3.3"
@@ -6592,79 +6093,6 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
dependencies = [
"tauri",
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.18",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest 0.13.2",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip 4.6.1",
]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
dependencies = [
"bitflags 2.11.0",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-runtime"
version = "2.10.1"
@@ -6873,9 +6301,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",
@@ -6908,28 +6334,13 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokenizers"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223"
dependencies = [
"ahash 0.8.12",
"ahash",
"aho-corasick",
"compact_str",
"dary_heap",
@@ -6965,12 +6376,26 @@ checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd"
dependencies = [
"bytes",
"libc",
"mio 1.2.0",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
@@ -7435,12 +6860,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-width"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
[[package]]
name = "utf8-zero"
version = "0.8.1"
@@ -7472,12 +6891,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "value-bag"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -8090,15 +7503,6 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -8586,15 +7990,6 @@ dependencies = [
"x11-dl",
]
[[package]]
name = "wyz"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
dependencies = [
"tap",
]
[[package]]
name = "x11"
version = "2.21.0"
+10 -24
View File
@@ -1,9 +1,8 @@
[package]
name = "phokus"
version = "0.1.1"
description = "Local-first desktop media library"
version = "0.1.0"
description = "A performant image gallery application"
authors = ["JezzWTF"]
license = "MIT"
edition = "2021"
[lib]
@@ -14,9 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[features]
# CUDA is on by default for the main dev machine; build with
# `--no-default-features` (pnpm dev:app:cpu) on machines without the toolkit.
default = ["candle-cuda"]
default = []
candle-cuda = ["candle-core/cuda", "candle-nn/cuda", "candle-transformers/cuda"]
[dependencies]
@@ -35,32 +32,25 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
fast_image_resize = { version = "6.0.0", features = ["image"] }
walkdir = "2"
rayon = "1"
tokio = { version = "1", features = ["rt-multi-thread"] }
chrono = "0.4"
tokio = { version = "1", features = ["full"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
anyhow = "1"
log = "0.4"
ffmpeg-sidecar = "2.5.0"
xxhash-rust = { version = "0.8", features = ["xxh3"] }
memmap2 = "0.9"
sysinfo = "0.38.4"
candle-core = "0.10.2"
candle-nn = "0.10.2"
candle-transformers = "0.10.2"
candle-core = { version = "0.10.2", features = ["cuda"] }
candle-nn = { version = "0.10.2", features = ["cuda"] }
candle-transformers = { version = "0.10.2", features = ["cuda"] }
hf-hub = { version = "0.5.0", default-features = false, features = ["ureq", "native-tls"] }
tokenizers = "0.22.1"
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "ndarray", "download-binaries", "copy-dylibs", "load-dynamic", "directml", "api-24", "tls-native"] }
ureq = { version = "3.3.0", default-features = false, features = ["native-tls"] }
zip = { version = "4.6.1", default-features = false, features = ["deflate"] }
csv = "1"
kamadak-exif = "0.5"
notify = "6"
tauri-plugin-notification = "2"
mozjpeg = "0.10.13"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-log = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-window-state = "2"
log = "0.4"
# ── Dev-mode performance ────────────────────────────────────────────────────
# opt-level=1 on the main crate keeps incremental compile short.
@@ -89,10 +79,6 @@ opt-level = 3
opt-level = 3
[profile.dev.package.fast_image_resize]
opt-level = 3
[profile.dev.package.mozjpeg]
opt-level = 3
[profile.dev.package.mozjpeg-sys]
opt-level = 3
# Parallel work scheduler
[profile.dev.package.rayon]
+2 -6
View File
@@ -15,17 +15,13 @@ fn main() {
if let Ok(path) = &cuda_path {
println!("cargo:warning=CUDA Toolkit found at {path} — GPU acceleration enabled.");
} else {
println!(
"cargo:warning=CUDA Toolkit (nvcc) found in PATH — GPU acceleration enabled."
);
println!("cargo:warning=CUDA Toolkit (nvcc) found in PATH — GPU acceleration enabled.");
}
} else {
println!("cargo:warning=━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!("cargo:warning= candle-cuda feature is enabled but no CUDA Toolkit found.");
println!("cargo:warning= Install CUDA Toolkit from https://developer.nvidia.com/cuda-downloads");
println!(
"cargo:warning= Or build without GPU support: cargo build --no-default-features"
);
println!("cargo:warning= Or build without GPU support: cargo build --no-default-features");
println!("cargo:warning=━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
}
}
-2
View File
@@ -14,8 +14,6 @@
"fs:read-files",
"fs:read-dirs",
"notification:default",
"updater:default",
"process:allow-restart",
"core:window:allow-minimize",
"core:window:allow-close",
"core:window:allow-toggle-maximize",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+52 -274
View File
@@ -8,20 +8,13 @@ use ort::session::{builder::GraphOptimizationLevel, Session};
use ort::value::{Shape, Tensor};
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::io::Read;
use std::io::{Cursor, Read};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::sync::OnceLock;
use std::time::Instant;
use tokenizers::Tokenizer;
// Suppress the console window when spawning curl.exe from the GUI app.
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
pub const FLORENCE_MODEL_ID: &str = "onnx-community/Florence-2-base-ft";
pub const FLORENCE_CAPTION_MODEL_NAME: &str = "florence-2-base-ft-onnx-q4";
const ONNX_RUNTIME_NUGET_URL: &str =
@@ -69,19 +62,15 @@ const REQUIRED_FILES: &[&str] = &[
"onnx/embed_tokens_fp16.onnx",
];
// Mutex<bool> rather than OnceLock<Result>: a failed attempt (DLL not yet
// downloaded) must NOT be cached, or a later successful download could never
// recover within the same app session.
static ORT_RUNTIME_INIT: Mutex<bool> = Mutex::new(false);
static ORT_RUNTIME_INIT: OnceLock<std::result::Result<(), String>> = OnceLock::new();
/// Set to `true` by `set_caption_acceleration` so the caption worker loop
/// knows to drop its cached `FlorenceCaptioner` and reload with the new EP.
pub static CAPTION_SESSION_DIRTY: AtomicBool = AtomicBool::new(false);
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CaptionAcceleration {
#[default]
Auto,
Cpu,
Directml,
@@ -97,12 +86,17 @@ impl CaptionAcceleration {
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
impl Default for CaptionAcceleration {
fn default() -> Self {
Self::Auto
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CaptionDetail {
Short,
Detailed,
#[default]
Paragraph,
}
@@ -132,6 +126,12 @@ impl CaptionDetail {
}
}
impl Default for CaptionDetail {
fn default() -> Self {
Self::Paragraph
}
}
#[derive(Serialize)]
pub struct CaptionModelStatus {
pub model_id: &'static str,
@@ -238,7 +238,6 @@ pub fn set_caption_detail(app_data_dir: &Path, detail: CaptionDetail) -> Result<
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, detail.as_str())?;
CAPTION_SESSION_DIRTY.store(true, Ordering::Relaxed);
Ok(detail)
}
@@ -470,7 +469,7 @@ impl FlorenceCaptioner {
acceleration,
false,
)?;
log::info!(
println!(
"Florence sessions loaded in {:?} with {:?} acceleration (total init {:?})",
sessions_started_at.elapsed(),
acceleration,
@@ -490,9 +489,9 @@ impl FlorenceCaptioner {
pub fn generate(&mut self, image_path: &Path) -> Result<String> {
let started_at = Instant::now();
log::info!("Florence caption started: {}", image_path.display());
println!("Florence caption started: {}", image_path.display());
let image_features = run_vision_encoder(&mut self.vision_session, image_path)?;
log::info!("Florence vision encoder done in {:?}", started_at.elapsed());
println!("Florence vision encoder done in {:?}", started_at.elapsed());
let prompt_ids = self
.tokenizer
.encode(self.caption_detail.prompt(), false)
@@ -502,7 +501,7 @@ impl FlorenceCaptioner {
.map(|id| i64::from(*id))
.collect::<Vec<_>>();
let prompt_embeds = run_token_embedder(&mut self.embed_session, &prompt_ids)?;
log::info!(
println!(
"Florence token embeddings done in {:?}",
started_at.elapsed()
);
@@ -513,7 +512,7 @@ impl FlorenceCaptioner {
&encoder_embeds,
&encoder_attention_mask,
)?;
log::info!("Florence encoder done in {:?}", started_at.elapsed());
println!("Florence encoder done in {:?}", started_at.elapsed());
let generated_ids = run_decoder(
&mut self.decoder_prefill_session,
@@ -523,7 +522,7 @@ impl FlorenceCaptioner {
&encoder_attention_mask,
self.caption_detail.max_new_tokens(),
)?;
log::info!("Florence decoder done in {:?}", started_at.elapsed());
println!("Florence decoder done in {:?}", started_at.elapsed());
let generated_u32 = generated_ids
.into_iter()
@@ -648,61 +647,23 @@ fn probe_vision_session(
}
pub fn ensure_onnx_runtime(local_dir: &Path) -> Result<()> {
let mut initialized = ORT_RUNTIME_INIT
.lock()
.map_err(|_| anyhow::anyhow!("ONNX runtime init lock poisoned"))?;
if *initialized {
return Ok(());
}
let dll_path = local_dir.join(ONNX_RUNTIME_DLL_FILE);
ORT_RUNTIME_INIT
.get_or_init(|| {
if !dll_path.exists() {
anyhow::bail!("ONNX Runtime DLL is missing: {}", dll_path.display());
return Err(format!(
"ONNX Runtime DLL is missing: {}",
dll_path.display()
));
}
ort::environment::init_from(&dll_path)
.map_err(|error| anyhow::anyhow!(error.to_string()))?
.map_err(|error| error.to_string())?
.with_name("phokus-florence")
.commit();
*initialized = true;
Ok(())
}
/// Download any ONNX Runtime DLLs missing from `local_dir`, reporting per-file
/// byte progress as `(short_label, downloaded_bytes, total_bytes)`.
/// `total_bytes` is `None` when the server omits Content-Length. Unlike
/// `ensure_onnx_runtime` (init only), this actually provisions the files —
/// callers that can run on a clean install must call this first. The callback
/// fires per chunk; callers should throttle.
pub fn provision_onnx_runtime_with_progress(
local_dir: &Path,
mut on_progress: impl FnMut(&str, u64, Option<u64>),
) -> Result<()> {
for (destination_file, source_url, archive_path) in ONNX_RUNTIME_FILES {
let destination = local_dir.join(destination_file);
if destination.exists() {
continue;
}
// Strip the "onnxruntime/" prefix for a clean label.
let label = destination_file
.rsplit('/')
.next()
.unwrap_or(destination_file);
download_nuget_file(
source_url,
archive_path,
&destination,
|downloaded, total| on_progress(label, downloaded, total),
)?;
}
Ok(())
}
/// Number of ONNX Runtime DLLs still missing from `local_dir` (for progress
/// step counts before downloading).
pub fn missing_onnx_runtime_count(local_dir: &Path) -> usize {
ONNX_RUNTIME_FILES
.iter()
.filter(|(destination_file, _, _)| !local_dir.join(destination_file).exists())
.count()
})
.clone()
.map_err(anyhow::Error::msg)
}
fn download_onnx_runtime_files(local_dir: &Path) -> Result<()> {
@@ -714,218 +675,35 @@ fn download_onnx_runtime_files(local_dir: &Path) -> Result<()> {
for (destination_file, source_url, archive_path) in ONNX_RUNTIME_FILES {
let destination = local_dir.join(destination_file);
download_nuget_file(source_url, archive_path, &destination, |_, _| {})?;
download_nuget_file(source_url, archive_path, &destination)?;
}
Ok(())
}
// Give up only after this many *consecutive* curl runs that download nothing;
// a run that makes any progress resets the counter, so a large file completes
// across however many resumes it takes. Kept low so a hard stall (e.g. a
// broken VM NIC) fails in a couple of minutes — surfacing a retryable error —
// rather than locking the UI on "preparing" for many minutes.
const MAX_STALL_RETRIES: usize = 3;
fn download_nuget_file(source_url: &str, archive_path: &str, destination: &Path) -> Result<()> {
let mut response = ureq::get(source_url)
.call()
.map_err(|error| anyhow::anyhow!("{error}"))?;
let mut bytes = Vec::new();
response
.body_mut()
.as_reader()
.read_to_end(&mut bytes)
.map_err(|error| anyhow::anyhow!("{error}"))?;
/// Resiliently download `url` to `destination` using the system `curl.exe`.
///
/// ureq's read timeout does not fire on a stalled large transfer on Windows
/// (schannel doesn't honor the socket read timeout), so a stall there hangs
/// forever. curl detects an inactivity stall (`--speed-time`), resumes from
/// the partial file (`-C -`), and retries internally — the same behavior a
/// browser gets. We monitor the `.part` file's size for the progress bar and
/// wrap curl in an outer progress-aware retry as a backstop. The partial file
/// survives an app restart, so a later retry continues from disk.
pub fn download_file_resilient(
url: &str,
destination: &Path,
mut on_progress: impl FnMut(u64, Option<u64>),
) -> Result<()> {
let mut archive = zip::ZipArchive::new(Cursor::new(bytes))?;
let mut dll = archive.by_name(archive_path)?;
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent)?;
}
let part = match destination.extension() {
Some(ext) => destination.with_extension(format!("{}.part", ext.to_string_lossy())),
None => destination.with_extension("part"),
};
let name = destination
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| url.to_string());
log::info!("{name}: resolving download size");
let total = remote_content_length(url);
// Reconcile any existing `.part` against the real size: exactly complete →
// finish; oversized (stale/corrupt) → discard so curl restarts cleanly
// (otherwise `curl -C -` would 416 forever).
if let Some(total) = total {
let size = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
if size == total {
std::fs::rename(&part, destination)?;
return Ok(());
}
if size > total {
let _ = std::fs::remove_file(&part);
}
}
log::info!(
"{name}: downloading via curl ({} bytes)",
total
.map(|t| t.to_string())
.unwrap_or_else(|| "unknown size".into())
);
let mut stalls = 0usize;
loop {
let before = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
match run_curl_download(url, &part, total, &mut on_progress) {
Ok(()) => break,
Err(error) => {
let after = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
if after > before {
log::warn!("{name}: curl interrupted at {after} bytes, resuming: {error}");
stalls = 0;
} else {
stalls += 1;
log::warn!(
"{name}: curl made no progress ({stalls}/{MAX_STALL_RETRIES}): {error}"
);
if stalls >= MAX_STALL_RETRIES {
// Discard the partial so a future attempt restarts clean
// rather than getting stuck re-resuming a bad file.
let _ = std::fs::remove_file(&part);
return Err(error);
}
}
std::thread::sleep(std::time::Duration::from_secs(2));
}
}
}
if let Some(total) = total {
let got = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
if got < total {
anyhow::bail!("{name}: incomplete after curl ({got}/{total} bytes)");
}
}
std::fs::rename(&part, destination)?;
Ok(())
}
/// Size probe via `curl -r 0-0` (a 1-byte Range request), parsing the total
/// from the `Content-Range: bytes 0-0/<total>` header. Uses curl rather than
/// ureq so no part of the download path depends on ureq (which hangs on this
/// VM's TLS stack). Returns None if the server doesn't report a size.
fn remote_content_length(url: &str) -> Option<u64> {
let mut command = Command::new("curl.exe");
command.args([
"-sL",
"-r",
"0-0",
"-D",
"-",
"-o",
"NUL",
"--connect-timeout",
"30",
"--max-time",
"30",
url,
]);
#[cfg(target_os = "windows")]
command.creation_flags(CREATE_NO_WINDOW);
let output = command.output().ok()?;
let headers = String::from_utf8_lossy(&output.stdout);
for line in headers.lines() {
if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-range:") {
if let Some(total) = rest.rsplit('/').next().map(str::trim) {
if let Ok(n) = total.parse::<u64>() {
return Some(n);
}
}
}
}
None
}
/// Run one `curl.exe` download to `dest`, resuming from any partial file, while
/// reporting progress from the growing file size. Returns an error (leaving the
/// partial in place) if curl exits non-zero.
fn run_curl_download(
url: &str,
dest: &Path,
total: Option<u64>,
on_progress: &mut impl FnMut(u64, Option<u64>),
) -> Result<()> {
let mut command = Command::new("curl.exe");
command
.arg("-fSL") // fail on HTTP errors, follow redirects, show errors
.args(["-C", "-"]) // resume from the existing output file
.args(["--retry", "3", "--retry-delay", "1", "--retry-connrefused"])
.args(["--connect-timeout", "30"])
// Abort (then --retry resumes) if under 1 KB/s for 30s — a real
// inactivity timeout, which is what ureq couldn't deliver here.
.args(["--speed-limit", "1024", "--speed-time", "30"])
.arg("-s") // no progress meter (we watch the file instead)
.arg("-o")
.arg(dest)
.arg(url)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped());
#[cfg(target_os = "windows")]
command.creation_flags(CREATE_NO_WINDOW);
let mut child = command
.spawn()
.map_err(|e| anyhow::anyhow!("failed to launch curl.exe (required for downloads): {e}"))?;
loop {
if let Some(status) = child.try_wait()? {
if status.success() {
return Ok(());
}
let mut stderr = String::new();
if let Some(mut pipe) = child.stderr.take() {
let _ = pipe.read_to_string(&mut stderr);
}
anyhow::bail!(
"curl exited with {}: {}",
status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".into()),
stderr.trim()
);
}
let downloaded = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
on_progress(downloaded, total);
std::thread::sleep(std::time::Duration::from_millis(300));
}
}
fn download_nuget_file(
source_url: &str,
archive_path: &str,
destination: &Path,
on_progress: impl FnMut(u64, Option<u64>),
) -> Result<()> {
// Download the .nupkg (a zip) resiliently, then extract the one DLL.
let package = destination.with_extension("nupkg");
download_file_resilient(source_url, &package, on_progress)?;
log::info!("extracting {archive_path} from package");
let file = std::fs::File::open(&package)?;
let mut archive = zip::ZipArchive::new(file)?;
let mut dll = archive.by_name(archive_path)?;
let temp_destination = destination.with_extension("tmp");
{
let mut out = std::fs::File::create(&temp_destination)?;
std::io::copy(&mut dll, &mut out)?;
let mut file = std::fs::File::create(&temp_destination)?;
std::io::copy(&mut dll, &mut file)?;
}
std::fs::rename(&temp_destination, destination)?;
let _ = std::fs::remove_file(&package);
log::info!("extracted {archive_path}");
std::fs::rename(temp_destination, destination)?;
Ok(())
}
@@ -1005,7 +783,7 @@ fn run_decoder(
"inputs_embeds" => prefill_inputs_embeds_tensor
})
.map_err(|error| anyhow::anyhow!("{error}"))?;
log::info!(
println!(
"Florence decoder prefill done in {:?}",
prefill_started_at.elapsed()
);
@@ -1069,7 +847,7 @@ fn run_decoder(
decoder_kv = collect_present_key_values(&outputs, DECODER_LAYERS)?;
}
log::info!("Florence decoder produced {} token(s)", generated.len());
println!("Florence decoder produced {} token(s)", generated.len());
Ok(generated)
}
@@ -1107,7 +885,7 @@ fn create_session(
CaptionAcceleration::Auto | CaptionAcceleration::Directml => {
// `allow_directml` is false for the 4 text/decoder sessions —
// they intentionally run on CPU regardless of the setting.
log::info!(
println!(
"Florence: using CPU for {} (DirectML disabled for this session type)",
path.display()
);
+91 -1078
View File
File diff suppressed because it is too large Load Diff
+76 -597
View File
File diff suppressed because it is too large Load Diff
+20 -30
View File
@@ -20,7 +20,7 @@ fn with_text_embedder<T>(f: impl FnOnce(&ClipImageEmbedder) -> Result<T>) -> Res
.lock()
.map_err(|_| anyhow::anyhow!("Text embedder lock poisoned"))?;
if guard.is_none() {
log::info!("Initializing CLIP text embedder...");
println!("Initializing CLIP text embedder...");
*guard = Some(ClipImageEmbedder::new()?);
}
f(guard.as_ref().unwrap())
@@ -35,13 +35,13 @@ pub struct ClipImageEmbedder {
impl ClipImageEmbedder {
pub fn new() -> Result<Self> {
log::info!("Initializing CLIP image embedder...");
println!("Initializing CLIP image embedder...");
let api = Api::new()?;
let repo = api.repo(Repo::new(
"laion/CLIP-ViT-B-32-laion2B-s34B-b79K".to_string(),
RepoType::Model,
));
log::info!("Resolving CLIP model weights from Hugging Face cache...");
println!("Resolving CLIP model weights from Hugging Face cache...");
let model_path = repo.get("model.safetensors")?;
let tokenizer_repo = api.repo(Repo::new(
"openai/clip-vit-base-patch32".to_string(),
@@ -60,7 +60,7 @@ impl ClipImageEmbedder {
};
let model = ClipModel::new(vb, &config)?;
let tokenizer = Tokenizer::from_file(tokenizer_path).map_err(anyhow::Error::msg)?;
log::info!("CLIP image embedder ready.");
println!("CLIP image embedder ready.");
Ok(Self {
model,
@@ -160,7 +160,7 @@ impl ClipImageEmbedder {
let ids = enc.get_ids();
let len = ids.len().min(max_len);
for j in 0..len {
flat[i * max_len + j] = ids[j];
flat[i * max_len + j] = ids[j] as u32;
}
}
@@ -177,25 +177,23 @@ impl ClipImageEmbedder {
fn resolve_device() -> Result<Device> {
match Device::cuda_if_available(0) {
Ok(device) if device.is_cuda() => {
log::info!("CLIP embedder: using CUDA GPU (device 0).");
println!("CLIP embedder: using CUDA GPU (device 0).");
return Ok(device);
}
Ok(_) => {
log::info!("CLIP embedder: no compatible CUDA GPU found — using CPU.");
println!("CLIP embedder: no compatible CUDA GPU found — using CPU.");
}
Err(e) => {
log::info!("CLIP embedder: CUDA init failed ({e}) — using CPU.");
println!("CLIP embedder: CUDA init failed ({e}) — using CPU.");
}
}
Ok(Device::Cpu)
}
fn load_image(path: &Path, image_size: usize) -> Result<Tensor> {
// Scaled decode: CLIP only needs image_size² pixels, so decoding a large
// JPEG at full resolution is wasted work. Cover mode keeps the shortest
// side at or above image_size for the fill-crop below. Also applies EXIF
// orientation, so rotated photos embed the way they are displayed.
let image = crate::thumbnail::decode_image_scaled(path, image_size as u32, true)?;
let image = image::ImageReader::open(path)?
.with_guessed_format()?
.decode()?;
let image = image.resize_to_fill(
image_size as u32,
image_size as u32,
@@ -210,29 +208,28 @@ fn load_image(path: &Path, image_size: usize) -> Result<Tensor> {
}
fn load_images(paths: &[PathBuf], image_size: usize) -> Result<Tensor> {
use rayon::prelude::*;
let images = paths
.par_iter()
.map(|path| load_image(path, image_size))
.collect::<Result<Vec<_>>>()?;
let mut images = Vec::with_capacity(paths.len());
for path in paths {
images.push(load_image(path, image_size)?);
}
Ok(Tensor::stack(&images, 0)?)
}
/// Returns the path that should be fed to the CLIP image embedder for a given media file.
///
/// For videos and AVIFs the thumbnail image is used because CLIP preprocessing
/// only uses decoders from the `image` crate, while AVIF is decoded through
/// FFmpeg into a JPEG thumbnail.
/// For videos the thumbnail image is used (because CLIP only understands still images).
/// If a video has no thumbnail yet, an error is returned — the caller should mark the
/// embedding job as failed rather than trying to decode the raw video file.
pub fn embedding_source_path(
path: &str,
thumbnail_path: Option<&str>,
media_kind: &str,
) -> Result<PathBuf> {
if media_kind == "video" || is_avif_path(path) {
if media_kind == "video" {
match thumbnail_path {
Some(thumb) => Ok(PathBuf::from(thumb)),
None => Err(anyhow::anyhow!(
"No thumbnail available yet for '{}' — embedding deferred until thumbnail is generated",
"No thumbnail available yet for video '{}' — embedding deferred until thumbnail is generated",
std::path::Path::new(path)
.file_name()
.map(|n| n.to_string_lossy())
@@ -243,10 +240,3 @@ pub fn embedding_source_path(
Ok(PathBuf::from(path))
}
}
fn is_avif_path(path: &str) -> bool {
std::path::Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("avif"))
}
+6 -31
View File
@@ -23,12 +23,6 @@ fn cache() -> &'static RwLock<Option<CachedHnswIndex>> {
}
fn build_index(conn: &Connection) -> Result<CachedHnswIndex> {
// Read the revision *before* fetching embeddings so we can detect any write
// that races with the build. If the revision advances while we are building,
// the resulting index would be stale — retry until it is stable.
loop {
let revision_before = vector::get_embedding_revision(conn)?;
let embeddings = vector::get_all_image_embeddings_with_ids(conn, None)?;
let max_elements = embeddings.len().max(1);
let max_layer = 16.min((max_elements as f32).ln().trunc() as usize).max(1);
@@ -58,18 +52,12 @@ fn build_index(conn: &Connection) -> Result<CachedHnswIndex> {
hnsw.parallel_insert(&data_with_id);
hnsw.set_searching_mode(true);
// If the revision is unchanged the index reflects a consistent snapshot.
let revision_after = vector::get_embedding_revision(conn)?;
if revision_before == revision_after {
return Ok(CachedHnswIndex {
revision: revision_after,
Ok(CachedHnswIndex {
revision: vector::get_embedding_revision(conn)?,
image_ids_by_external,
external_by_image_id,
hnsw,
});
}
// A concurrent write advanced the revision — discard this build and retry.
}
})
}
fn ensure_index(conn: &Connection) -> Result<()> {
@@ -103,29 +91,16 @@ pub fn find_similar_image_matches(
None => return Ok(Vec::new()),
};
// Fetch folder image IDs *before* acquiring the read lock so we don't hold
// the lock across a potentially slow SQLite query, which would delay any
// concurrent ensure_index call waiting for a write lock.
let folder_image_ids: Option<Vec<i64>> = if let Some(folder_id) = folder_id {
let ids = vector::get_all_image_embeddings_with_ids(conn, Some(folder_id))?
.into_iter()
.map(|(id, _)| id)
.collect();
Some(ids)
} else {
None
};
let guard = cache().read().expect("hnsw cache poisoned");
let Some(cached) = guard.as_ref() else {
return Ok(Vec::new());
};
let knbn = (offset + limit).max(limit).saturating_add(32);
let neighbours: Vec<Neighbour> = if let Some(image_ids) = folder_image_ids {
let mut allowed_ids = image_ids
let neighbours: Vec<Neighbour> = if let Some(folder_id) = folder_id {
let mut allowed_ids = vector::get_all_image_embeddings_with_ids(conn, Some(folder_id))?
.into_iter()
.filter_map(|allowed_image_id| {
.filter_map(|(allowed_image_id, _)| {
cached.external_by_image_id.get(&allowed_image_id).copied()
})
.collect::<Vec<_>>();
+131 -730
View File
File diff suppressed because it is too large Load Diff
+5 -82
View File
@@ -16,30 +16,6 @@ use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
// Must be the first plugin: a second launch hands its args to the
// running instance and exits before anything else initializes.
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.set_focus();
}
}))
.plugin(
tauri_plugin_log::Builder::new()
.targets([
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir {
file_name: Some("phokus".into()),
}),
])
.level(log::LevelFilter::Info)
.max_file_size(5 * 1024 * 1024)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepOne)
.build(),
)
.plugin(tauri_plugin_window_state::Builder::new().build())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
@@ -52,10 +28,7 @@ pub fn run() {
std::fs::create_dir_all(&app_dir).expect("Failed to create app data dir");
// FFmpeg provisioning happens in the background so the window
// appears immediately; workers gate video/AVIF jobs on readiness and
// the onboarding/Settings UI shows progress and retry.
media::spawn_ffmpeg_provision(app.handle().clone());
media::MediaTools::ensure_installed().expect("Failed to provision FFmpeg sidecar");
let db_path = app_dir.join("gallery.db");
let pool = db::create_pool(&db_path).expect("Failed to create database pool");
@@ -65,26 +38,17 @@ pub fn run() {
let conn = pool.get().expect("Failed to get connection for migration");
db::migrate(&conn).expect("Failed to run migrations");
db::reset_inflight_jobs(&conn).expect("Failed to reset inflight jobs");
let repaired_deferred = db::repair_deferred_embedding_jobs(&conn)
.expect("Failed to repair deferred embedding jobs");
if repaired_deferred > 0 {
log::info!("Requeued {repaired_deferred} deferred embedding jobs.");
}
let repaired_avif =
db::repair_avif_jobs(&conn).expect("Failed to repair AVIF jobs");
if repaired_avif > 0 {
log::info!("Requeued {repaired_avif} AVIF jobs.");
}
let backfilled =
db::backfill_embedding_jobs(&conn).expect("Failed to backfill embedding jobs");
if backfilled > 0 {
log::info!("Backfilled {backfilled} embedding jobs.");
println!("Backfilled {} embedding jobs.", backfilled);
}
let (orphaned_vectors, missing_vectors) = db::repair_embedding_consistency(&conn)
.expect("Failed to repair embedding consistency");
if orphaned_vectors > 0 || missing_vectors > 0 {
log::info!(
"Repaired embedding consistency: removed {orphaned_vectors} orphaned vectors, requeued {missing_vectors} missing vectors."
println!(
"Repaired embedding consistency: removed {} orphaned vectors, requeued {} missing vectors.",
orphaned_vectors, missing_vectors
);
}
}
@@ -92,19 +56,6 @@ pub fn run() {
let thumb_dir = app_dir.join("thumbnails");
std::fs::create_dir_all(&thumb_dir).expect("Failed to create thumbnail dir");
// The asset protocol scope is no longer a blanket "**": thumbnails
// are allowed statically in tauri.conf.json, and each indexed
// folder is allowed here (and in add_folder/update_folder_path).
{
let scope = app.asset_protocol_scope();
let conn = pool.get().expect("Failed to get connection for asset scope");
for folder in db::get_folders(&conn).unwrap_or_default() {
if let Err(error) = scope.allow_directory(&folder.path, true) {
log::error!("Failed to allow asset scope for {}: {}", folder.path, error);
}
}
}
let thumbnail_worker_count = std::thread::available_parallelism()
.map(|parallelism| StorageProfile::Balanced.thumbnail_workers(parallelism.get()))
.unwrap_or(2);
@@ -123,20 +74,14 @@ pub fn run() {
// indexer::start_caption_worker(app.handle().clone(), pool.clone(), app_dir.clone());
indexer::start_tagging_worker(app.handle().clone(), pool.clone(), app_dir.clone());
let watcher_handle = indexer::start_watcher(app.handle().clone(), pool.clone(), thumb_dir.clone());
app.manage(pool);
app.manage(media_tools);
app.manage(watcher_handle);
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::add_folder,
commands::add_folders,
commands::list_directories,
commands::get_folders,
commands::reorder_folders,
commands::get_background_job_progress,
commands::remove_folder,
commands::get_images,
@@ -169,7 +114,6 @@ pub fn run() {
commands::get_explore_tags,
commands::get_images_by_ids,
commands::get_failed_embedding_images,
commands::get_failed_tagging_images,
commands::get_tagger_model_status,
commands::get_tagger_acceleration,
commands::set_tagger_acceleration,
@@ -188,28 +132,7 @@ pub fn run() {
commands::search_tags_autocomplete,
commands::find_duplicates,
commands::load_duplicate_scan_cache,
commands::invalidate_duplicate_scan_cache,
commands::delete_images_from_disk,
commands::rename_folder,
commands::update_folder_path,
commands::get_tagging_queue_scope,
commands::set_tagging_queue_scope,
commands::get_tagging_queue_folder_ids,
commands::set_tagging_queue_folder_ids,
commands::open_app_data_folder,
commands::get_database_info,
commands::vacuum_database,
commands::rebuild_semantic_index,
commands::get_orphaned_thumbnails_info,
commands::cleanup_orphaned_thumbnails,
commands::get_muted_folder_ids,
commands::set_muted_folder_ids,
commands::get_ffmpeg_status,
commands::retry_ffmpeg_download,
commands::get_onboarding_completed,
commands::set_onboarding_completed,
commands::get_notifications_paused,
commands::set_notifications_paused,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+28 -128
View File
@@ -2,136 +2,9 @@ use anyhow::{anyhow, Result};
use ffmpeg_sidecar::download::{auto_download_with_progress, FfmpegDownloadProgressEvent};
use ffmpeg_sidecar::ffprobe::ffprobe_path;
use ffmpeg_sidecar::paths::ffmpeg_path;
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter};
static FFMPEG_READY: AtomicBool = AtomicBool::new(false);
static FFMPEG_DOWNLOADING: AtomicBool = AtomicBool::new(false);
// Set when a provision attempt fails, so the frontend can distinguish
// "failed before the event listener attached" from "about to start".
static FFMPEG_FAILED: AtomicBool = AtomicBool::new(false);
/// True once both ffmpeg and ffprobe binaries are present on disk. Workers
/// gate video jobs on this so a missing/in-flight download never fails jobs.
pub fn ffmpeg_ready() -> bool {
FFMPEG_READY.load(Ordering::Relaxed)
}
pub fn ffmpeg_downloading() -> bool {
FFMPEG_DOWNLOADING.load(Ordering::Relaxed)
}
pub fn ffmpeg_failed() -> bool {
FFMPEG_FAILED.load(Ordering::Relaxed)
}
#[derive(Debug, Clone, Serialize)]
pub struct FfmpegProgressPayload {
pub phase: String,
pub downloaded_bytes: Option<u64>,
pub total_bytes: Option<u64>,
pub error: Option<String>,
}
impl FfmpegProgressPayload {
fn phase(phase: &str) -> Self {
Self {
phase: phase.to_string(),
downloaded_bytes: None,
total_bytes: None,
error: None,
}
}
}
const FFMPEG_PROGRESS_EVENT: &str = "ffmpeg-progress";
/// Provision FFmpeg in the background, streaming progress to the frontend as
/// `ffmpeg-progress` events. Idempotent: re-invoking while a download is in
/// flight is a no-op, and re-invoking after success only re-emits `done`.
pub fn spawn_ffmpeg_provision(app: AppHandle) {
// Fast path: binaries already on disk (previous run, or a manual install).
if ffmpeg_path().exists() && ffprobe_path().exists() {
FFMPEG_READY.store(true, Ordering::Relaxed);
let _ = app.emit(FFMPEG_PROGRESS_EVENT, FfmpegProgressPayload::phase("done"));
return;
}
if FFMPEG_DOWNLOADING
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return; // a download is already running
}
FFMPEG_FAILED.store(false, Ordering::SeqCst);
std::thread::spawn(move || {
// The Downloading callback fires very frequently; throttle emissions.
// Cell because the download callback is Fn, not FnMut.
let last_emit = std::cell::Cell::new(Instant::now() - Duration::from_secs(1));
let result = auto_download_with_progress(|event| match event {
FfmpegDownloadProgressEvent::Starting => {
log::info!("Downloading bundled FFmpeg...");
let _ = app.emit(
FFMPEG_PROGRESS_EVENT,
FfmpegProgressPayload::phase("starting"),
);
}
FfmpegDownloadProgressEvent::Downloading {
total_bytes,
downloaded_bytes,
} => {
if last_emit.get().elapsed() >= Duration::from_millis(250) {
last_emit.set(Instant::now());
let _ = app.emit(
FFMPEG_PROGRESS_EVENT,
FfmpegProgressPayload {
phase: "downloading".to_string(),
downloaded_bytes: Some(downloaded_bytes),
total_bytes: Some(total_bytes),
error: None,
},
);
}
}
FfmpegDownloadProgressEvent::UnpackingArchive => {
log::info!("Unpacking bundled FFmpeg...");
let _ = app.emit(
FFMPEG_PROGRESS_EVENT,
FfmpegProgressPayload::phase("unpacking"),
);
}
FfmpegDownloadProgressEvent::Done => {
log::info!("Bundled FFmpeg ready.");
}
});
FFMPEG_DOWNLOADING.store(false, Ordering::SeqCst);
match result {
Ok(()) => {
FFMPEG_READY.store(true, Ordering::Relaxed);
let _ = app.emit(FFMPEG_PROGRESS_EVENT, FfmpegProgressPayload::phase("done"));
}
Err(error) => {
FFMPEG_FAILED.store(true, Ordering::SeqCst);
log::error!("FFmpeg provisioning failed: {error}");
let _ = app.emit(
FFMPEG_PROGRESS_EVENT,
FfmpegProgressPayload {
phase: "error".to_string(),
downloaded_bytes: None,
total_bytes: None,
error: Some(error.to_string()),
},
);
}
}
});
}
// On Windows, GUI apps spawn subprocesses with a visible console window by default.
// CREATE_NO_WINDOW suppresses that for every ffmpeg/ffprobe invocation.
@@ -163,6 +36,33 @@ impl MediaTools {
}
}
pub fn ensure_installed() -> Result<()> {
// Skip download entirely if both binaries are already present.
if ffmpeg_path().exists() && ffprobe_path().exists() {
return Ok(());
}
auto_download_with_progress(|event| match event {
FfmpegDownloadProgressEvent::Starting => {
println!("Downloading bundled FFmpeg...");
}
FfmpegDownloadProgressEvent::Downloading {
total_bytes,
downloaded_bytes,
} => {
println!(
"Downloading bundled FFmpeg: {}/{} bytes",
downloaded_bytes, total_bytes
);
}
FfmpegDownloadProgressEvent::UnpackingArchive => {
println!("Unpacking bundled FFmpeg...");
}
FfmpegDownloadProgressEvent::Done => {
println!("Bundled FFmpeg ready.");
}
})
}
pub fn ffmpeg_command(&self) -> Command {
let mut cmd = Command::new(&self.ffmpeg_path);
#[cfg(target_os = "windows")]
+2 -2
View File
@@ -1,4 +1,4 @@
use std::path::Path;
use std::path::{Path, PathBuf};
use sysinfo::{DiskKind, Disks};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -107,7 +107,7 @@ pub fn detect_storage_profile(path: &Path) -> StorageProfile {
}
}
fn fallback_profile_for_path(path: &Path) -> StorageProfile {
fn fallback_profile_for_path(path: &PathBuf) -> StorageProfile {
let path_str = path.to_string_lossy().to_lowercase();
if path_str.starts_with("\\\\") {
return StorageProfile::Conservative;
+42 -80
View File
@@ -45,10 +45,9 @@ pub static TAGGER_SESSION_DIRTY: AtomicBool = AtomicBool::new(false);
// Settings types
// ---------------------------------------------------------------------------
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum TaggerAcceleration {
#[default]
Auto,
Cpu,
Directml,
@@ -64,6 +63,12 @@ impl TaggerAcceleration {
}
}
impl Default for TaggerAcceleration {
fn default() -> Self {
Self::Auto
}
}
// ---------------------------------------------------------------------------
// Status / probe types exposed to the frontend
// ---------------------------------------------------------------------------
@@ -82,10 +87,6 @@ pub struct TaggerModelProgress {
pub total_files: usize,
pub completed_files: usize,
pub current_file: Option<String>,
// Byte progress for the file currently downloading. None for files whose
// size isn't known up front (or between files).
pub downloaded_bytes: Option<u64>,
pub total_bytes: Option<u64>,
pub done: bool,
}
@@ -190,7 +191,6 @@ pub fn set_tagger_threshold(app_data_dir: &Path, threshold: f32) -> Result<f32>
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, clamped.to_string())?;
TAGGER_SESSION_DIRTY.store(true, Ordering::Relaxed);
Ok(clamped)
}
@@ -199,7 +199,11 @@ pub fn tagger_batch_size(app_data_dir: &Path) -> usize {
let Ok(value) = std::fs::read_to_string(path) else {
return 8;
};
value.trim().parse::<usize>().unwrap_or(8).clamp(1, 100)
value
.trim()
.parse::<usize>()
.unwrap_or(8)
.clamp(1, 100)
}
pub fn set_tagger_batch_size(app_data_dir: &Path, batch_size: usize) -> Result<usize> {
@@ -249,93 +253,51 @@ pub fn prepare_tagger_model_with_progress(
let local_dir = model_dir(app_data_dir);
std::fs::create_dir_all(&local_dir)?;
// The tagger shares the ONNX runtime DLLs with the captioner; download
// them here so the tagger works even on a clean install where the caption
// model has never been fetched (ensure_onnx_runtime only initializes).
// Only download the two tagger-specific files; ONNX runtime DLLs are
// already handled by the captioner download flow.
const DOWNLOAD_FILES: &[&str] = &["model.onnx", "selected_tags.csv"];
let caption_model_dir = crate::captioner::model_dir(app_data_dir);
std::fs::create_dir_all(&caption_model_dir)?;
// Unified step count across DLLs and tagger files so the bar is coherent.
let dll_count = crate::captioner::missing_onnx_runtime_count(&caption_model_dir);
let model_pending = DOWNLOAD_FILES
.iter()
.filter(|file| !local_dir.join(file).exists())
.count();
let total_files = dll_count + model_pending;
let mut completed_files = 0usize;
emit_progress(TaggerModelProgress {
total_files,
completed_files,
current_file: None,
downloaded_bytes: None,
total_bytes: None,
done: total_files == 0,
});
// ── ONNX runtime DLLs (small, but non-trivial on a slow link) ──
{
let mut last_emit = Instant::now() - std::time::Duration::from_secs(1);
crate::captioner::provision_onnx_runtime_with_progress(
&caption_model_dir,
|label, downloaded, total| {
if last_emit.elapsed() >= std::time::Duration::from_millis(200) {
last_emit = Instant::now();
emit_progress(TaggerModelProgress {
total_files,
completed_files,
current_file: Some(format!("ONNX Runtime: {label}")),
downloaded_bytes: Some(downloaded),
total_bytes: total,
done: false,
});
}
},
)?;
completed_files += dll_count;
}
log::info!("Tagger: ONNX runtime DLLs ready; initializing runtime");
crate::captioner::ensure_onnx_runtime(&caption_model_dir)?;
log::info!("Tagger: runtime initialized; downloading model files");
// ── Tagger model files (model.onnx is ~446 MB) ──
// Download directly from the resolved URL with our resilient downloader
// (timeout + resume), rather than hf-hub's download_with_progress, whose
// agent has no read timeout and would hang on a stalled connection.
let api = Api::new()?;
let repo = api.repo(Repo::new(WD_TAGGER_MODEL_ID.to_string(), RepoType::Model));
let mut completed_files = DOWNLOAD_FILES
.iter()
.filter(|file| local_dir.join(file).exists())
.count();
emit_progress(TaggerModelProgress {
total_files: DOWNLOAD_FILES.len(),
completed_files,
current_file: None,
done: completed_files == DOWNLOAD_FILES.len(),
});
for file in DOWNLOAD_FILES {
let destination = local_dir.join(file);
if destination.exists() {
continue;
}
let url = repo.url(file);
let label = (*file).to_string();
let mut last_emit = Instant::now() - std::time::Duration::from_secs(1);
crate::captioner::download_file_resilient(&url, &destination, |downloaded, total| {
if last_emit.elapsed() >= std::time::Duration::from_millis(200) {
last_emit = Instant::now();
emit_progress(TaggerModelProgress {
total_files,
total_files: DOWNLOAD_FILES.len(),
completed_files,
current_file: Some(label.clone()),
downloaded_bytes: Some(downloaded),
total_bytes: total,
current_file: Some((*file).to_string()),
done: false,
});
}
})?;
let cached = repo.get(file)?;
std::fs::copy(cached, destination)?;
completed_files += 1;
emit_progress(TaggerModelProgress {
total_files: DOWNLOAD_FILES.len(),
completed_files,
current_file: Some((*file).to_string()),
done: completed_files == DOWNLOAD_FILES.len(),
});
}
emit_progress(TaggerModelProgress {
total_files,
total_files: DOWNLOAD_FILES.len(),
completed_files,
current_file: None,
downloaded_bytes: None,
total_bytes: None,
done: true,
});
@@ -477,7 +439,7 @@ impl WdTagger {
let labels = load_labels(&labels_path)?;
log::info!(
println!(
"WD tagger loaded in {:?} ({} labels, input {}x{}, {:?} acceleration)",
started_at.elapsed(),
labels.len(),
@@ -515,7 +477,7 @@ impl WdTagger {
.try_extract_tensor::<f32>()
.map_err(|error| anyhow::anyhow!("{error}"))?;
let probs: &[f32] = probabilities;
let probs: &[f32] = &probabilities;
if probs.len() != self.labels.len() {
anyhow::bail!(
@@ -553,7 +515,7 @@ impl WdTagger {
tags.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
tags.truncate(max_tags);
log::info!(
println!(
"WD tagger: {} tags (threshold={}, rating={}) in {:?} for {}",
tags.len(),
self.threshold,
@@ -593,13 +555,13 @@ fn create_tagger_session(path: &Path, acceleration: TaggerAcceleration) -> Resul
let mut builder = match acceleration {
TaggerAcceleration::Cpu => {
log::info!("WD tagger: using CPU execution provider");
println!("WD tagger: using CPU execution provider");
builder
}
TaggerAcceleration::Auto => builder
.with_execution_providers([ep::DirectML::default().build().fail_silently()])
.unwrap_or_else(|error| {
log::info!("WD tagger: DirectML unavailable, falling back to CPU");
println!("WD tagger: DirectML unavailable, falling back to CPU");
error.recover()
}),
TaggerAcceleration::Directml => builder
+16 -236
View File
@@ -31,26 +31,29 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
});
}
let img = decode_for_thumbnail(image_path)?;
let reader = image::ImageReader::open(image_path)?.with_guessed_format()?;
let mut decoder = reader.into_decoder()?;
let orientation = decoder.orientation()?;
let mut img = image::DynamicImage::from_decoder(decoder)?;
img.apply_orientation(orientation);
// RGB8 throughout: JPEG output has no alpha, so decoding/resizing RGBA
// only to flatten at encode time wastes a third of the bandwidth.
let src = image::DynamicImage::ImageRgb8(img.into_rgb8());
let src = image::DynamicImage::ImageRgba8(img.into_rgba8());
let (dst_width, dst_height) = fit_dimensions(src.width(), src.height(), THUMB_SIZE);
let mut dst = fir::images::Image::new(dst_width, dst_height, src.pixel_type().unwrap());
let mut resizer = fir::Resizer::new();
let options = fir::ResizeOptions::new()
.resize_alg(fir::ResizeAlg::Convolution(fir::FilterType::CatmullRom));
.resize_alg(fir::ResizeAlg::Convolution(fir::FilterType::Lanczos3));
resizer.resize(&src, &mut dst, Some(&options))?;
let thumb = image::RgbImage::from_raw(dst_width, dst_height, dst.buffer().to_vec())
let thumb = image::RgbaImage::from_raw(dst_width, dst_height, dst.buffer().to_vec())
.ok_or_else(|| anyhow!("failed to construct resized thumbnail buffer"))?;
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let thumb = image::DynamicImage::ImageRgba8(thumb).into_rgb8();
let mut output_file = std::fs::File::create(&out_path)?;
let mut encoder = JpegEncoder::new_with_quality(&mut output_file, 82);
encoder.encode_image(&thumb)?;
@@ -61,98 +64,6 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
})
}
/// Decodes an image for thumbnailing, with EXIF orientation already applied.
fn decode_for_thumbnail(image_path: &Path) -> Result<image::DynamicImage> {
decode_image_scaled(image_path, THUMB_SIZE, false)
}
/// Decodes an image at reduced resolution, with EXIF orientation applied.
///
/// JPEGs take a fast path that decodes at reduced resolution (DCT scaling),
/// which skips most of the IDCT/upsampling work for large photos. Anything
/// that path can't handle falls back to the `image` crate at full size.
///
/// `cover` selects which side must stay at or above `target`: `false` keeps
/// the longest side (for aspect-fit consumers), `true` keeps the shortest
/// side (for fill-crop consumers like the CLIP preprocessor).
pub fn decode_image_scaled(
image_path: &Path,
target: u32,
cover: bool,
) -> Result<image::DynamicImage> {
if is_jpeg(image_path) {
if let Some(img) = decode_jpeg_scaled(image_path, target, cover) {
return Ok(img);
}
}
let reader = image::ImageReader::open(image_path)?.with_guessed_format()?;
let mut decoder = reader.into_decoder()?;
let orientation = decoder.orientation()?;
let mut img = image::DynamicImage::from_decoder(decoder)?;
img.apply_orientation(orientation);
Ok(img)
}
fn is_jpeg(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("jpg") || ext.eq_ignore_ascii_case("jpeg"))
}
/// Decodes a JPEG at the smallest DCT scale that still covers `target`.
/// Returns `None` on any failure (corrupt file, CMYK without conversion
/// support, etc.) so the caller can fall back to the generic decoder.
/// mozjpeg reports errors by panicking, hence the `catch_unwind`.
fn decode_jpeg_scaled(image_path: &Path, target: u32, cover: bool) -> Option<image::DynamicImage> {
let path = image_path.to_path_buf();
let decoded = std::panic::catch_unwind(move || -> std::io::Result<(Vec<u8>, u32, u32)> {
let mut decompress = mozjpeg::Decompress::new_path(&path)?;
let (width, height) = decompress.size();
let reference = if cover {
width.min(height)
} else {
width.max(height)
};
decompress.scale(scale_numerator(reference, target));
let mut started = decompress.rgb()?;
let (out_width, out_height) = (started.width() as u32, started.height() as u32);
let pixels = started.read_scanlines::<u8>()?;
started.finish()?;
Ok((pixels, out_width, out_height))
})
.ok()?
.ok()?;
let (pixels, width, height) = decoded;
let buffer = image::RgbImage::from_raw(width, height, pixels)?;
let mut img = image::DynamicImage::ImageRgb8(buffer);
if let Some(orientation) = exif_orientation(image_path) {
img.apply_orientation(orientation);
}
Some(img)
}
/// Smallest numerator (of /8) that keeps `reference` at or above `target`,
/// so the subsequent resize never upscales.
fn scale_numerator(reference: usize, target: u32) -> u8 {
for numerator in 1..=8u8 {
if reference * numerator as usize / 8 >= target as usize {
return numerator;
}
}
8
}
fn exif_orientation(path: &Path) -> Option<image::metadata::Orientation> {
let file = std::fs::File::open(path).ok()?;
let mut reader = std::io::BufReader::new(file);
let exif = exif::Reader::new().read_from_container(&mut reader).ok()?;
let field = exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)?;
let value = field.value.get_uint(0)?;
image::metadata::Orientation::from_exif(value as u8)
}
pub fn generate_video_thumbnail(
tools: &MediaTools,
video_path: &Path,
@@ -177,8 +88,6 @@ pub fn generate_video_thumbnail(
let attempts: [&[&str]; 3] = [
&[
"-y",
"-threads",
"2",
"-ss",
"00:00:00.000",
"-i",
@@ -186,15 +95,13 @@ pub fn generate_video_thumbnail(
"-frames:v",
"1",
"-vf",
"scale=320:-1:force_original_aspect_ratio=decrease",
"thumbnail,scale=320:-1:force_original_aspect_ratio=decrease",
"-q:v",
"4",
&output_path,
],
&[
"-y",
"-threads",
"2",
"-ss",
"00:00:00.250",
"-i",
@@ -202,21 +109,19 @@ pub fn generate_video_thumbnail(
"-frames:v",
"1",
"-vf",
"scale=320:-1:force_original_aspect_ratio=decrease",
"thumbnail,scale=320:-1:force_original_aspect_ratio=decrease",
"-q:v",
"4",
&output_path,
],
&[
"-y",
"-threads",
"2",
"-i",
path_str.as_ref(),
"-frames:v",
"1",
"-vf",
"scale=320:-1:force_original_aspect_ratio=decrease",
"thumbnail,scale=320:-1:force_original_aspect_ratio=decrease",
"-q:v",
"4",
&output_path,
@@ -237,94 +142,12 @@ pub fn generate_video_thumbnail(
}
Err(anyhow!(
"ffmpeg failed generating poster for {path_str}: {last_error}"
"ffmpeg failed generating poster for {}: {}",
path_str,
last_error
))
}
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,
});
}
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() {
return Ok(GeneratedThumbnail {
path: out_path,
width: original_dimensions.0,
height: original_dimensions.1,
});
}
Err(anyhow!(
"ffmpeg failed generating AVIF thumbnail for {path_str}: {}",
String::from_utf8_lossy(&output.stderr)
))
}
fn ffprobe_dimensions(tools: &MediaTools, image_path: &Path) -> (Option<i64>, Option<i64>) {
let output = tools
.ffprobe_command()
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"csv=p=0:s=x",
&image_path.to_string_lossy(),
])
.output();
let Ok(output) = output else {
return (None, None);
};
if !output.status.success() {
return (None, None);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut parts = stdout.trim().split('x');
let width = parts.next().and_then(|part| part.parse::<i64>().ok());
let height = parts.next().and_then(|part| part.parse::<i64>().ok());
(width, height)
}
pub fn thumb_path(cache_dir: &Path, image_path: &str) -> PathBuf {
thumb_path_with_ext(cache_dir, image_path, "jpg")
}
@@ -335,7 +158,7 @@ pub fn video_poster_path(cache_dir: &Path, video_path: &str) -> PathBuf {
fn thumb_path_with_ext(cache_dir: &Path, input_path: &str, extension: &str) -> PathBuf {
let hash = hash_path(input_path);
cache_dir.join(format!("{hash}.{extension}"))
cache_dir.join(format!("{}.{}", hash, extension))
}
fn hash_path(s: &str) -> String {
@@ -355,46 +178,3 @@ fn fit_dimensions(width: u32, height: u32, max_size: u32) -> (u32, u32) {
(scaled_width.max(1), max_size)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scale_numerator_picks_smallest_sufficient() {
assert_eq!(scale_numerator(6000, THUMB_SIZE), 1);
assert_eq!(scale_numerator(640, THUMB_SIZE), 4);
assert_eq!(scale_numerator(320, THUMB_SIZE), 8);
assert_eq!(scale_numerator(100, THUMB_SIZE), 8);
// Cover mode reference: shortest side must reach 224 for CLIP.
assert_eq!(scale_numerator(1200, 224), 2);
}
#[test]
fn jpeg_fast_path_decodes_at_reduced_resolution() {
let dir = std::env::temp_dir().join("phokus-thumb-test");
std::fs::create_dir_all(&dir).unwrap();
let src_path = dir.join("large.jpg");
let img =
image::RgbImage::from_fn(1600, 1200, |x, _| image::Rgb([(x % 256) as u8, 64, 128]));
img.save(&src_path).unwrap();
// 1600 max dim -> numerator 2 -> 400x300 decode output
let decoded = decode_jpeg_scaled(&src_path, THUMB_SIZE, false)
.expect("fast path should handle plain JPEG");
assert_eq!((decoded.width(), decoded.height()), (400, 300));
// Cover mode: shortest side (1200) must stay >= 224 -> numerator 2
let covered =
decode_jpeg_scaled(&src_path, 224, true).expect("fast path should handle plain JPEG");
assert_eq!((covered.width(), covered.height()), (400, 300));
let cache = dir.join("cache");
let _ = std::fs::remove_file(thumb_path(&cache, &src_path.to_string_lossy()));
let result = generate_image_thumbnail(&src_path, &cache).unwrap();
assert_eq!(result.width, Some(1600));
assert_eq!(result.height, Some(1200));
let (thumb_w, thumb_h) = image::image_dimensions(&result.path).unwrap();
assert_eq!((thumb_w, thumb_h), (320, 240));
}
}
+11 -39
View File
@@ -10,14 +10,7 @@ static SQLITE_VEC_INIT: Once = Once::new();
pub fn register_sqlite_vec() {
SQLITE_VEC_INIT.call_once(|| unsafe {
sqlite3_auto_extension(Some(std::mem::transmute::<
*const (),
unsafe extern "C" fn(
*mut rusqlite::ffi::sqlite3,
*mut *mut std::os::raw::c_char,
*const rusqlite::ffi::sqlite3_api_routines,
) -> i32,
>(sqlite3_vec_init as *const ())));
sqlite3_auto_extension(Some(std::mem::transmute(sqlite3_vec_init as *const ())));
});
}
@@ -25,38 +18,21 @@ pub fn migrate(conn: &Connection) -> Result<()> {
conn.execute_batch(&format!(
"CREATE VIRTUAL TABLE IF NOT EXISTS image_vec USING vec0(
image_id INTEGER PRIMARY KEY,
embedding FLOAT[{CLIP_VECTOR_DIM}] distance_metric=cosine
embedding FLOAT[{}] distance_metric=cosine
);
CREATE VIRTUAL TABLE IF NOT EXISTS caption_vec USING vec0(
image_id INTEGER PRIMARY KEY,
embedding FLOAT[{CLIP_VECTOR_DIM}] distance_metric=cosine
);"
embedding FLOAT[{}] distance_metric=cosine
);",
CLIP_VECTOR_DIM, CLIP_VECTOR_DIM
))?;
Ok(())
}
/// Drop and recreate the vector tables at the current `CLIP_VECTOR_DIM`. Used by
/// the "Rebuild semantic index" maintenance action when stored vectors no longer
/// match the active model's dimension (e.g. after switching embedding models),
/// so the columns are rebuilt to the right size before embeddings regenerate.
pub fn rebuild_tables(conn: &Connection) -> Result<()> {
conn.execute_batch(
"DROP TABLE IF EXISTS image_vec;
DROP TABLE IF EXISTS caption_vec;",
)?;
migrate(conn)
}
#[allow(dead_code)]
pub fn delete_embedding(conn: &Connection, image_id: i64) -> Result<()> {
conn.execute("DELETE FROM image_vec WHERE image_id = ?1", [image_id])?;
// Advance the revision so any cached HNSW index is invalidated after deletions.
conn.execute(
"INSERT INTO app_kv (key, value) VALUES ('embedding_revision', 1)
ON CONFLICT(key) DO UPDATE SET value = value + 1",
[],
)?;
Ok(())
}
@@ -239,17 +215,13 @@ pub fn get_image_embedding(conn: &Connection, image_id: i64) -> Result<Option<Ve
}
pub fn get_embedding_revision(conn: &Connection) -> Result<String> {
// Use the monotonically incremented app_kv counter so that two embeddings
// saved within the same clock second still advance the revision, preventing
// the HNSW cache from serving stale vectors.
let revision: i64 = conn
.query_row(
"SELECT COALESCE((SELECT value FROM app_kv WHERE key = 'embedding_revision'), 0)",
let count: i64 = conn.query_row("SELECT COUNT(*) FROM image_vec", [], |row| row.get(0))?;
let max_updated_at: Option<String> = conn.query_row(
"SELECT MAX(embedding_updated_at) FROM images WHERE embedding_status = 'ready'",
[],
|row| row.get(0),
)
.unwrap_or(0);
Ok(revision.to_string())
)?;
Ok(format!("{}:{}", count, max_updated_at.unwrap_or_default()))
}
// fn image_ids_for_folder(
@@ -483,7 +455,7 @@ pub fn has_image_vector(conn: &Connection, image_id: i64) -> Result<bool> {
#[allow(dead_code)]
fn pack_f32(values: &[f32]) -> Vec<u8> {
let mut out = Vec::with_capacity(std::mem::size_of_val(values));
let mut out = Vec::with_capacity(values.len() * std::mem::size_of::<f32>());
for value in values {
out.extend_from_slice(&value.to_le_bytes());
}
+4 -20
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Phokus",
"version": null,
"version": "0.1.0",
"identifier": "wtf.jezz.phokus",
"build": {
"beforeDevCommand": "pnpm dev:vite",
@@ -22,34 +22,18 @@
}
],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost data: blob:; media-src 'self' asset: http://asset.localhost; connect-src 'self' ipc: http://ipc.localhost; font-src 'self' data:; object-src 'none'",
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost data: blob:; media-src 'self' asset: http://asset.localhost; connect-src 'self' ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420; font-src 'self' data:; object-src 'none'",
"csp": null,
"assetProtocol": {
"enable": true,
"scope": [
"$APPDATA/thumbnails/**"
"**"
]
}
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDYyMDVBQzkyOENENjYzOTUKUldTVlk5YU1rcXdGWW9JRklYdHpOVXA1MDNMVEpyell6cVlma0VGS3pYaUVBLzJydy9nNUtJdlUK",
"endpoints": [
"https://github.com/JezzWTF/phokus/releases/latest/download/latest.json"
]
}
},
"bundle": {
"active": true,
"targets": ["nsis"],
"createUpdaterArtifacts": true,
"publisher": "JezzWTF",
"license": "MIT",
"copyright": "Copyright © 2026 JezzWTF",
"shortDescription": "Local-first desktop media library",
"longDescription": "A local-first desktop media library for browsing, filtering, and curating image and video folders, with AI tagging, semantic search, and duplicate detection — all processed on-device.",
"homepage": "https://git.jezz.wtf/jezzwtf/phokus",
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
-17
View File
@@ -1,17 +0,0 @@
{
"$schema": "https://schema.tauri.app/config/2",
"plugins": {
"updater": {
"endpoints": [
"https://github.com/JezzWTF/phokus/releases/latest/download/latest-cuda.json"
]
}
},
"bundle": {
"windows": {
"nsis": {
"installerHooks": "./windows/cuda-hooks.nsh"
}
}
}
}
-35
View File
@@ -1,35 +0,0 @@
; NSIS hooks for the CUDA build variant.
;
; The CUDA exe load-time-imports the CUDA runtime DLLs, so they must sit next
; to phokus.exe ($INSTDIR) where the Windows loader searches. Rather than
; bundling them as Tauri "resources" (which land in $INSTDIR\resources\) and
; then relocating them — a move that proved unreliable in the elevated/silent
; installer (CopyFiles via SHFileOperation, and even Rename, failed) — we embed
; them directly into the installer with NSIS `File` and extract them straight
; to $INSTDIR. `File` is the standard, reliable install primitive.
;
; This hook is !included from its original location, and ${__FILEDIR__} in this
; top-level !define expands at *include* time to this file's own directory,
; src-tauri\windows. cuda-redist is its sibling under src-tauri, so one level
; up. (Caveat that bit us: ${__FILEDIR__} used inline *inside the macro* would
; instead expand at insertion time to Tauri's generated nsis dir — hence the
; !define here.) The 4 DLLs must be present in src-tauri\cuda-redist at build
; time (see RELEASE_PLAN.md).
!define CUDA_REDIST "${__FILEDIR__}\..\cuda-redist"
!macro NSIS_HOOK_POSTINSTALL
DetailPrint "Phokus: installing bundled CUDA runtime libraries..."
SetOutPath "$INSTDIR"
File "${CUDA_REDIST}\cudart64_12.dll"
File "${CUDA_REDIST}\cublas64_12.dll"
File "${CUDA_REDIST}\cublasLt64_12.dll"
File "${CUDA_REDIST}\curand64_10.dll"
!macroend
!macro NSIS_HOOK_POSTUNINSTALL
Delete "$INSTDIR\cudart64_12.dll"
Delete "$INSTDIR\cublas64_12.dll"
Delete "$INSTDIR\cublasLt64_12.dll"
Delete "$INSTDIR\curand64_10.dll"
!macroend
+1 -31
View File
@@ -7,13 +7,8 @@ import { Gallery } from "./components/Gallery";
import { Lightbox } from "./components/Lightbox";
import { TagCloud } from "./components/TagCloud";
import { DuplicateFinder } from "./components/DuplicateFinder";
import { Timeline } from "./components/Timeline";
import { TitleBar } from "./components/TitleBar";
import { SettingsModal } from "./components/SettingsModal";
import { FolderPickerModal } from "./components/FolderPickerModal";
import { UpdateToast } from "./components/UpdateToast";
import { OnboardingOverlay } from "./components/onboarding/OnboardingOverlay";
import { DemoPanel } from "./components/DemoPanel";
import { initializeNotifications } from "./notifications";
export default function App() {
@@ -22,26 +17,11 @@ export default function App() {
const loadImages = useGalleryStore((state) => state.loadImages);
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus);
const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache);
const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds);
const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused);
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
const loadAppVersion = useGalleryStore((state) => state.loadAppVersion);
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
const loadFfmpegStatus = useGalleryStore((state) => state.loadFfmpegStatus);
const loadOnboardingCompleted = useGalleryStore((state) => state.loadOnboardingCompleted);
const activeView = useGalleryStore((state) => state.activeView);
useEffect(() => {
void initializeNotifications();
void loadMutedFolderIds();
void loadNotificationsPaused();
void loadAppVersion();
void loadFfmpegStatus();
void loadOnboardingCompleted();
// Quiet launch check — dev builds have no signed artifacts to update to.
if (import.meta.env.PROD) {
void checkForUpdates({ quiet: true });
}
loadFolders().then(() => {
void loadBackgroundJobProgress();
void loadCaptionModelStatus();
@@ -66,13 +46,7 @@ export default function App() {
<div className="flex flex-1 min-h-0">
<Sidebar />
<main className="flex-1 flex flex-col min-w-0">
{activeView === "timeline" ? (
<>
<Toolbar />
<BackgroundTasks />
<Timeline />
</>
) : activeView === "explore" ? (
{activeView === "explore" ? (
<>
<BackgroundTasks />
<TagCloud />
@@ -94,10 +68,6 @@ export default function App() {
<Lightbox />
<SettingsModal />
<FolderPickerModal />
<UpdateToast />
<OnboardingOverlay />
{import.meta.env.DEV && <DemoPanel />}
</div>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

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

After

Width:  |  Height:  |  Size: 4.0 KiB

+94 -152
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { useGalleryStore, WorkerKey } from "../store";
import { useGalleryStore } from "../store";
type WorkerKey = "thumbnail" | "metadata" | "embedding" | "tagging";
const WORKER_FOR_STAGE: Record<string, WorkerKey> = {
Thumbnails: "thumbnail",
@@ -23,7 +24,6 @@ interface Task {
stages: TaskStage[];
hasFailedEmbeddings: boolean;
hasFailedTagging: boolean;
hasFailedCaptions: boolean;
pendingMediaWork: number;
embeddingProcessed: number;
embeddingTotal: number;
@@ -31,109 +31,106 @@ interface Task {
snapshot: string;
}
interface FailedWorkerItem {
interface FailedEmbeddingItem {
image_id: number;
filename: string;
path: string;
error: string | null;
}
function FailedWorkerItemRow({ item }: { item: FailedWorkerItem }) {
return (
<div className="flex min-w-0 items-start gap-1.5">
<svg className="mt-px h-2.5 w-2.5 shrink-0 text-amber-500 light-theme:text-amber-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<div className="min-w-0 flex-1">
<p className="truncate text-[10px] font-medium text-amber-400/80 light-theme:text-amber-700">{item.filename}</p>
{item.error && (
<p className="truncate text-[9px] text-gray-600">{item.error}</p>
)}
</div>
<button
className="shrink-0 text-gray-700 transition-colors hover:text-gray-300 light-theme:text-gray-600 light-theme:hover:text-gray-100"
title="Reveal in Explorer"
onClick={() => void revealItemInDir(item.path)}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
</button>
</div>
);
interface FolderWorkerStates {
folder_id: number;
thumbnail_paused: boolean;
metadata_paused: boolean;
embedding_paused: boolean;
tagging_paused: boolean;
}
const DEFAULT_PAUSED_STATE: Record<WorkerKey, boolean> = {
thumbnail: false,
metadata: false,
embedding: false,
tagging: false,
};
export function BackgroundTasks() {
const folders = useGalleryStore((state) => state.folders);
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs);
const showFailedTagging = useGalleryStore((state) => state.showFailedTagging);
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs);
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
const [expanded, setExpanded] = useState(false);
const [dismissed, setDismissed] = useState<Record<number, string>>({});
const [failedEmbeddingItems, setFailedEmbeddingItems] = useState<Record<number, FailedWorkerItem[]>>({});
const [failedTaggingItems, setFailedTaggingItems] = useState<Record<number, FailedWorkerItem[]>>({});
const workerPaused = useGalleryStore((state) => state.workerPaused);
const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates);
const setWorkerPaused = useGalleryStore((state) => state.setWorkerPaused);
const [paused, setPaused] = useState<Record<number, Record<WorkerKey, boolean>>>({});
const [failedItems, setFailedItems] = useState<Record<number, FailedEmbeddingItem[]>>({});
useEffect(() => {
void loadWorkerStates();
}, [folders, loadWorkerStates]);
const folderIds = folders.map((folder) => folder.id);
if (folderIds.length === 0) {
setPaused({});
return;
}
// Fetch failed filenames whenever the expanded panel opens or failure counts change.
const failedEmbeddingCounts = useMemo(
invoke<FolderWorkerStates[]>("get_worker_states", { folderIds }).then((states) => {
setPaused(
Object.fromEntries(
states.map((state) => [
state.folder_id,
{
thumbnail: state.thumbnail_paused,
metadata: state.metadata_paused,
embedding: state.embedding_paused,
tagging: state.tagging_paused,
},
]),
),
);
});
}, [folders]);
// Fetch failed embedding filenames whenever the expanded panel opens or failure counts change.
const failedCounts = useMemo(
() =>
Object.fromEntries(
Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.embedding_failed ?? 0]),
),
[mediaJobProgress],
);
const failedTaggingCounts = useMemo(
() =>
Object.fromEntries(
Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.tagging_failed ?? 0]),
),
[mediaJobProgress],
);
useEffect(() => {
if (!expanded) return;
for (const [folderId, count] of Object.entries(failedEmbeddingCounts)) {
for (const [folderId, count] of Object.entries(failedCounts)) {
if (count > 0) {
invoke<FailedWorkerItem[]>("get_failed_embedding_images", {
invoke<FailedEmbeddingItem[]>("get_failed_embedding_images", {
folderId: Number(folderId),
})
.then((items) => setFailedEmbeddingItems((prev) => ({ ...prev, [folderId]: items })))
.then((items) => setFailedItems((prev) => ({ ...prev, [folderId]: items })))
.catch(() => undefined);
}
}
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]);
}, [expanded, failedCounts]);
const isWorkerPaused = (folderId: number, worker: WorkerKey) => {
return workerPaused[folderId]?.[worker] ?? false;
return paused[folderId]?.[worker] ?? DEFAULT_PAUSED_STATE[worker];
};
const toggleWorker = (folderId: number, worker: WorkerKey) => {
setWorkerPaused(folderId, worker, !isWorkerPaused(folderId, worker));
const next = !isWorkerPaused(folderId, worker);
setPaused((prev) => ({
...prev,
[folderId]: {
...DEFAULT_PAUSED_STATE,
...prev[folderId],
[worker]: next,
},
}));
void invoke("set_worker_paused", { worker, folderId, paused: next });
};
const dismissTask = (id: number, snapshot: string) => {
if (id < 0) return; // system tasks (duplicate scan) cannot be dismissed
void clearTaggingJobs(id);
setDismissed((prev) => ({ ...prev, [id]: snapshot }));
setExpanded(false);
};
@@ -152,22 +149,16 @@ export function BackgroundTasks() {
const taggingPending = jobs?.tagging_pending ?? 0;
const taggingReady = jobs?.tagging_ready ?? 0;
const taggingFailed = jobs?.tagging_failed ?? 0;
const captionPending = jobs?.caption_pending ?? 0;
const captionReady = jobs?.caption_ready ?? 0;
const captionFailed = jobs?.caption_failed ?? 0;
const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending + taggingPending + captionPending;
const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending + taggingPending;
const embeddingProcessed = embeddingReady + embeddingFailed;
const embeddingTotal = embeddingProcessed + embeddingPending;
const taggingProcessed = taggingReady + taggingFailed;
const taggingTotal = taggingProcessed + taggingPending;
const captionProcessed = captionReady + captionFailed;
const captionTotal = captionProcessed + captionPending;
const hasFailedEmbeddings = embeddingFailed > 0;
const hasFailedTagging = taggingFailed > 0;
const hasFailedCaptions = captionFailed > 0;
if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings && !hasFailedTagging && !hasFailedCaptions) return null;
if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings && !hasFailedTagging) return null;
const stages: TaskStage[] = [];
@@ -219,16 +210,6 @@ export function BackgroundTasks() {
});
}
if (captionPending > 0) {
const pct = captionTotal > 0 ? (captionProcessed / captionTotal) * 100 : 0;
stages.push({
label: "Captions",
detail: `${captionProcessed.toLocaleString()} / ${captionTotal.toLocaleString()}`,
progress: pct,
failed: false,
});
}
if (hasFailedEmbeddings && pendingMediaWork === 0) {
stages.push({
label: "Failed",
@@ -247,16 +228,7 @@ export function BackgroundTasks() {
});
}
if (hasFailedCaptions && pendingMediaWork === 0) {
stages.push({
label: "Failed",
detail: `${captionFailed.toLocaleString()} captions`,
progress: null,
failed: true,
});
}
const snapshot = `${pendingMediaWork}:${embeddingFailed}:${taggingFailed}:${captionFailed}`;
const snapshot = `${pendingMediaWork}:${embeddingFailed}:${taggingFailed}`;
return {
id: folder.id,
@@ -264,7 +236,6 @@ export function BackgroundTasks() {
stages,
hasFailedEmbeddings,
hasFailedTagging,
hasFailedCaptions,
pendingMediaWork,
embeddingProcessed,
embeddingTotal,
@@ -281,22 +252,17 @@ export function BackgroundTasks() {
id: -1,
name: "Duplicate Scan",
stages: [{
label: duplicateScanProgress?.phase === "checking"
? "Checking"
: duplicateScanProgress?.phase === "confirming"
? "Confirming"
: "Hashing",
label: "Hashing",
detail: duplicateScanProgress
? `${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ""}`
? `${duplicateScanProgress.scanned.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}`
: "Starting…",
progress: duplicateScanProgress && duplicateScanProgress.total > 0
? (duplicateScanProgress.processed / duplicateScanProgress.total) * 100
? (duplicateScanProgress.scanned / duplicateScanProgress.total) * 100
: null,
failed: false,
}],
hasFailedEmbeddings: false,
hasFailedTagging: false,
hasFailedCaptions: false,
pendingMediaWork: 1,
embeddingProcessed: 0,
embeddingTotal: 0,
@@ -310,15 +276,14 @@ export function BackgroundTasks() {
const primary = allTasks[0];
const extraCount = allTasks.length - 1;
const hasFailed = tasks.some((t) => (t.hasFailedEmbeddings || t.hasFailedTagging || t.hasFailedCaptions) && t.pendingMediaWork === 0);
const hasFailed = tasks.some((t) => (t.hasFailedEmbeddings || t.hasFailedTagging) && t.pendingMediaWork === 0);
// Best progress bar value: use embedding progress if available (most informative),
// otherwise tagging progress, otherwise fall back to scanning progress, otherwise indeterminate.
const embeddingStage = primary.stages.find((s) => s.label === "Embeddings");
const taggingStage = primary.stages.find((s) => s.label === "Tags");
const scanningStage = primary.stages.find((s) => s.label === "Scanning");
const duplicateStage = primary.id === -1 ? primary.stages[0] : null;
const barProgress = embeddingStage?.progress ?? taggingStage?.progress ?? scanningStage?.progress ?? duplicateStage?.progress ?? null;
const barProgress = embeddingStage?.progress ?? taggingStage?.progress ?? scanningStage?.progress ?? null;
return (
<div className="shrink-0 border-b border-white/[0.06]">
@@ -348,14 +313,14 @@ export function BackgroundTasks() {
key={stage.label}
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${
stage.failed
? "bg-amber-500/10 text-amber-400 light-theme:border light-theme:border-amber-500/40 light-theme:bg-amber-100 light-theme:text-amber-700"
? "bg-amber-500/10 text-amber-400"
: isPaused
? "bg-white/4 text-gray-600"
: "bg-white/5 text-gray-400"
}`}
>
<span>{stage.label}</span>
<span className={`tabular-nums ${stage.failed ? "text-amber-500 light-theme:text-amber-700" : isPaused ? "text-gray-700" : "text-gray-600"}`}>
<span className={`tabular-nums ${stage.failed ? "text-amber-500" : isPaused ? "text-gray-700" : "text-gray-600"}`}>
{stage.detail}
</span>
{workerKey && (
@@ -401,27 +366,14 @@ export function BackgroundTasks() {
</span>
)}
{primary.pendingMediaWork === 0 && (primary.hasFailedEmbeddings || primary.hasFailedTagging) && (
<div className="flex shrink-0 items-center gap-1.5">
{primary.hasFailedTagging ? (
{/* Retry (failed embeddings only) */}
{primary.hasFailedEmbeddings && primary.pendingMediaWork === 0 && (
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
onClick={(e) => { e.stopPropagation(); showFailedTagging(primary.id); }}
>
Locate
</button>
) : null}
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
onClick={(e) => {
e.stopPropagation();
if (primary.hasFailedEmbeddings) void retryFailedEmbeddings(primary.id);
if (primary.hasFailedTagging) void queueTaggingJobs(primary.id);
}}
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-300 hover:bg-amber-500/20 transition-colors shrink-0"
onClick={(e) => { e.stopPropagation(); void retryFailedEmbeddings(primary.id); }}
>
Retry
</button>
</div>
)}
{/* Expand chevron (only when multiple tasks) */}
@@ -455,9 +407,8 @@ export function BackgroundTasks() {
const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings");
const taskTaggingStage = task.stages.find((s) => s.label === "Tags");
const taskScanningStage = task.stages.find((s) => s.label === "Scanning");
const taskDuplicateStage = task.id === -1 ? task.stages[0] : null;
const taskBarProgress = taskEmbeddingStage?.progress ?? taskTaggingStage?.progress ?? taskScanningStage?.progress ?? taskDuplicateStage?.progress ?? null;
const taskHasFailed = (task.hasFailedEmbeddings || task.hasFailedTagging || task.hasFailedCaptions) && task.pendingMediaWork === 0;
const taskBarProgress = taskEmbeddingStage?.progress ?? taskTaggingStage?.progress ?? taskScanningStage?.progress ?? null;
const taskHasFailed = (task.hasFailedEmbeddings || task.hasFailedTagging) && task.pendingMediaWork === 0;
return (
<div key={task.id}>
@@ -473,7 +424,7 @@ export function BackgroundTasks() {
key={stage.label}
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${
stage.failed
? "bg-amber-500/10 text-amber-400 light-theme:border light-theme:border-amber-500/40 light-theme:bg-amber-100 light-theme:text-amber-700"
? "bg-amber-500/10 text-amber-400"
: isPaused
? "bg-white/4 text-gray-600"
: "bg-white/5 text-gray-500"
@@ -485,7 +436,7 @@ export function BackgroundTasks() {
</svg>
)}
<span>{stage.label}</span>
<span className={`tabular-nums ${stage.failed ? "text-amber-500 light-theme:text-amber-700" : "text-gray-600"}`}>
<span className={`tabular-nums ${stage.failed ? "text-amber-500" : "text-gray-600"}`}>
{stage.detail}
</span>
{workerKey && (
@@ -524,25 +475,12 @@ export function BackgroundTasks() {
</div>
{taskHasFailed && (
<div className="flex shrink-0 items-center gap-1.5">
{task.hasFailedTagging ? (
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
onClick={() => showFailedTagging(task.id)}
>
Locate
</button>
) : null}
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
onClick={() => {
if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id);
if (task.hasFailedTagging) void queueTaggingJobs(task.id);
}}
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300 hover:bg-amber-500/20 transition-colors shrink-0"
onClick={() => void retryFailedEmbeddings(task.id)}
>
Retry
</button>
</div>
)}
{task.id >= 0 && (
@@ -564,18 +502,22 @@ export function BackgroundTasks() {
</p>
)}
{/* Failed worker file lists */}
{taskHasFailed && failedEmbeddingItems[task.id] && failedEmbeddingItems[task.id].length > 0 && (
{/* Failed embedding file list */}
{taskHasFailed && failedItems[task.id] && failedItems[task.id].length > 0 && (
<div className="mt-2 pl-[calc(7rem+0.75rem)] space-y-0.5">
{failedEmbeddingItems[task.id].map((item) => (
<FailedWorkerItemRow key={item.image_id} item={item} />
))}
</div>
{failedItems[task.id].map((item) => (
<div key={item.image_id} className="flex items-start gap-1.5 min-w-0">
<svg className="h-2.5 w-2.5 text-amber-500 shrink-0 mt-px" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<div className="min-w-0">
<p className="text-[10px] text-amber-400/80 truncate font-medium">{item.filename}</p>
{item.error && (
<p className="text-[9px] text-gray-600 truncate">{item.error}</p>
)}
{taskHasFailed && failedTaggingItems[task.id] && failedTaggingItems[task.id].length > 0 && (
<div className="mt-2 pl-[calc(7rem+0.75rem)] space-y-0.5">
{failedTaggingItems[task.id].map((item) => (
<FailedWorkerItemRow key={item.image_id} item={item} />
</div>
</div>
))}
</div>
)}
-220
View File
@@ -1,220 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { FolderJobProgress, useGalleryStore } from "../store";
// Dev-only screenshot/demo helper. Injects frozen UI states that are otherwise
// too transient (or unreachable) to capture: the background-worker pipeline,
// which drains in under a second, and the auto-updater flow, which never fires
// locally because there's no remote latest.json to check against.
// Gated by import.meta.env.DEV in App.tsx so the whole thing is tree-shaken out
// of production builds.
//
// Toggle the controls with Ctrl+Shift+D. Injected state persists in the store
// while the panel is hidden, so: open → inject → hide → screenshot.
function emptyProgress(folderId: number): FolderJobProgress {
return {
folder_id: folderId,
thumbnail_pending: 0,
metadata_pending: 0,
embedding_pending: 0,
embedding_ready: 0,
embedding_failed: 0,
caption_pending: 0,
caption_ready: 0,
caption_failed: 0,
tagging_pending: 0,
tagging_ready: 0,
tagging_failed: 0,
};
}
// A believable multi-folder "busy pipeline" — three folders at different stages.
const BUSY_PRESETS: Partial<FolderJobProgress>[] = [
// Embeddings actively running, tagging queued behind it.
{ embedding_ready: 34, embedding_pending: 16, tagging_pending: 50 },
// Just started: thumbnails draining, everything else queued.
{ thumbnail_pending: 11, embedding_pending: 50, tagging_pending: 50 },
// Late stage: embeddings done, tagging running.
{ embedding_ready: 40, tagging_ready: 18, tagging_pending: 22 },
];
const DEMO_UPDATE_VERSION = "0.2.0";
export function DemoPanel() {
const folders = useGalleryStore((state) => state.folders);
const [open, setOpen] = useState(false);
const downloadTimer = useRef<number | null>(null);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === "d") {
event.preventDefault();
setOpen((value) => !value);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const stopTimer = () => {
if (downloadTimer.current !== null) {
window.clearInterval(downloadTimer.current);
downloadTimer.current = null;
}
};
// Stop any running download animation when the panel unmounts.
useEffect(() => stopTimer, []);
const injectBusy = () => {
const progress: Record<number, FolderJobProgress> = {};
folders.slice(0, BUSY_PRESETS.length).forEach((folder, index) => {
progress[folder.id] = { ...emptyProgress(folder.id), ...BUSY_PRESETS[index] };
});
useGalleryStore.setState({ mediaJobProgress: progress });
};
const injectSingleEmbedding = () => {
const folder = folders[0];
if (!folder) return;
useGalleryStore.setState({
mediaJobProgress: {
[folder.id]: { ...emptyProgress(folder.id), embedding_ready: 27, embedding_pending: 23 },
},
});
};
const clear = () => useGalleryStore.setState({ mediaJobProgress: {} });
// --- Updater flow ---------------------------------------------------------
// These drive the real UpdateToast + Settings "About" row. The Install button
// stays inert (there's no real pendingUpdate), so this validates the
// presentation only — see DemoPanel's header note for the real e2e path.
const updateAvailable = () => {
stopTimer();
useGalleryStore.setState({
updateStatus: "available",
updateVersion: DEMO_UPDATE_VERSION,
updateProgress: null,
updateError: null,
updateDismissed: false,
});
};
// Indeterminate pulse, then climb 0 → 100%, then flip to "installing".
const simulateDownload = () => {
stopTimer();
useGalleryStore.setState({
updateStatus: "downloading",
updateVersion: DEMO_UPDATE_VERSION,
updateProgress: null,
updateError: null,
updateDismissed: false,
});
let progress = 0;
window.setTimeout(() => {
downloadTimer.current = window.setInterval(() => {
progress = Math.min(progress + 0.07, 1);
useGalleryStore.setState({ updateProgress: progress });
if (progress >= 1) {
stopTimer();
useGalleryStore.setState({ updateStatus: "installing" });
}
}, 200);
}, 700);
};
const updateInstalling = () => {
stopTimer();
useGalleryStore.setState({
updateStatus: "installing",
updateVersion: DEMO_UPDATE_VERSION,
updateProgress: 1,
updateDismissed: false,
});
};
const updateError = () => {
stopTimer();
useGalleryStore.setState({
updateStatus: "error",
updateError: "Could not reach the update server (connection timed out).",
updateDismissed: false,
});
};
const updateUpToDate = () => {
stopTimer();
useGalleryStore.setState({ updateStatus: "upToDate", updateVersion: null, updateError: null });
};
const resetUpdate = () => {
stopTimer();
useGalleryStore.setState({
updateStatus: "idle",
updateVersion: null,
updateProgress: null,
updateError: null,
updateDismissed: false,
});
};
if (!open) return null;
const injectBtn =
"rounded-md border border-amber-400/30 bg-amber-500/15 px-2 py-1.5 text-left hover:bg-amber-500/25";
const neutralBtn =
"rounded-md border border-white/10 bg-white/[0.06] px-2 py-1.5 text-left text-gray-300 hover:bg-white/10";
return (
<div className="fixed bottom-4 left-4 z-[100] max-h-[calc(100vh-2rem)] w-56 overflow-y-auto rounded-lg border border-amber-400/40 bg-amber-950/90 p-3 text-xs text-amber-100 shadow-xl backdrop-blur">
<div className="mb-2 flex items-center justify-between">
<span className="font-semibold uppercase tracking-wide">Demo · Ctrl+Shift+D</span>
</div>
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-amber-200/60">Pipeline</p>
<p className="mb-2 text-[11px] leading-snug text-amber-200/70">
Inject a frozen worker-bar state, hide this panel, then screenshot.
</p>
<div className="flex flex-col gap-1.5">
<button className={injectBtn} onClick={injectBusy}>
Pipeline: busy (3 folders)
</button>
<button className={injectBtn} onClick={injectSingleEmbedding}>
Pipeline: embedding (1 folder)
</button>
<button className={neutralBtn} onClick={clear}>
Clear injected state
</button>
</div>
<div className="my-3 h-px bg-amber-400/20" />
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-amber-200/60">Update flow</p>
<p className="mb-2 text-[11px] leading-snug text-amber-200/70">
Drives the real toast (bottom-right) + Settings About row. Install is inert here.
</p>
<div className="flex flex-col gap-1.5">
<button className={injectBtn} onClick={updateAvailable}>
Update available
</button>
<button className={injectBtn} onClick={simulateDownload}>
Simulate download install
</button>
<button className={injectBtn} onClick={updateInstalling}>
Installing
</button>
<button className={injectBtn} onClick={updateError}>
Check failed (error)
</button>
<button className={injectBtn} onClick={updateUpToDate}>
Up to date
</button>
<button className={neutralBtn} onClick={resetUpdate}>
Reset updater state
</button>
</div>
</div>
);
}
+14 -61
View File
@@ -1,8 +1,6 @@
import { useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useState } from "react";
import { convertFileSrc } from "@tauri-apps/api/core";
import { DuplicateGroup, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
function formatBytes(bytes: number): string {
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
@@ -72,7 +70,7 @@ function DuplicateGroupCard({ group }: { group: DuplicateGroup }) {
return (
<button
key={image.id}
className={`media-dark-surface group relative overflow-hidden rounded-xl border transition-all ${
className={`group relative overflow-hidden rounded-xl border transition-all ${
isSelected
? "border-red-400/50 ring-1 ring-red-400/30"
: "border-white/8 hover:border-white/20"
@@ -118,8 +116,6 @@ export function DuplicateFinder() {
const duplicateGroups = useGalleryStore((state) => state.duplicateGroups);
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
const duplicateScanError = useGalleryStore((state) => state.duplicateScanError);
const duplicateScanWarning = useGalleryStore((state) => state.duplicateScanWarning);
const duplicateSelectedIds = useGalleryStore((state) => state.duplicateSelectedIds);
const duplicateLastScanned = useGalleryStore((state) => state.duplicateLastScanned);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
@@ -131,17 +127,6 @@ export function DuplicateFinder() {
const [deleting, setDeleting] = useState(false);
const [deleteResult, setDeleteResult] = useState<string | null>(null);
// Virtualize the group list so a large result set (e.g. thousands of pairs)
// only mounts the on-screen cards. Group cards vary in height (number of
// copies wraps across rows), so heights are measured dynamically.
const scrollRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: duplicateGroups.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 220,
overscan: 4,
});
const selectedCount = duplicateSelectedIds.size;
const hasResults = duplicateGroups.length > 0;
const hasScanned = hasResults || duplicateLastScanned !== null || (!duplicateScanning && duplicateScanProgress !== null);
@@ -166,18 +151,11 @@ export function DuplicateFinder() {
const progressPercent =
duplicateScanProgress && duplicateScanProgress.total > 0
? Math.round((duplicateScanProgress.processed / duplicateScanProgress.total) * 100)
? Math.round((duplicateScanProgress.scanned / duplicateScanProgress.total) * 100)
: 0;
const progressLabel = duplicateScanProgress
? duplicateScanProgress.phase === "checking"
? "Checking file sizes"
: duplicateScanProgress.phase === "hashing"
? "Hashing duplicate candidates"
: "Confirming exact matches"
: null;
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-gray-950">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[#07080f]">
{/* Header */}
<div className="shrink-0 border-b border-white/[0.05] px-6 py-4">
<div className="flex items-center justify-between gap-4">
@@ -186,7 +164,7 @@ export function DuplicateFinder() {
<p className="mt-0.5 text-[11px] text-white/30">
{duplicateScanning
? duplicateScanProgress
? `${progressLabel}${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ""}`
? `Scanning${duplicateScanProgress.scanned.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}`
: "Starting scan…"
: hasResults
? `${duplicateGroups.length} group${duplicateGroups.length === 1 ? "" : "s"} · ${formatBytes(totalWasted)} reclaimable`
@@ -201,11 +179,10 @@ export function DuplicateFinder() {
)}
</div>
<div className="flex items-center gap-2">
<FolderScopeDropdown />
{/* Batch select — only shown when there are groups and nothing is selected yet */}
{hasResults && selectedCount === 0 && !deleting && (
<button
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white 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"
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white"
onClick={selectKeepFirstAllGroups}
title={`Mark ${totalDuplicateImages} duplicate${totalDuplicateImages === 1 ? "" : "s"} for deletion across all groups (keeps first in each)`}
>
@@ -216,7 +193,7 @@ export function DuplicateFinder() {
<>
<span className="text-[11px] text-white/40">{selectedCount} marked for deletion</span>
<button
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white disabled:opacity-40"
onClick={clearDuplicateSelection}
disabled={deleting}
>
@@ -232,7 +209,7 @@ export function DuplicateFinder() {
</>
) : null}
<button
className="rounded-lg border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
className="rounded-lg border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => { setDeleteResult(null); void scanDuplicates(selectedFolderId); }}
disabled={duplicateScanning}
>
@@ -251,12 +228,6 @@ export function DuplicateFinder() {
</div>
) : null}
{duplicateScanError ? (
<p className="mt-2 text-[11px] text-red-400/80">{duplicateScanError}</p>
) : null}
{duplicateScanWarning ? (
<p className="mt-2 text-[11px] text-amber-300/70">{duplicateScanWarning}</p>
) : null}
{deleteResult ? (
<p className="mt-2 text-[11px] text-white/40">{deleteResult}</p>
) : null}
@@ -266,7 +237,7 @@ export function DuplicateFinder() {
{duplicateScanning && !hasResults ? (
<div className="flex flex-1 items-center justify-center gap-3 text-white/25">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/15 border-t-white/50" />
<span className="text-sm">{progressLabel ? `${progressLabel}` : "Preparing scan…"}</span>
<span className="text-sm">Hashing files</span>
</div>
) : !hasScanned ? (
<div className="flex flex-1 items-center justify-center px-8">
@@ -289,29 +260,11 @@ export function DuplicateFinder() {
<p className="text-sm text-white/25">No duplicate files found.</p>
</div>
) : (
<div ref={scrollRef} className="overflow-y-auto px-6 py-5">
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const group = duplicateGroups[virtualItem.index];
if (!group) return null;
return (
<div
key={group.file_hash}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualItem.start}px)`,
paddingBottom: 16,
}}
>
<DuplicateGroupCard group={group} />
</div>
);
})}
<div className="overflow-y-auto px-6 py-5">
<div className="space-y-4">
{duplicateGroups.map((group) => (
<DuplicateGroupCard key={group.file_hash} group={group} />
))}
</div>
</div>
)}
-449
View File
@@ -1,449 +0,0 @@
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>
);
}
-98
View File
@@ -1,98 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useGalleryStore } from "../store";
/**
* In-view folder scope picker for feature views (Timeline / Explore /
* Duplicates). Changes the scope via setViewFolderScope, which keeps the
* current view active — unlike sidebar folder clicks, which jump to Gallery.
*/
export function FolderScopeDropdown() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const folders = useGalleryStore((state) => state.folders);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const setViewFolderScope = useGalleryStore((state) => state.setViewFolderScope);
useEffect(() => {
const close = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
window.addEventListener("pointerdown", close);
return () => window.removeEventListener("pointerdown", close);
}, []);
const currentLabel =
selectedFolderId === null
? "All Media"
: folders.find((folder) => folder.id === selectedFolderId)?.name ?? "All Media";
const select = (folderId: number | null) => {
setViewFolderScope(folderId);
setOpen(false);
};
return (
<div ref={ref} className="feature-scope-dropdown relative">
<button
onClick={() => setOpen((v) => !v)}
className={`feature-scope-trigger flex max-w-56 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${
open
? "border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white"
: "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:border-gray-700/40 light-theme:text-gray-600 light-theme:hover:border-gray-700 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
title="Change folder scope"
>
<svg className="h-3.5 w-3.5 shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="truncate">{currentLabel}</span>
<svg
className={`h-3 w-3 shrink-0 text-gray-500 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open ? (
<div className="feature-scope-menu absolute right-0 top-full z-30 mt-1.5 max-h-80 min-w-52 overflow-y-auto rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur light-theme:border-gray-700/50">
<button
className={`feature-scope-option flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
selectedFolderId === null ? "bg-white/6 text-white light-theme:bg-gray-900 light-theme:text-white" : "text-gray-400 hover:bg-white/5 hover:text-white light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => select(null)}
>
All Media
{selectedFolderId === null ? (
<svg className="h-3.5 w-3.5 shrink-0 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : null}
</button>
{folders.map((folder) => {
const active = selectedFolderId === folder.id;
return (
<button
key={folder.id}
className={`feature-scope-option flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
active ? "bg-white/6 text-white light-theme:bg-gray-900 light-theme:text-white" : "text-gray-400 hover:bg-white/5 hover:text-white light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => select(folder.id)}
>
<span className="min-w-0 truncate">{folder.name}</span>
<span className="flex shrink-0 items-center gap-2">
<span className="text-[11px] tabular-nums text-gray-600">{folder.image_count.toLocaleString()}</span>
{active ? (
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : null}
</span>
</button>
);
})}
</div>
) : null}
</div>
);
}
+26 -72
View File
@@ -1,5 +1,4 @@
import { useEffect, useLayoutEffect, useRef, useCallback, useMemo, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useEffect, useRef, useCallback, useState } from "react";
import { convertFileSrc } from "@tauri-apps/api/core";
import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store";
@@ -17,7 +16,7 @@ function formatDuration(durationMs: number | null): string | null {
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
export function ContextMenu({
function ContextMenu({
x,
y,
image,
@@ -105,7 +104,7 @@ export function ContextMenu({
);
}
export function ImageTile({
function ImageTile({
image,
onClick,
onContextMenu,
@@ -120,11 +119,15 @@ export function ImageTile({
const similarScope = useGalleryStore((state) => state.similarScope);
const canFindSimilar = image.embedding_status === "ready";
const src = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) : null;
const src = image.thumbnail_path
? convertFileSrc(image.thumbnail_path)
: image.media_kind === "image" && image.path
? convertFileSrc(image.path)
: null;
return (
<button
className="media-dark-surface group relative overflow-hidden rounded-xl bg-white/[0.04] text-left focus:outline-none"
className="group relative overflow-hidden rounded-xl bg-white/[0.04] text-left focus:outline-none"
style={{ width: "100%", aspectRatio: "1 / 1" }}
onClick={onClick}
onContextMenu={onContextMenu}
@@ -265,62 +268,29 @@ export function Gallery() {
const parsedSearch = parseSearchValue(search);
const parentRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null);
useLayoutEffect(() => {
const el = parentRef.current;
if (!el) return;
setContainerWidth(el.clientWidth);
const ro = new ResizeObserver((entries) => {
setContainerWidth(entries[0].contentRect.width);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const tileSize = tileSizeForZoom(zoomPreset);
const cols = useMemo(
() => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))),
[containerWidth, tileSize],
);
const rowCount = Math.ceil(images.length / cols);
const estimateSize = useCallback(() => tileSize + GAP, [tileSize]);
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => parentRef.current,
estimateSize,
overscan: 3,
paddingStart: GAP,
});
useEffect(() => {
virtualizer.measure();
}, [cols, virtualizer]);
useEffect(() => {
parentRef.current?.scrollTo({ top: 0, left: 0 });
}, [galleryScrollResetKey]);
const handleScroll = useCallback(() => {
const el = parentRef.current;
if (!el) return;
if (el.scrollTop < 24) return;
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600;
const element = parentRef.current;
if (!element) return;
if (element.scrollTop < 24) return;
const nearBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 600;
if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages();
}
}, [images.length, loadMoreImages, loadingImages, totalImages]);
useEffect(() => {
const el = parentRef.current;
if (!el) return;
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
const element = parentRef.current;
if (!element) return;
element.addEventListener("scroll", handleScroll, { passive: true });
return () => element.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
useEffect(() => {
parentRef.current?.scrollTo({ top: 0, left: 0 });
}, [galleryScrollResetKey]);
useEffect(() => {
const close = (event: PointerEvent) => {
if ((event.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return;
@@ -338,7 +308,7 @@ export function Gallery() {
}, []);
return (
<div ref={parentRef} className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-gray-950">
<div ref={parentRef} className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#07080f]">
{images.length === 0 && loadingImages ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 min-w-72">
@@ -395,28 +365,15 @@ export function Gallery() {
</div>
</div>
) : (
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const startIndex = virtualRow.index * cols;
const rowImages = images.slice(startIndex, startIndex + cols);
return (
<div
key={virtualRow.key}
className="grid content-start"
style={{
position: "absolute",
top: virtualRow.start,
width: "100%",
height: virtualRow.size,
display: "grid",
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
padding: GAP,
gap: GAP,
paddingLeft: GAP,
paddingRight: GAP,
paddingBottom: GAP,
boxSizing: "border-box",
gridTemplateColumns: `repeat(auto-fill, minmax(${tileSizeForZoom(zoomPreset)}px, 1fr))`,
}}
>
{rowImages.map((image) => (
{images.map((image) => (
<ImageTile
key={image.id}
image={image}
@@ -428,9 +385,6 @@ export function Gallery() {
/>
))}
</div>
);
})}
</div>
)}
{images.length > 0 && loadingImages ? (
+33 -134
View File
@@ -1,9 +1,7 @@
import { useEffect, useCallback, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { convertFileSrc } from "@tauri-apps/api/core";
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { useGalleryStore, ImageTag, AiRating } from "../store";
import { VideoPlayer } from "./VideoPlayer";
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -116,29 +114,6 @@ function rectToNormalisedCrop(
/** Minimum selection size as a fraction of the viewport container dimension. */
const MIN_SELECTION_FRACTION = 0.02;
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 4;
interface ViewTransform {
zoom: number;
panX: number;
panY: number;
}
const IDENTITY_VIEW: ViewTransform = { zoom: 1, panX: 0, panY: 0 };
/** Re-anchor the pan so the point at (anchorX, anchorY) — measured from the
* viewport centre — stays under the cursor across a zoom change. */
function zoomViewAt(view: ViewTransform, newZoom: number, anchorX: number, anchorY: number): ViewTransform {
if (newZoom <= 1) return { ...IDENTITY_VIEW, zoom: newZoom };
const ratio = newZoom / view.zoom;
return {
zoom: newZoom,
panX: anchorX - (anchorX - view.panX) * ratio,
panY: anchorY - (anchorY - view.panY) * ratio,
};
}
export function Lightbox() {
const selectedImage = useGalleryStore((state) => state.selectedImage);
const closeImage = useGalleryStore((state) => state.closeImage);
@@ -154,15 +129,7 @@ export function Lightbox() {
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
const queueTaggingForImage = useGalleryStore((state) => state.queueTaggingForImage);
// Tracks the image id that is currently displayed, used to discard async
// tag mutations that resolve after the user has navigated to another image.
const currentImageIdRef = useRef<number | null>(null);
currentImageIdRef.current = selectedImage?.id ?? null;
const [view, setView] = useState<ViewTransform>(IDENTITY_VIEW);
const zoom = view.zoom;
const [isPanning, setIsPanning] = useState(false);
const lastPanPointRef = useRef({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [imageTags, setImageTags] = useState<ImageTag[]>([]);
const [tagInput, setTagInput] = useState("");
const [tagAdding, setTagAdding] = useState(false);
@@ -196,24 +163,8 @@ export function Lightbox() {
setDragRect(null);
}, []);
// Keep the pan within bounds: when the scaled image overflows the viewport
// the image edge may not be dragged past the viewport edge; when it fits,
// the pan snaps back to centre on that axis.
const clampPan = useCallback((v: ViewTransform): ViewTransform => {
const img = imgRef.current;
const vp = imageViewportRef.current;
if (!img || !vp) return v;
const maxX = Math.max(0, (img.offsetWidth * v.zoom - vp.clientWidth) / 2);
const maxY = Math.max(0, (img.offsetHeight * v.zoom - vp.clientHeight) / 2);
return {
...v,
panX: Math.min(maxX, Math.max(-maxX, v.panX)),
panY: Math.min(maxY, Math.max(-maxY, v.panY)),
};
}, []);
useEffect(() => {
setView(IDENTITY_VIEW);
setZoom(1);
setImageTags([]);
setTagInput("");
setTagsExpanded(false);
@@ -224,13 +175,9 @@ export function Lightbox() {
useEffect(() => {
if (!selectedImage) return;
// Capture the ID so a stale response for image A cannot overwrite B's tags
// when the user navigates before the request resolves.
let cancelled = false;
void getImageTags(selectedImage.id)
.then((tags) => { if (!cancelled) setImageTags(tags); })
.catch(() => { if (!cancelled) setImageTags([]); });
return () => { cancelled = true; };
.then(setImageTags)
.catch(() => setImageTags([]));
}, [selectedImage?.id, selectedImage?.ai_tagged_at, getImageTags]);
// Reset the queued state once the worker finishes so the button is usable again
@@ -246,19 +193,15 @@ export function Lightbox() {
if (regionSelectMode) return; // don't zoom during selection
if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return;
event.preventDefault();
const bounds = viewport.getBoundingClientRect();
const anchorX = event.clientX - (bounds.left + bounds.width / 2);
const anchorY = event.clientY - (bounds.top + bounds.height / 2);
setView((v) => {
setZoom((value) => {
const delta = event.deltaY < 0 ? 0.15 : -0.15;
const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, v.zoom + delta));
return clampPan(zoomViewAt(v, next, anchorX, anchorY));
return Math.min(4, Math.max(0.5, value + delta));
});
};
viewport.addEventListener("wheel", handleWheel, { passive: false });
return () => viewport.removeEventListener("wheel", handleWheel);
}, [selectedImage, regionSelectMode, clampPan]);
}, [selectedImage, regionSelectMode]);
useEffect(() => {
const handler = (event: KeyboardEvent) => {
@@ -271,32 +214,20 @@ export function Lightbox() {
}
}
if (regionSelectMode) return; // block nav keys during selection
// Shift+arrows are reserved for video seeking (handled by VideoPlayer)
if (event.key === "ArrowLeft" && !event.shiftKey) goPrev();
if (event.key === "ArrowRight" && !event.shiftKey) goNext();
if (event.key === "+" || event.key === "=")
setView((v) => clampPan(zoomViewAt(v, Math.min(3, v.zoom + 0.25), 0, 0)));
if (event.key === "-")
setView((v) => clampPan(zoomViewAt(v, Math.max(0.75, v.zoom - 0.25), 0, 0)));
if (event.key === "ArrowLeft") goPrev();
if (event.key === "ArrowRight") goNext();
if (event.key === "+" || event.key === "=") setZoom((value) => Math.min(3, value + 0.25));
if (event.key === "-") setZoom((value) => Math.max(0.75, value - 0.25));
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [selectedImage, closeImage, goPrev, goNext, regionSelectMode, exitRegionMode, clampPan]);
}, [selectedImage, closeImage, goPrev, goNext, regionSelectMode, exitRegionMode]);
// ── Region selection / pan pointer handlers ─────────────────────────────────
// ── Region selection pointer handlers ───────────────────────────────────────
const handleRegionPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!regionSelectMode) {
// Drag-to-pan when the image is zoomed in
if (zoom > 1 && event.button === 0 && selectedImage?.media_kind === "image") {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
lastPanPointRef.current = { x: event.clientX, y: event.clientY };
setIsPanning(true);
}
return;
}
if (!regionSelectMode) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setIsDragging(true);
@@ -307,33 +238,21 @@ export function Lightbox() {
endY: event.clientY,
});
},
[regionSelectMode, zoom, selectedImage?.media_kind],
[regionSelectMode],
);
const handleRegionPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (isPanning) {
const dx = event.clientX - lastPanPointRef.current.x;
const dy = event.clientY - lastPanPointRef.current.y;
lastPanPointRef.current = { x: event.clientX, y: event.clientY };
setView((v) => clampPan({ ...v, panX: v.panX + dx, panY: v.panY + dy }));
return;
}
if (!isDragging) return;
setDragRect((prev) =>
prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null,
);
},
[isDragging, isPanning, clampPan],
[isDragging],
);
const handleRegionPointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (isPanning) {
event.currentTarget.releasePointerCapture(event.pointerId);
setIsPanning(false);
return;
}
if (!isDragging || !dragRect || !selectedImage || !imgRef.current) {
setIsDragging(false);
return;
@@ -366,7 +285,7 @@ export function Lightbox() {
void loadSimilarByRegion(selectedImage.id, crop, folderId, selectedImage.folder_id)
.finally(() => setRegionSearching(false));
},
[isPanning, isDragging, dragRect, selectedImage, similarScope, loadSimilarByRegion, exitRegionMode],
[isDragging, dragRect, selectedImage, similarScope, loadSimilarByRegion, exitRegionMode],
);
// Build the CSS rect for the selection overlay (viewport-relative)
@@ -378,7 +297,7 @@ export function Lightbox() {
{selectedImage ? (
<motion.div
key="lightbox"
className="media-dark-surface fixed inset-0 z-50 flex bg-black/90 backdrop-blur-sm"
className="fixed inset-0 z-50 flex bg-black/90 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
@@ -402,14 +321,8 @@ export function Lightbox() {
<div className="flex flex-1 overflow-hidden">
<div
ref={imageViewportRef}
className={`group relative flex flex-1 items-center justify-center overflow-hidden p-10 ${
regionSelectMode
? "cursor-crosshair select-none"
: isPanning
? "cursor-grabbing select-none"
: zoom > 1 && selectedImage.media_kind === "image"
? "cursor-grab"
: ""
className={`group relative flex flex-1 items-center justify-center overflow-auto p-10 ${
regionSelectMode ? "cursor-crosshair select-none" : ""
}`}
onPointerDown={handleRegionPointerDown}
onPointerMove={handleRegionPointerMove}
@@ -443,18 +356,19 @@ export function Lightbox() {
<AnimatePresence mode="wait">
<motion.div
key={selectedImage.id}
className={
selectedImage.media_kind === "video"
? "absolute inset-0"
: "flex items-center justify-center"
}
className="flex items-center justify-center"
initial={{ opacity: 0.3, scale: 0.985 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0.3, scale: 0.985 }}
transition={{ duration: 0.12 }}
>
{selectedImage.media_kind === "video" ? (
<VideoPlayer src={convertFileSrc(selectedImage.path)} />
<video
src={convertFileSrc(selectedImage.path)}
controls
className="max-h-full max-w-full rounded-2xl shadow-2xl"
style={{ maxHeight: "calc(100vh - 10rem)" }}
/>
) : (
<>
<img
@@ -462,10 +376,9 @@ export function Lightbox() {
src={convertFileSrc(selectedImage.path)}
alt={selectedImage.filename}
className="max-w-full rounded-2xl shadow-2xl"
draggable={false}
style={{
maxHeight: "calc(100vh - 10rem)",
transform: `translate(${view.panX}px, ${view.panY}px) scale(${zoom})`,
transform: `scale(${zoom})`,
transformOrigin: "center center",
// Slightly dim the image while in region select mode
...(regionSelectMode ? { opacity: 0.85 } : {}),
@@ -476,14 +389,14 @@ export function Lightbox() {
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 py-1 backdrop-blur">
<button
className="px-2 text-sm text-gray-300 hover:text-white"
onClick={() => setView((v) => clampPan(zoomViewAt(v, Math.max(MIN_ZOOM, v.zoom - 0.25), 0, 0)))}
onClick={() => setZoom((value) => Math.max(0.5, value - 0.25))}
>
-
</button>
<span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span>
<button
className="px-2 text-sm text-gray-300 hover:text-white"
onClick={() => setView((v) => clampPan(zoomViewAt(v, Math.min(MAX_ZOOM, v.zoom + 0.25), 0, 0)))}
onClick={() => setZoom((value) => Math.min(4, value + 0.25))}
>
+
</button>
@@ -496,7 +409,7 @@ export function Lightbox() {
</AnimatePresence>
</div>
<div className="lightbox-panel flex w-72 shrink-0 flex-col border-l border-white/5 bg-gray-900/95">
<div className="flex w-72 shrink-0 flex-col border-l border-white/5 bg-gray-900/95">
<div className="flex shrink-0 items-center justify-between px-5 pt-5 pb-4">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{selectedImage.filename}</p>
@@ -751,11 +664,8 @@ export function Lightbox() {
const raw = tagInput.trim();
if (!raw || tagAdding) return;
setTagAdding(true);
const taggedImageId = selectedImage.id;
void addUserTag(taggedImageId, raw)
void addUserTag(selectedImage.id, raw)
.then((newTag) => {
// Discard if the user navigated away before the request resolved.
if (currentImageIdRef.current !== taggedImageId) return;
setImageTags((prev) => [...prev, newTag]);
setTagInput("");
})
@@ -781,18 +691,7 @@ export function Lightbox() {
</div>
<div>
<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="mb-1 text-xs uppercase tracking-wider text-gray-500">Path</p>
<p className="break-all text-xs text-gray-400">{selectedImage.path}</p>
</div>
</div>
+7 -3
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { MediaFilter, ZoomPreset, useGalleryStore } from "../store";
type MenuKey = "library" | "view" | "filter";
@@ -71,7 +72,7 @@ const FILTER_OPTIONS: { value: MediaFilter; label: string }[] = [
export function MenuBar() {
const [openMenu, setOpenMenu] = useState<MenuKey | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen);
const addFolder = useGalleryStore((state) => state.addFolder);
const reindexFolder = useGalleryStore((state) => state.reindexFolder);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
@@ -92,8 +93,11 @@ export function MenuBar() {
return () => window.removeEventListener("pointerdown", handlePointerDown);
}, []);
const handleAddFolder = () => {
setFolderPickerOpen(true);
const handleAddFolder = async () => {
const selected = await open({ directory: true, multiple: false, title: "Select Media Folder" });
if (selected && typeof selected === "string") {
await addFolder(selected);
}
setOpenMenu(null);
};
-39
View File
@@ -1,39 +0,0 @@
// Phokus brand mark — a camera iris, rendered inline so it inherits `currentColor`
// and scales to any size. Drive color + dimensions from the className, e.g.
// <PhokusMark className="h-4 w-4 text-gray-300" />
// The full app-icon tile (filled iris on a dark rounded square) lives in
// branding/phokus-aperture.svg and feeds `pnpm tauri icon`.
//
// Pass dotClassName (e.g. "fill-amber-400") to light up the central focal point —
// used in the titlebar as the "update available" indicator.
const BLADE = "M0,-4.18 A10,10 0 0 1 6.43,-7.66";
export function PhokusMark({
className,
dotClassName,
}: {
className?: string;
dotClassName?: string;
}) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className}>
<g
transform="translate(12 12)"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle r="10" />
<path d="M0,-4.18 A4.73,4.73 0 0 0 3.62,-2.09 A4.73,4.73 0 0 0 3.62,2.09 A4.73,4.73 0 0 0 0,4.18 A4.73,4.73 0 0 0 -3.62,2.09 A4.73,4.73 0 0 0 -3.62,-2.09 A4.73,4.73 0 0 0 0,-4.18 Z" />
<path d={BLADE} />
<path d={BLADE} transform="rotate(60)" />
<path d={BLADE} transform="rotate(120)" />
<path d={BLADE} transform="rotate(180)" />
<path d={BLADE} transform="rotate(240)" />
<path d={BLADE} transform="rotate(300)" />
</g>
{dotClassName ? <circle cx="12" cy="12" r="2.6" stroke="none" className={dotClassName} /> : null}
</svg>
);
}
+168 -508
View File
@@ -1,79 +1,61 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { AppTheme, CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store";
import { FfmpegStatusRow } from "./onboarding/StepWelcome";
import { ThemedDropdown } from "./ThemedDropdown";
import { useEffect, useMemo, useState } from "react";
import { TaggerAcceleration, TaggingQueueScope, useGalleryStore } from "../store";
type SettingsSection = "workspace" | "general";
type SettingsSection = "workspace" | "workers";
const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [
{ id: "general", label: "General", detail: "App data and diagnostics" },
{ id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" },
{ id: "workers", label: "Workers", detail: "Queue activity and background processing" },
];
function formatBytesShort(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
return `${(bytes / 1024).toFixed(0)} KB`;
}
function StatusPill({ children, tone }: { children: React.ReactNode; tone: "ready" | "muted" | "busy" }) {
const className =
tone === "ready"
? "border-emerald-400/25 bg-emerald-500/10 text-emerald-300 light-theme:border-emerald-600/40 light-theme:bg-emerald-100 light-theme:text-emerald-700"
? "border-emerald-400/25 bg-emerald-500/10 text-emerald-300"
: tone === "busy"
? "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-sky-400/25 bg-sky-500/10 text-sky-300"
: "border-white/10 bg-white/[0.04] text-gray-500";
return <span className={`inline-flex rounded-md border px-2 py-0.5 text-[11px] font-medium ${className}`}>{children}</span>;
return <span className={`inline-flex rounded-md border px-2 py-1 text-[11px] font-medium ${className}`}>{children}</span>;
}
function SettingsGroup({ title, description, children }: {
function SectionShell({ eyebrow, title, description, children }: {
eyebrow: string;
title: string;
description?: string;
children: React.ReactNode;
}) {
return (
<section>
<h4 className="text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">{title}</h4>
{description ? <p className="mt-1 text-xs leading-relaxed text-gray-600">{description}</p> : null}
<div className="mt-1 divide-y divide-white/[0.05]">{children}</div>
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-600">{eyebrow}</p>
<h3 className="mt-1 text-lg font-semibold text-white">{title}</h3>
{description ? <p className="mt-2 max-w-2xl text-sm leading-relaxed text-gray-500">{description}</p> : null}
<div className="mt-5 space-y-4">{children}</div>
</section>
);
}
function SettingsItem({ label, description, children, vertical = false }: {
label: React.ReactNode;
description?: React.ReactNode;
children?: React.ReactNode;
vertical?: boolean;
}) {
if (vertical) {
function SettingsCard({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
return (
<div className="py-4">
<p className="text-sm text-white">{label}</p>
{description ? <div className="mt-1 text-xs leading-relaxed text-gray-500">{description}</div> : null}
{children ? <div className="mt-3">{children}</div> : null}
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] p-5">
<div className="flex flex-col gap-1 border-b border-white/[0.07] pb-4">
<p className="text-sm font-medium text-white">{title}</p>
{description ? <p className="text-xs leading-relaxed text-gray-500">{description}</p> : null}
</div>
);
}
return (
<div className="flex items-start justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm text-white">{label}</p>
{description ? <div className="mt-1 max-w-xl text-xs leading-relaxed text-gray-500">{description}</div> : null}
</div>
<div className="shrink-0">{children}</div>
<div className="pt-4">{children}</div>
</div>
);
}
function StatPair({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) {
function SettingsRow({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
return (
<span className="inline-flex items-baseline gap-2">
<span className="text-[10px] uppercase tracking-[0.14em] text-gray-600">{label}</span>
<span className={`text-sm font-semibold tabular-nums ${accent ? "text-emerald-300" : "text-white"}`}>{value}</span>
</span>
<div className="flex items-start justify-between gap-5 border-b border-white/[0.07] py-4 last:border-b-0 first:pt-0 last:pb-0">
<div className="min-w-0">
<p className="text-sm font-medium text-white">{title}</p>
<p className="mt-1 max-w-md text-xs leading-relaxed text-gray-500">{description}</p>
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
@@ -89,8 +71,8 @@ function ScopeButton({ scope, current, onSelect, children }: {
type="button"
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700"
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200"
: "border-white/10 bg-white/[0.045] text-gray-500 hover:bg-white/[0.075] hover:text-gray-200"
}`}
onClick={() => onSelect(scope)}
>
@@ -111,8 +93,8 @@ function TaggerAccelerationButton({ acceleration, current, onSelect, children }:
type="button"
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700"
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200"
: "border-white/10 bg-white/[0.045] text-gray-500 hover:bg-white/[0.075] hover:text-gray-200"
}`}
onClick={() => onSelect(acceleration)}
>
@@ -122,42 +104,24 @@ function TaggerAccelerationButton({ acceleration, current, onSelect, children }:
}
export function SettingsModal() {
const [activeSection, setActiveSection] = useState<SettingsSection>("general");
const [activeSection, setActiveSection] = useState<SettingsSection>("workspace");
const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null);
const [taggerQueueing, setTaggerQueueing] = useState(false);
const [taggerClearing, setTaggerClearing] = useState(false);
const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false);
const [taggerAccelerationError, setTaggerAccelerationError] = useState<string | null>(null);
const [taggerThresholdDraft, setTaggerThresholdDraft] = useState<string | null>(null);
const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false);
const [taggerThresholdError, setTaggerThresholdError] = useState<string | null>(null);
const [taggerBatchSizeDraft, setTaggerBatchSizeDraft] = useState<string | null>(null);
const [taggerBatchSizeSaving, setTaggerBatchSizeSaving] = useState(false);
const [taggerBatchSizeError, setTaggerBatchSizeError] = useState<string | null>(null);
const [openingDataFolder, setOpeningDataFolder] = useState(false);
const [dbInfo, setDbInfo] = useState<DatabaseInfo | null>(null);
const [vacuuming, setVacuuming] = useState(false);
const [vacuumResult, setVacuumResult] = useState<VacuumResult | null>(null);
const [rebuildingIndex, setRebuildingIndex] = useState(false);
const [rebuildIndexResult, setRebuildIndexResult] = useState<string | null>(null);
const [thumbnailInfo, setThumbnailInfo] = useState<OrphanedThumbnailsInfo | null>(null);
const [cleaningThumbnails, setCleaningThumbnails] = useState(false);
const [thumbnailCleanupResult, setThumbnailCleanupResult] = useState<CleanupOrphanedThumbnailsResult | null>(null);
const thresholdErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const batchSizeErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const settingsOpen = useGalleryStore((state) => state.settingsOpen);
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
const folders = useGalleryStore((state) => state.folders);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const taggingQueueScope = useGalleryStore((state) => state.taggingQueueScope);
const taggingQueueFolderIds = useGalleryStore((state) => state.taggingQueueFolderIds);
const loadTaggingQueueScope = useGalleryStore((state) => state.loadTaggingQueueScope);
const setTaggingQueueScope = useGalleryStore((state) => state.setTaggingQueueScope);
const toggleTaggingQueueFolder = useGalleryStore((state) => state.toggleTaggingQueueFolder);
const setTaggingQueueFolderIds = useGalleryStore((state) => state.setTaggingQueueFolderIds);
const loadTaggingQueueFolderIds = useGalleryStore((state) => state.loadTaggingQueueFolderIds);
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
const taggerModelPreparing = useGalleryStore((state) => state.taggerModelPreparing);
const taggerModelProgress = useGalleryStore((state) => state.taggerModelProgress);
@@ -181,27 +145,6 @@ export function SettingsModal() {
const queueTaggingJobsForFolders = useGalleryStore((state) => state.queueTaggingJobsForFolders);
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs);
const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders);
const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder);
const notificationsPaused = useGalleryStore((state) => state.notificationsPaused);
const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused);
const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo);
const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase);
const rebuildSemanticIndex = useGalleryStore((state) => state.rebuildSemanticIndex);
const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo);
const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails);
const appVersion = useGalleryStore((state) => state.appVersion);
const updateStatus = useGalleryStore((state) => state.updateStatus);
const updateVersion = useGalleryStore((state) => state.updateVersion);
const updateError = useGalleryStore((state) => state.updateError);
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
const installUpdate = useGalleryStore((state) => state.installUpdate);
const openOnboarding = useGalleryStore((state) => state.openOnboarding);
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(() => {
if (!settingsOpen) return;
@@ -209,37 +152,24 @@ export function SettingsModal() {
void loadTaggerAcceleration();
void loadTaggerThreshold();
void loadTaggerBatchSize();
void loadTaggingQueueScope();
void loadTaggingQueueFolderIds();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setSettingsOpen(false);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, loadTaggingQueueScope, loadTaggingQueueFolderIds, setSettingsOpen]);
useEffect(() => {
if (!settingsOpen || activeSection !== "general") return;
setVacuumResult(null);
setThumbnailCleanupResult(null);
void getDatabaseInfo().then(setDbInfo).catch(() => {});
void getOrphanedThumbnailsInfo().then(setThumbnailInfo).catch(() => {});
}, [settingsOpen, activeSection, getDatabaseInfo, getOrphanedThumbnailsInfo]);
// Clean up error timers on unmount
useEffect(() => {
return () => {
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current);
if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current);
};
}, []);
}, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, setSettingsOpen]);
const selectedFolders = useMemo(
() => folders.filter((folder) => taggingQueueFolderIds.includes(folder.id)),
[folders, taggingQueueFolderIds],
);
const totalQueuedJobs = useMemo(
() => Object.values(mediaJobProgress).reduce((sum, progress) => sum + (progress?.tagging_pending ?? 0), 0),
[mediaJobProgress],
);
if (!settingsOpen) return null;
const taggerReady = taggerModelStatus?.ready ?? false;
@@ -251,22 +181,14 @@ export function SettingsModal() {
: "no folders selected";
const thresholdDisplay = taggerThresholdDraft ?? String(taggerThreshold);
const batchSizeDisplay = taggerBatchSizeDraft ?? String(taggerBatchSize);
const taggerBytesKnown =
taggerModelProgress?.downloaded_bytes != null &&
taggerModelProgress.total_bytes != null &&
taggerModelProgress.total_bytes > 0;
const taggerDownloadLabel = taggerBytesKnown
? `Downloading ${formatBytesShort(taggerModelProgress!.downloaded_bytes!)} / ${formatBytesShort(taggerModelProgress!.total_bytes!)}`
: taggerModelProgress
const taggerDownloadLabel = taggerModelProgress
? `Downloading ${taggerModelProgress.completed_files}/${taggerModelProgress.total_files}`
: taggerModelPreparing
? "Preparing WD Tagger..."
: taggerReady
? "Installed"
: "Install model";
const taggerDownloadPercent = taggerBytesKnown
? Math.round((taggerModelProgress!.downloaded_bytes! / taggerModelProgress!.total_bytes!) * 100)
: taggerModelProgress
const taggerDownloadPercent = taggerModelProgress
? Math.round((taggerModelProgress.completed_files / Math.max(taggerModelProgress.total_files, 1)) * 100)
: 0;
@@ -319,7 +241,7 @@ export function SettingsModal() {
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm" onClick={() => setSettingsOpen(false)}>
<div
className="relative flex h-[min(85vh,900px)] w-[min(85vw,1400px)] overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60"
className="flex h-[min(760px,calc(100vh-56px))] w-full max-w-5xl overflow-hidden rounded-lg border border-white/10 bg-[#07080f] shadow-2xl shadow-black/60"
onClick={(event) => event.stopPropagation()}
>
<aside className="flex w-64 shrink-0 flex-col border-r border-white/[0.07] bg-white/[0.025]">
@@ -343,8 +265,14 @@ export function SettingsModal() {
</div>
</aside>
<main className="flex min-w-0 flex-1 flex-col">
<div className="flex h-14 shrink-0 items-center justify-between border-b border-white/[0.07] px-6">
<div>
<p className="text-sm font-medium text-white">{activeSection === "workspace" ? "AI Workspace" : "Workers"}</p>
<p className="text-xs text-gray-600">{activeSection === "workspace" ? "Model setup and queue targets" : "Background processing status"}</p>
</div>
<button
className="absolute right-4 top-4 z-10 rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={() => setSettingsOpen(false)}
title="Close settings"
>
@@ -352,62 +280,63 @@ export function SettingsModal() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<main className="min-w-0 flex-1 overflow-y-auto">
<div className="px-10 py-8">
<h3 className="text-lg font-semibold text-white">{activeSection === "workspace" ? "AI Workspace" : "General"}</h3>
<p className="mt-1 text-xs text-gray-600">{activeSection === "workspace" ? "Model setup and queue targets" : "App data and diagnostics"}</p>
<div className="flex-1 overflow-y-auto px-7 py-6">
{activeSection === "workspace" ? (
<div className="mt-8 space-y-9">
<SettingsGroup title="Model">
<SettingsItem
label={
<>
WD SwinV2 Tagger v3{" "}
<span className="ml-1.5 align-middle">
<div className="space-y-8">
<SectionShell
eyebrow="AI Workspace"
title="Tagging"
>
<SettingsCard title="Tagging Models">
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-white">WD SwinV2 Tagger v3</p>
<StatusPill tone={taggerReady ? "ready" : taggerModelPreparing ? "busy" : "muted"}>
{taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"}
</StatusPill>
</span>
</>
}
description="Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds."
</div>
<p className="mt-1 max-w-xl text-xs leading-relaxed text-gray-500">
Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds.
</p>
</div>
<div className="flex flex-col items-end gap-2">
<button
className="relative overflow-hidden rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => void prepareTaggerModel()}
disabled={taggerModelPreparing || taggerReady}
>
<div className="flex items-center gap-2">
{taggerModelProgress ? <span className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200" style={{ width: `${taggerDownloadPercent}%` }} /> : null}
<span className="relative">{taggerDownloadLabel}</span>
</button>
{taggerReady ? (
<>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 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"
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => void probeTaggerRuntime()}
disabled={taggerRuntimeChecking}
>
{taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"}
</button>
<button
className="rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-red-400/50 light-theme:bg-red-50 light-theme:text-red-700 light-theme:hover:bg-red-100"
className="rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => void deleteTaggerModel()}
disabled={taggerModelPreparing}
>
Delete model files
</button>
</>
) : (
<button
className="relative overflow-hidden rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => void prepareTaggerModel()}
disabled={taggerModelPreparing}
>
{taggerModelProgress ? <span className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200" style={{ width: `${taggerDownloadPercent}%` }} /> : null}
<span className="relative">{taggerDownloadLabel}</span>
</button>
)}
) : null}
</div>
</div>
</SettingsItem>
<SettingsItem label="Tagger acceleration" description="Use DirectML when available, or fall back to CPU for reliability.">
<div className="flex flex-col items-end gap-1.5">
<div className="flex rounded-lg border border-white/[0.07] p-0.5 light-theme:border-gray-300/80">
<div className="mt-4 space-y-4 border-t border-white/[0.07] pt-4">
<SettingsRow title="Tagger acceleration" description="Use DirectML when available, or fall back to CPU for reliability.">
<div className="flex flex-col items-end gap-2">
<div className="flex rounded-lg border border-white/[0.07] bg-black/20 p-1">
{(["auto", "directml", "cpu"] as const).map((acceleration) => (
<TaggerAccelerationButton
key={acceleration}
@@ -415,9 +344,8 @@ export function SettingsModal() {
current={taggerAcceleration}
onSelect={(nextAcceleration) => {
setTaggerAccelerationSaving(true);
setTaggerAccelerationError(null);
void setTaggerAcceleration(nextAcceleration)
.catch((error: unknown) => setTaggerAccelerationError(String(error)))
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => setTaggerAccelerationSaving(false));
}}
>
@@ -425,16 +353,12 @@ export function SettingsModal() {
</TaggerAccelerationButton>
))}
</div>
{taggerAccelerationError ? (
<p className="text-[11px] text-amber-300">{taggerAccelerationError}</p>
) : (
<p className="text-[11px] text-gray-600">{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}</p>
)}
</div>
</SettingsItem>
</SettingsRow>
<SettingsItem label="Confidence threshold" description="Lower values keep more tags. Higher values are stricter and usually cleaner.">
<div className="flex flex-col items-end gap-1.5">
<SettingsRow title="Confidence threshold" description="Lower values keep more tags. Higher values are stricter and usually cleaner.">
<div className="flex flex-col items-end gap-2">
<input
type="number"
min="0.05"
@@ -446,32 +370,24 @@ export function SettingsModal() {
onBlur={() => {
const value = parseFloat(thresholdDisplay);
if (!isNaN(value) && value >= 0.05 && value <= 0.99) {
setTaggerThresholdError(null);
setTaggerThresholdSaving(true);
void setTaggerThreshold(value)
.catch((error: unknown) => setTaggerThresholdError(String(error)))
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => {
setTaggerThresholdDraft(null);
setTaggerThresholdSaving(false);
});
} else {
setTaggerThresholdDraft(null);
setTaggerThresholdError("Must be 0.05 0.99");
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current);
thresholdErrorTimerRef.current = setTimeout(() => setTaggerThresholdError(null), 2000);
}
}}
/>
{taggerThresholdError ? (
<p className="text-[11px] text-amber-300">{taggerThresholdError}</p>
) : (
<p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}</p>
)}
</div>
</SettingsItem>
</SettingsRow>
<SettingsItem label="Tagging batch size" description="Number of images processed concurrently during tag generation.">
<div className="flex flex-col items-end gap-1.5">
<SettingsRow title="Tagging batch size" description="Number of images processed concurrently during tag generation.">
<div className="flex flex-col items-end gap-2">
<input
type="number"
min="1"
@@ -483,7 +399,6 @@ export function SettingsModal() {
onBlur={() => {
const value = parseInt(batchSizeDisplay, 10);
if (!isNaN(value) && value >= 1 && value <= 100) {
setTaggerBatchSizeError(null);
setTaggerBatchSizeSaving(true);
void setTaggerBatchSize(value)
.catch((error: unknown) => setTaggerQueueStatus(String(error)))
@@ -493,73 +408,68 @@ export function SettingsModal() {
});
} else {
setTaggerBatchSizeDraft(null);
setTaggerBatchSizeError("Must be 1 100");
if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current);
batchSizeErrorTimerRef.current = setTimeout(() => setTaggerBatchSizeError(null), 2000);
}
}}
/>
{taggerBatchSizeError ? (
<p className="text-[11px] text-amber-300">{taggerBatchSizeError}</p>
) : (
<p className="text-[11px] text-gray-600">{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}</p>
)}
</div>
</SettingsItem>
</SettingsRow>
<SettingsItem label="Model location" vertical>
<div>
<p className="break-all font-mono text-xs text-gray-600">
<p className="text-xs font-medium text-gray-400">Model location</p>
<p className="mt-2 break-all rounded-md border border-white/[0.07] bg-black/20 px-3 py-2 text-xs text-gray-600">
{taggerReady ? taggerModelStatus?.local_dir : "Not downloaded"}
</p>
{taggerModelProgress?.current_file ? <p className="mt-2 break-all text-xs text-gray-500">{taggerModelProgress.current_file}</p> : null}
{taggerModelError ? <p className="mt-2 text-xs text-amber-300">{taggerModelError}</p> : null}
{taggerModelProgress?.current_file ? <p className="mt-3 break-all text-xs text-gray-500">{taggerModelProgress.current_file}</p> : null}
{taggerModelError ? <p className="mt-3 text-xs text-amber-300">{taggerModelError}</p> : null}
{taggerRuntimeProbe ? (
<div className="mt-3">
<p className="text-xs text-gray-400">
Runtime check <span className="ml-1.5 align-middle"><StatusPill tone="ready">Ready</StatusPill></span>
<span className="ml-2 text-gray-600">acceleration: {taggerRuntimeProbe.acceleration}</span>
</p>
<p className="mt-1.5 break-all font-mono text-xs text-gray-600">{taggerRuntimeProbe.session.file}</p>
<div className="mt-4 rounded-lg border border-white/[0.07] bg-black/20 px-3 py-3">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-medium text-gray-400">Runtime check</p>
<StatusPill tone="ready">Ready</StatusPill>
</div>
<p className="mt-2 text-xs text-gray-600">Tagger acceleration: {taggerRuntimeProbe.acceleration}</p>
<p className="mt-2 break-all text-xs text-gray-500">{taggerRuntimeProbe.session.file}</p>
</div>
) : null}
</div>
</SettingsItem>
</SettingsGroup>
</div>
</div>
</SettingsCard>
<SettingsGroup title="Queue targets" description="Choose which folders to include when queuing tagging jobs.">
<SettingsItem label="Target scope" description="Queue across the full library, or choose a specific folder set and keep the modal open while you work through it.">
<div className="flex rounded-lg border border-white/[0.07] p-0.5 light-theme:border-gray-300/80">
<SettingsCard title="Queue Targets" description="Choose which folders to include when queuing tagging jobs.">
<SettingsRow title="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] bg-black/20 p-1">
<ScopeButton scope="all" current={taggingQueueScope} onSelect={setTaggingQueueScope}>All media</ScopeButton>
<ScopeButton scope="selected" current={taggingQueueScope} onSelect={setTaggingQueueScope}>Selected folders</ScopeButton>
</div>
</SettingsItem>
</SettingsRow>
<div className="py-4">
<div className="border-b border-white/[0.07] py-4">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm text-white">Folder selection</p>
<p className="text-sm font-medium text-white">Folder selection</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">Current target: {queueScopeLabel}.</p>
</div>
<div className="flex gap-2">
<button
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40 disabled:cursor-not-allowed 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"
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"
onClick={() => setTaggingQueueFolderIds(folders.map((folder) => folder.id))}
disabled={taggingQueueScope === "all" || folders.length === 0}
disabled={folders.length === 0}
>
Select all
</button>
<button
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40 disabled:cursor-not-allowed 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"
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"
onClick={() => setTaggingQueueFolderIds([])}
disabled={taggingQueueScope === "all" || taggingQueueFolderIds.length === 0}
disabled={taggingQueueFolderIds.length === 0}
>
Clear
</button>
</div>
</div>
<div className={`mt-2 max-h-64 divide-y divide-white/[0.04] overflow-y-auto pr-1 ${taggingQueueScope === "selected" ? "opacity-100" : "opacity-60"}`}>
<div className={`mt-4 grid max-h-64 gap-2 overflow-y-auto pr-1 ${taggingQueueScope === "selected" ? "opacity-100" : "opacity-60"}`}>
{folders.map((folder) => {
const active = taggingQueueFolderIds.includes(folder.id);
const progress = mediaJobProgress[folder.id];
@@ -567,337 +477,87 @@ export function SettingsModal() {
<button
key={folder.id}
type="button"
className={`flex w-full items-center justify-between gap-3 px-1 py-2 text-left transition-colors disabled:cursor-not-allowed ${
active ? "text-white" : "text-gray-400 hover:text-gray-200"
className={`flex items-center justify-between rounded-xl border px-3 py-2 text-left transition-colors ${
active
? "border-emerald-400/30 bg-emerald-500/10 text-white"
: "border-white/[0.07] bg-black/20 text-gray-300 hover:border-white/15 hover:bg-white/[0.04]"
}`}
onClick={() => toggleTaggingQueueFolder(folder.id)}
disabled={taggingQueueScope === "all"}
>
<p className="min-w-0 truncate text-sm">{folder.name}</p>
<div className="flex shrink-0 items-center gap-3">
<div>
<p className="text-sm font-medium">{folder.name}</p>
<p className="mt-1 text-[11px] text-gray-500">{folder.image_count.toLocaleString()} items</p>
</div>
<div className="flex items-center gap-2">
{(progress?.tagging_pending ?? 0) > 0 ? <StatusPill tone="busy">{progress?.tagging_pending} queued</StatusPill> : null}
<span className="text-[11px] tabular-nums text-gray-600">{folder.image_count.toLocaleString()} items</span>
<span className={`h-4 w-4 rounded-full border ${active ? "border-emerald-300 bg-emerald-300" : "border-white/15 bg-transparent"}`} />
</div>
</button>
);
})}
{folders.length === 0 ? <p className="py-2 text-sm text-gray-500">Add a folder first to enable targeted tagging queues.</p> : null}
{folders.length === 0 ? <p className="text-sm text-gray-500">Add a folder first to enable targeted tagging queues.</p> : null}
</div>
</div>
<SettingsItem label="Queue tagging jobs" description="Generate missing AI tags for the current target. Results flow back into the library as the background worker finishes.">
<div className="flex items-center gap-2">
<SettingsRow title="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 flex-col items-end gap-2">
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 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"
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => runQueueAction("queue")}
disabled={!taggerReady || taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
>
{taggerQueueing ? "Queueing..." : "Queue tagging"}
</button>
<button
className="rounded-md border border-amber-400/20 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 transition-colors hover:bg-amber-500/15 disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-amber-500/50 light-theme:bg-amber-50 light-theme:text-amber-700 light-theme:hover:bg-amber-100"
className="rounded-md border border-amber-400/20 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 transition-colors hover:bg-amber-500/15 disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => runQueueAction("clear")}
disabled={taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
>
{taggerClearing ? "Clearing..." : "Clear queued jobs"}
</button>
</div>
</SettingsItem>
</SettingsRow>
{taggerQueueStatus ? <p className="pt-3 text-xs text-gray-500">{taggerQueueStatus}</p> : null}
</SettingsGroup>
{taggerQueueStatus ? <p className="pt-4 text-xs text-gray-500">{taggerQueueStatus}</p> : null}
</SettingsCard>
<SettingsCard title="Captioning">
<div className="rounded-xl border border-dashed border-white/[0.09] bg-black/20 px-4 py-4">
<p className="text-sm font-medium text-white/50">Coming soon</p>
</div>
</SettingsCard>
</SectionShell>
</div>
) : (
<div className="mt-8 space-y-9">
<SettingsGroup title="Appearance">
<SettingsItem label="Theme" description="Choose the app palette. Subtle Light uses a warm, low-glare background.">
<ThemedDropdown
value={theme}
onChange={(value) => setTheme(value as AppTheme)}
ariaLabel="App theme"
options={[
{ value: "phokus", label: "Phokus" },
{ value: "subtle-light", label: "Subtle Light" },
{ value: "conventional-dark", label: "Conventional Dark" },
]}
/>
</SettingsItem>
</SettingsGroup>
<div className="space-y-8">
<SectionShell
eyebrow="Workers"
title="Background processing"
>
<SettingsCard title="Queue summary" description="Live totals across all folder workers.">
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Tagging queued</p>
<p className="mt-2 text-2xl font-semibold text-white">{totalQueuedJobs.toLocaleString()}</p>
</div>
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Selected folders</p>
<p className="mt-2 text-2xl font-semibold text-white">{selectedFolders.length.toLocaleString()}</p>
</div>
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Library folders</p>
<p className="mt-2 text-2xl font-semibold text-white">{folders.length.toLocaleString()}</p>
</div>
</div>
</SettingsCard>
<SettingsGroup title="Video playback">
<SettingsItem
label="Autoplay in lightbox"
description="Start playing videos automatically when opened in the lightbox."
>
<button
role="switch"
aria-checked={lightboxAutoplay}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${lightboxAutoplay ? "bg-sky-500" : "bg-white/15"}`}
onClick={() => setLightboxAutoplay(!lightboxAutoplay)}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${lightboxAutoplay ? "translate-x-4" : "translate-x-0"}`} />
</button>
</SettingsItem>
<SettingsItem
label="Start muted"
description="Open videos with their audio muted — unmute from the player controls."
>
<button
role="switch"
aria-checked={lightboxAutoMute}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${lightboxAutoMute ? "bg-sky-500" : "bg-white/15"}`}
onClick={() => setLightboxAutoMute(!lightboxAutoMute)}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${lightboxAutoMute ? "translate-x-4" : "translate-x-0"}`} />
</button>
</SettingsItem>
</SettingsGroup>
<SettingsGroup title="Updates">
<SettingsItem
label={
<span className="inline-flex items-center gap-2.5">
<span>Phokus {appVersion ? `v${appVersion}` : "—"}</span>
{updateStatus === "available" || updateStatus === "downloading" || updateStatus === "installing" ? (
<StatusPill tone="busy">v{updateVersion} available</StatusPill>
) : updateStatus === "upToDate" ? (
<StatusPill tone="ready">Up to date</StatusPill>
) : null}
</span>
}
description={
updateStatus === "error" ? (
<span className="text-amber-300/90">Update check failed: {updateError}</span>
) : updateStatus === "downloading" || updateStatus === "installing" ? (
"Downloading update — the app will restart when it finishes."
) : (
"Updates are checked quietly at launch and installed only when you choose."
)
}
>
{updateStatus === "available" ? (
<button
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700 light-theme:hover:bg-emerald-200"
onClick={() => void installUpdate()}
>
Install &amp; restart
</button>
) : (
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => void checkForUpdates()}
disabled={updateStatus === "checking" || updateStatus === "downloading" || updateStatus === "installing"}
>
{updateStatus === "checking" ? "Checking..." : "Check for updates"}
</button>
)}
</SettingsItem>
</SettingsGroup>
<SettingsGroup title="Setup">
<FfmpegStatusRow />
<SettingsItem
label="Welcome tour"
description="Replay the guided first-run tour — library setup, the background pipeline, search modes, and AI features."
>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => {
setSettingsOpen(false);
openOnboarding();
}}
>
Show welcome tour
</button>
</SettingsItem>
</SettingsGroup>
<SettingsGroup title="Storage & notifications">
<SettingsItem
label="App data folder"
description="Open the folder in Explorer to inspect or back up the database, thumbnails, and models."
>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => {
setOpeningDataFolder(true);
void openAppDataFolder().finally(() => setOpeningDataFolder(false));
}}
disabled={openingDataFolder}
>
{openingDataFolder ? "Opening..." : "Open data folder"}
</button>
</SettingsItem>
<SettingsItem
label="Pause all notifications"
description="Notifications are batched per folder — a single alert fires once activity settles. Mute individual folders from their right-click menu."
>
<button
role="switch"
aria-checked={notificationsPaused}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${notificationsPaused ? "bg-sky-500" : "bg-white/15"}`}
onClick={() => setNotificationsPaused(!notificationsPaused)}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${notificationsPaused ? "translate-x-4" : "translate-x-0"}`} />
</button>
</SettingsItem>
</SettingsGroup>
<SettingsGroup title="Maintenance">
<SettingsItem
label="Compact database"
description={
<>
<span>Reclaims wasted space left behind when images or tags are deleted. Safe to run at any time.</span>
<span className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-1">
<StatPair
label="Size"
value={
vacuumResult
? `${vacuumResult.after_mb.toFixed(1)} MB`
: dbInfo
? `${dbInfo.size_mb.toFixed(1)} MB`
: "—"
}
/>
<StatPair
label="Reclaimable"
accent={vacuumResult !== null}
value={
vacuumResult
? `${vacuumResult.freed_mb.toFixed(1)} MB freed`
: dbInfo
? `${dbInfo.reclaimable_mb.toFixed(1)} MB`
: "—"
}
/>
</span>
<span className="mt-2 block text-gray-600">
{vacuumResult
? `Compacted from ${vacuumResult.before_mb.toFixed(1)} MB to ${vacuumResult.after_mb.toFixed(1)} MB.`
: dbInfo && dbInfo.reclaimable_mb < 0.5
? "Database is already compact."
: "Run this after removing folders or bulk-deleting images."}
</span>
</>
}
>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => {
setVacuuming(true);
setVacuumResult(null);
void vacuumDatabase()
.then((result) => {
setVacuumResult(result);
setDbInfo({ size_mb: result.after_mb, reclaimable_mb: 0 });
})
.catch(() => {})
.finally(() => setVacuuming(false));
}}
disabled={vacuuming || (dbInfo !== null && dbInfo.reclaimable_mb < 0.5)}
>
{vacuuming ? "Compacting..." : "Compact now"}
</button>
</SettingsItem>
<SettingsItem
label="Rebuild semantic index"
description={
<>
<span>
Recreates the visual-embedding index and re-embeds every image in the
background. Use this if semantic or similar-image search reports a
dimension-mismatch error (for example after experimenting with a
different embedding model).
</span>
{rebuildIndexResult !== null ? (
<span className="mt-2 block text-gray-600">{rebuildIndexResult}</span>
) : null}
</>
}
>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => {
setRebuildingIndex(true);
setRebuildIndexResult(null);
void rebuildSemanticIndex()
.then((count) =>
setRebuildIndexResult(
`Re-queued ${count.toLocaleString()} image${count === 1 ? "" : "s"} for embedding.`,
),
)
.catch((error) => setRebuildIndexResult(String(error)))
.finally(() => setRebuildingIndex(false));
}}
disabled={rebuildingIndex}
>
{rebuildingIndex ? "Rebuilding…" : "Rebuild index"}
</button>
</SettingsItem>
<SettingsItem
label="Thumbnail cache"
description={
<>
<span>Thumbnails left behind when folders or images are removed. Safe to delete they are regenerated if the originals are re-indexed.</span>
<span className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-1">
<StatPair
label="Orphaned files"
value={
thumbnailCleanupResult
? "0"
: thumbnailInfo
? thumbnailInfo.count.toLocaleString()
: "—"
}
/>
<StatPair
label="Reclaimable"
accent={thumbnailCleanupResult !== null}
value={
thumbnailCleanupResult
? `${thumbnailCleanupResult.freed_mb.toFixed(1)} MB freed`
: thumbnailInfo
? `${thumbnailInfo.size_mb.toFixed(1)} MB`
: "—"
}
/>
</span>
<span className="mt-2 block text-gray-600">
{cleaningThumbnails
? "Scanning and removing orphaned thumbnails…"
: thumbnailCleanupResult
? `Removed ${thumbnailCleanupResult.deleted_count.toLocaleString()} file${thumbnailCleanupResult.deleted_count === 1 ? "" : "s"}, freed ${thumbnailCleanupResult.freed_mb.toFixed(1)} MB.`
: thumbnailInfo && thumbnailInfo.count === 0
? "No orphaned thumbnails found."
: thumbnailInfo && thumbnailInfo.count > 1000
? "May take a few minutes for large collections."
: "Remove thumbnails no longer associated with any indexed image."}
</span>
</>
}
>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => {
setCleaningThumbnails(true);
cleanupOrphanedThumbnails()
.then((result) => {
setThumbnailCleanupResult(result);
setThumbnailInfo(null);
})
.catch(() => {})
.finally(() => setCleaningThumbnails(false));
}}
disabled={cleaningThumbnails || thumbnailCleanupResult !== null || (thumbnailInfo !== null && thumbnailInfo.count === 0)}
>
{cleaningThumbnails ? "Cleaning…" : "Clean up"}
</button>
</SettingsItem>
</SettingsGroup>
<SettingsCard title="Worker controls" description="Pause, resume, retry, and per-folder failure details are available in the background tasks panel.">
<div className="flex items-center justify-between gap-4 rounded-xl border border-white/[0.07] bg-black/20 p-4">
<p className="text-sm text-gray-400">Workers are running in the background.</p>
<StatusPill tone="ready">Live</StatusPill>
</div>
</SettingsCard>
</SectionShell>
</div>
)}
</div>
+47 -423
View File
@@ -1,243 +1,53 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Reorder, useDragControls } from "framer-motion";
import { useEffect, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { useGalleryStore, Folder, IndexProgress } from "../store";
import { ThemedDropdown } from "./ThemedDropdown";
interface ContextMenuState {
folderId: number;
x: number;
y: number;
}
type LibrarySort = "az" | "za" | "custom";
const LIBRARY_SORT_KEY = "phokus-library-sort";
function FolderContextMenu({
menu,
folder,
isMuted,
isPausedAll,
onClose,
onRename,
onReindex,
onLocate,
onToggleMute,
onTogglePauseAll,
onRemove,
}: {
menu: ContextMenuState;
folder: Folder;
isMuted: boolean;
isPausedAll: boolean;
onClose: () => void;
onRename: () => void;
onReindex: () => void;
onLocate: () => void;
onToggleMute: () => void;
onTogglePauseAll: () => void;
onRemove: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("mousedown", handleDown);
document.addEventListener("keydown", handleKey);
return () => {
document.removeEventListener("mousedown", handleDown);
document.removeEventListener("keydown", handleKey);
};
}, [onClose]);
const item = (label: string, onClick: () => void, danger = false) => (
<button
className={`w-full text-left px-3 py-1.5 text-[12px] rounded-md transition-colors ${
danger
? "text-red-400 hover:bg-red-500/15 hover:text-red-300"
: "text-gray-300 hover:bg-white/8 hover:text-white"
}`}
onClick={() => { onClick(); onClose(); }}
>
{label}
</button>
);
return (
<div
ref={ref}
className="fixed z-50 min-w-[160px] py-1 px-1 rounded-lg bg-gray-900 border border-white/10 shadow-xl shadow-black/50"
style={{ left: menu.x, top: menu.y }}
>
{item("Reindex", onReindex)}
{item("Rename", onRename)}
{item(isPausedAll ? "Resume background work" : "Pause background work", onTogglePauseAll)}
{item(isMuted ? "Unmute notifications" : "Mute notifications", onToggleMute)}
{folder.scan_error && item("Locate Folder", onLocate)}
<div className="my-1 border-t border-white/[0.06]" />
{item("Remove from app", onRemove, true)}
</div>
);
}
function FolderItem({
folder,
selected,
progress,
customOrdering,
dragging,
onDragStart,
onDragEnd,
onKeyboardMove,
}: {
folder: Folder;
selected: boolean;
progress: IndexProgress | undefined;
customOrdering: boolean;
dragging: boolean;
onDragStart: (pointerY: number) => void;
onDragEnd: () => void;
onKeyboardMove: (direction: -1 | 1) => void;
}) {
const dragControls = useDragControls();
const { selectFolder, removeFolder, reindexFolder, renameFolder, updateFolderPath, toggleMutedFolder } = useGalleryStore();
const mutedFolderIds = useGalleryStore((state) => state.mutedFolderIds);
const setAllWorkersPaused = useGalleryStore((state) => state.setAllWorkersPaused);
const folderWorkers = useGalleryStore((state) => state.workerPaused[folder.id]);
const isMuted = mutedFolderIds.includes(folder.id);
// "Fully paused" only when every worker for this folder is paused.
const isPausedAll = !!folderWorkers &&
folderWorkers.thumbnail && folderWorkers.metadata && folderWorkers.embedding && folderWorkers.tagging;
const { selectFolder, removeFolder, reindexFolder } = useGalleryStore();
const isIndexing = progress && !progress.done;
const isMissing = !!folder.scan_error && !isIndexing;
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(folder.name);
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
const renameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (renaming) {
setRenameValue(folder.name);
setTimeout(() => renameInputRef.current?.select(), 0);
}
}, [renaming, folder.name]);
if (!confirmingRemoval) return;
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// Keep menu inside viewport
const x = Math.min(e.clientX, window.innerWidth - 180);
const y = Math.min(e.clientY, window.innerHeight - 160);
setContextMenu({ folderId: folder.id, x, y });
};
const timeout = window.setTimeout(() => {
setConfirmingRemoval(false);
}, 4000);
const handleLocateFolder = async () => {
const picked = await open({ directory: true, multiple: false, title: `Locate "${folder.name}"` });
if (picked && typeof picked === "string") {
await updateFolderPath(folder.id, picked);
}
};
return () => window.clearTimeout(timeout);
}, [confirmingRemoval]);
const commitRename = async () => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== folder.name) {
await renameFolder(folder.id, trimmed);
}
setRenaming(false);
};
const handleRenameKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") { e.preventDefault(); void commitRename(); }
if (e.key === "Escape") { setRenaming(false); }
const handleRemove = async (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
await removeFolder(folder.id);
};
return (
<Reorder.Item
as="div"
value={folder.id}
drag={customOrdering ? "y" : false}
dragControls={dragControls}
dragListener={false}
dragElastic={0.08}
onDragEnd={onDragEnd}
layout
transition={{ layout: { type: "spring", stiffness: 520, damping: 38, mass: 0.55 } }}
className={`relative z-0 ${dragging ? "z-20" : ""}`}
style={{ position: "relative" }}
>
<div
className={`group relative flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
selected
? "bg-white/8 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
} ${dragging ? "scale-[1.02] bg-white/[0.11] text-white shadow-xl shadow-black/25 ring-1 ring-white/15" : ""}`}
onClick={() => !renaming && selectFolder(folder.id)}
onContextMenu={handleContextMenu}
>
{customOrdering ? (
<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);
}}
onClick={() => selectFolder(folder.id)}
>
<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 ? (
<span className="shrink-0 text-amber-400">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</span>
) : (
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
)}
<div className="flex-1 min-w-0">
{renaming ? (
<input
ref={renameInputRef}
className="w-full bg-white/10 text-white text-[13px] font-medium rounded px-1 py-0 outline-none ring-1 ring-blue-500/60 leading-tight"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleRenameKey}
onBlur={() => void commitRename()}
onClick={(e) => e.stopPropagation()}
/>
) : (
<div className={`truncate text-[13px] font-medium leading-tight ${selected ? "text-white" : ""}`}>
{folder.name}
</div>
)}
{isIndexing ? (
<>
<div className="text-[11px] text-gray-600 mt-0.5">{progress.indexed}/{progress.total}</div>
@@ -253,214 +63,72 @@ function FolderItem({
)}
</div>
{/* Hover action buttons */}
{!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 removeFolder(folder.id); setConfirmingRemoval(false); }}
{confirmingRemoval ? (
<div
className="flex items-center gap-1 shrink-0"
role="group"
aria-label={`Confirm removal of ${folder.name}`}
onClick={(event) => event.stopPropagation()}
>
Confirm
<button
className="px-1.5 py-1 rounded-md text-[10px] font-medium text-red-400 bg-red-500/10 hover:bg-red-500/20 hover:text-red-300"
onClick={handleRemove}
>
Remove
</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"
className="px-1.5 py-1 rounded-md text-[10px] font-medium text-gray-400 hover:text-gray-100 hover:bg-white/10"
onClick={() => setConfirmingRemoval(false)}
>
Cancel
</button>
</div>
) : (
<div className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity shrink-0">
<button
className="p-1 rounded text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors"
title="Reindex"
onClick={(e) => { e.stopPropagation(); void reindexFolder(folder.id); }}
className="p-1 rounded-md hover:bg-white/10 text-gray-500 hover:text-gray-200"
title="Re-index"
aria-label={`Re-index ${folder.name}`}
onClick={(e) => { e.stopPropagation(); reindexFolder(folder.id); }}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button
className="p-1 rounded text-gray-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
title="Remove from app"
onClick={(e) => { e.stopPropagation(); setConfirmingRemoval(true); }}
className="p-1 rounded-md hover:bg-red-500/15 text-gray-500 hover:text-red-400"
title="Remove folder"
aria-label={`Remove ${folder.name}`}
onClick={(event) => {
event.stopPropagation();
setConfirmingRemoval(true);
}}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75}
d="M6 18L18 6M6 6l12 12" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)
)}
</div>
{isMissing && (
<div className="mx-2 mb-1 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20">
<p className="text-[11px] text-amber-400 font-medium mb-1.5">Folder not found</p>
<p className="text-[10px] text-gray-500 mb-2 leading-snug">
This folder may have been moved or renamed. Locate it to resume, or remove it from the app.
</p>
<div className="flex gap-1.5">
<button
className="flex-1 px-2 py-1 rounded-md text-[10px] font-medium bg-amber-500/20 text-amber-300 hover:bg-amber-500/30 hover:text-amber-200 transition-colors"
onClick={(e) => { e.stopPropagation(); void handleLocateFolder(); }}
>
Locate Folder
</button>
<button
className="flex-1 px-2 py-1 rounded-md text-[10px] font-medium bg-white/5 text-gray-400 hover:bg-red-500/15 hover:text-red-400 transition-colors"
onClick={(e) => { e.stopPropagation(); setConfirmingRemoval(true); }}
>
Remove
</button>
</div>
</div>
)}
{contextMenu && contextMenu.folderId === folder.id && (
<FolderContextMenu
menu={contextMenu}
folder={folder}
isMuted={isMuted}
isPausedAll={isPausedAll}
onClose={() => setContextMenu(null)}
onRename={() => setRenaming(true)}
onReindex={() => void reindexFolder(folder.id)}
onLocate={() => void handleLocateFolder()}
onToggleMute={() => toggleMutedFolder(folder.id)}
onTogglePauseAll={() => setAllWorkersPaused(folder.id, !isPausedAll)}
onRemove={() => setConfirmingRemoval(true)}
/>
)}
</Reorder.Item>
);
}
export function Sidebar() {
const folders = useGalleryStore((state) => state.folders);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen);
const addFolder = useGalleryStore((state) => state.addFolder);
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
const selectFolder = useGalleryStore((state) => state.selectFolder);
const activeView = useGalleryStore((state) => state.activeView);
const setView = useGalleryStore((state) => state.setView);
const reorderFolders = useGalleryStore((state) => state.reorderFolders);
const [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;
const handleAddFolder = async () => {
const selected = await open({ directory: true, multiple: false, title: "Select Media Folder" });
if (selected && typeof selected === "string") {
await addFolder(selected);
}
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);
};
return (
@@ -515,23 +183,6 @@ export function Sidebar() {
</span>
</div>
<div
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
activeView === "timeline"
? "bg-white/8 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
onClick={() => setView("timeline")}
>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className={`text-[13px] font-medium ${activeView === "timeline" ? "text-white" : ""}`}>
Timeline
</span>
</div>
<div
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
activeView === "duplicates"
@@ -552,55 +203,28 @@ export function Sidebar() {
{/* Section label */}
{folders.length > 0 && (
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-1">
<div className="px-5 pt-3 pb-1">
<span className="text-[10px] font-semibold uppercase tracking-[0.15em] text-gray-600">Libraries</span>
<ThemedDropdown
value={librarySort}
onChange={(value) => setLibrarySort(value as LibrarySort)}
ariaLabel="Library order"
compact
options={[
{ value: "az", label: "A-Z" },
{ value: "za", label: "Z-A" },
{ value: "custom", label: "Custom" },
]}
/>
</div>
)}
{/* Folder list */}
<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"
>
<div className="flex-1 overflow-y-auto px-2 pb-2 space-y-px min-h-0">
{folders.length === 0 ? (
<p className="text-gray-700 text-xs px-3 py-6 text-center leading-relaxed">
Add a folder to get started
</p>
) : (
displayedFolders.map((folder) => (
folders.map((folder) => (
<FolderItem
key={folder.id}
folder={folder}
selected={selectedFolderId === folder.id}
progress={indexingProgress[folder.id]}
customOrdering={librarySort === "custom"}
dragging={draggedFolderId === folder.id}
onDragStart={(pointerY) => {
pointerYRef.current = pointerY;
setDraggedFolderId(folder.id);
}}
onDragEnd={finishReorder}
onKeyboardMove={(direction) => moveFolderByKeyboard(folder.id, direction)}
/>
))
)}
</Reorder.Group>
</div>
</aside>
);
}
+33 -60
View File
@@ -1,8 +1,7 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
import { motion } from "framer-motion";
import { convertFileSrc } from "@tauri-apps/api/core";
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
const ACCENTS = [
"#60a5fa",
@@ -110,44 +109,29 @@ function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: nu
}));
}
function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imageIds: number[]) => void; animated: boolean }) {
function CloudCard({ node, onOpen }: { node: PlacedNode; onOpen: (imageIds: number[]) => void }) {
const src = node.entry.thumbnail_path ? convertFileSrc(node.entry.thumbnail_path) : null;
const { w, h, accent } = node;
const driftTransition = {
duration: node.driftDuration,
ease: "easeInOut" as const,
delay: seeded(node.index + 41) * 1.6,
repeat: 1,
repeatType: "reverse" as const,
};
return (
<motion.button
className="explore-cluster-card group absolute overflow-hidden rounded-2xl border border-white/8 bg-white/[0.04] text-left shadow-[0_8px_28px_rgba(0,0,0,0.38)] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
className="group absolute overflow-hidden rounded-2xl border border-white/8 bg-white/[0.04] text-left shadow-[0_8px_40px_rgba(0,0,0,0.5)] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
style={{ width: w, height: h, left: node.x - w / 2, top: node.y - h / 2 }}
initial={animated ? { opacity: 0, scale: 0.82, rotate: node.rotateSeed } : { opacity: 0, scale: 0.96 }}
animate={
animated
? {
initial={{ opacity: 0, scale: 0.75 }}
animate={{
opacity: 1,
scale: 1,
x: [0, node.driftX * 0.65, 0],
y: [0, node.driftY * 0.65, 0],
rotate: [node.rotateSeed, node.rotateSeed + 0.8, node.rotateSeed],
}
: { opacity: 1, scale: 1, rotate: node.rotateSeed }
}
transition={
animated
? {
opacity: { duration: 0.24, delay: Math.min(node.index * 0.024, 0.45) },
scale: { duration: 0.24, delay: Math.min(node.index * 0.024, 0.45) },
x: driftTransition,
y: { ...driftTransition, duration: node.driftDuration + 1.2, delay: seeded(node.index + 51) * 1.6 },
rotate: { ...driftTransition, duration: node.driftDuration + 0.8, delay: seeded(node.index + 61) * 1.2 },
}
: { opacity: { duration: 0.18, delay: Math.min(node.index * 0.016, 0.28) }, scale: { duration: 0.18, delay: Math.min(node.index * 0.016, 0.28) } }
}
x: [0, node.driftX, 0],
y: [0, node.driftY, 0],
rotate: [node.rotateSeed, node.rotateSeed + 1.4, node.rotateSeed],
}}
transition={{
opacity: { duration: 0.3, delay: Math.min(node.index * 0.035, 0.7) },
scale: { duration: 0.3, delay: Math.min(node.index * 0.035, 0.7) },
x: { duration: node.driftDuration, repeat: Infinity, ease: "easeInOut", delay: seeded(node.index + 41) * 3 },
y: { duration: node.driftDuration + 1.6, repeat: Infinity, ease: "easeInOut", delay: seeded(node.index + 51) * 3 },
rotate: { duration: node.driftDuration + 0.9, repeat: Infinity, ease: "easeInOut" },
}}
whileHover={{ scale: 1.06, rotate: 0, transition: { duration: 0.18 } }}
onClick={() => onOpen(node.entry.image_ids)}
title={`Open cluster — ${node.entry.count.toLocaleString()} images`}
@@ -158,24 +142,22 @@ function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imag
alt=""
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
draggable={false}
loading="lazy"
decoding="async"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.07] to-transparent" />
)}
<div className="explore-cluster-overlay absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" />
{/* Accent glow on hover */}
<div
className="absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
style={{ background: `radial-gradient(ellipse at bottom, ${accent}25, transparent 70%)` }}
/>
<div className="absolute inset-x-0 bottom-0 p-3">
<div className="explore-cluster-rule mb-2 h-px rounded-full" style={{ background: `linear-gradient(to right, ${accent}80, transparent)` }} />
<div className="mb-2 h-px rounded-full" style={{ background: `linear-gradient(to right, ${accent}80, transparent)` }} />
<div className="flex items-end justify-between gap-2">
<div>
<p className="explore-cluster-label text-[9px] uppercase tracking-[0.18em] text-white/35">Cluster</p>
<p className="explore-cluster-count text-base font-semibold leading-none text-white">{node.entry.count.toLocaleString()}</p>
<p className="text-[9px] uppercase tracking-[0.18em] text-white/35">Cluster</p>
<p className="text-base font-semibold leading-none text-white">{node.entry.count.toLocaleString()}</p>
</div>
<span
className="rounded-full border px-2 py-0.5 text-[9px] uppercase tracking-[0.1em] opacity-0 transition-opacity duration-200 group-hover:opacity-100"
@@ -214,7 +196,7 @@ function TagWord({
animate={{ opacity: 0.4 + ratio * 0.6, scale: 1 }}
transition={{ delay: Math.min(index * 0.008, 0.55), duration: 0.22 }}
whileHover={{ scale: 1.2, opacity: 1, rotate: 0, transition: { duration: 0.14 } }}
className="explore-tag-word group inline-flex items-center gap-1 rounded-full px-2 py-1 transition-colors hover:bg-white/[0.07]"
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`}
@@ -238,7 +220,7 @@ function TagWord({
function Spinner() {
return (
<motion.div
className="explore-spinner h-5 w-5 rounded-full border-2 border-white/15 border-t-white/50"
className="h-5 w-5 rounded-full border-2 border-white/15 border-t-white/50"
animate={{ rotate: 360 }}
transition={{ duration: 0.85, repeat: Infinity, ease: "linear" }}
/>
@@ -255,7 +237,6 @@ function ClusterCloud({
entries: TagCloudEntry[];
onOpen: (imageIds: number[]) => void;
}) {
const reducedMotion = useReducedMotion();
const canvasRef = useRef<HTMLDivElement>(null);
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
@@ -279,14 +260,9 @@ function ClusterCloud({
return (
<div ref={canvasRef} className="relative min-h-0 flex-1 overflow-hidden">
<div className="explore-cluster-grid pointer-events-none absolute inset-0 opacity-[0.07] [background-image:radial-gradient(circle,rgba(255,255,255,0.6)_1px,transparent_1px)] [background-size:28px_28px]" />
<div className="pointer-events-none absolute inset-0 opacity-[0.07] [background-image:radial-gradient(circle,rgba(255,255,255,0.6)_1px,transparent_1px)] [background-size:28px_28px]" />
{nodes.map((node) => (
<CloudCard
key={`${node.entry.representative_image_id}:${node.index}`}
node={node}
onOpen={onOpen}
animated={!reducedMotion && node.index < 12}
/>
<CloudCard key={`${node.entry.representative_image_id}:${node.index}`} node={node} onOpen={onOpen} />
))}
</div>
);
@@ -323,13 +299,13 @@ export function TagCloud() {
const entryCount = exploreMode === "visual" ? tagCloudEntries.length : exploreTagEntries.length;
return (
<div className="explore-view flex min-h-0 flex-1 flex-col overflow-hidden bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.08),transparent_50%),radial-gradient(ellipse_at_80%_75%,rgba(168,85,247,0.07),transparent_40%),#07080f]">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.08),transparent_50%),radial-gradient(ellipse_at_80%_75%,rgba(168,85,247,0.07),transparent_40%),#07080f]">
{/* Header */}
<div className="explore-header 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="min-w-0">
<h2 className="explore-title text-[15px] font-semibold text-white">Explore</h2>
<p className="explore-subtitle mt-0.5 truncate text-[11px] text-white/30">
<h2 className="text-[15px] font-semibold text-white">Explore</h2>
<p className="mt-0.5 truncate text-[11px] text-white/30">
{loading
? exploreMode === "visual" ? "Computing visual clusters…" : "Loading tags…"
: hasEntries
@@ -341,11 +317,9 @@ export function TagCloud() {
: "No tags — run the AI tagger or add tags manually"}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<FolderScopeDropdown />
<div className="explore-mode-toggle flex rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
<div className="flex shrink-0 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 ${
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
exploreMode === "visual" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
}`}
onClick={() => setExploreMode("visual")}
@@ -353,7 +327,7 @@ export function TagCloud() {
Clusters
</button>
<button
className={`explore-mode-button rounded-md px-3 py-1.5 text-xs transition-colors ${
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
exploreMode === "tags" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
}`}
onClick={() => setExploreMode("tags")}
@@ -363,16 +337,15 @@ export function TagCloud() {
</div>
</div>
</div>
</div>
{loading ? (
<div className="explore-empty flex flex-1 items-center justify-center gap-3 text-white/25">
<div className="flex flex-1 items-center justify-center gap-3 text-white/25">
<Spinner />
<span className="text-sm">{exploreMode === "visual" ? "Computing clusters…" : "Loading tags…"}</span>
</div>
) : !hasEntries ? (
<div className="flex flex-1 items-center justify-center px-8">
<p className="explore-empty max-w-xs text-center text-sm leading-relaxed text-white/25">
<p className="max-w-xs text-center text-sm leading-relaxed text-white/25">
{exploreMode === "visual"
? "No visual clusters yet. Images need embeddings before they can be grouped. Check indexing progress in the sidebar."
: "No tags yet. Run the AI tagger from Settings, or add tags manually in the image preview."}
-106
View File
@@ -1,106 +0,0 @@
import { useEffect, useRef, useState } from "react";
export interface DropdownOption {
value: string;
label: string;
}
export function ThemedDropdown({
value,
options,
onChange,
ariaLabel,
compact = false,
align = "right",
}: {
value: string;
options: DropdownOption[];
onChange: (value: string) => void;
ariaLabel: string;
compact?: boolean;
align?: "left" | "right";
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const current = options.find((option) => option.value === value) ?? options[0];
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
if (!ref.current?.contains(event.target as Node)) setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
};
}, []);
return (
<div ref={ref} className="relative">
<button
type="button"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((currentOpen) => !currentOpen)}
className={`flex items-center justify-between gap-2 rounded-md border transition-colors ${
compact
? "border-white/10 bg-white/[0.04] px-1.5 py-0.5 text-[10px] font-medium light-theme:border-gray-700/50 light-theme:bg-gray-900"
: "min-w-40 border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs light-theme:border-gray-700/50 light-theme:bg-gray-900"
} ${open ? "border-white/20 text-white light-theme:border-gray-700 light-theme:text-white" : "text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:text-white light-theme:hover:border-gray-700 light-theme:hover:bg-gray-800 light-theme:hover:text-white"}`}
>
<span>{current?.label}</span>
<svg
className={`h-3 w-3 shrink-0 text-gray-500 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open ? (
<div
role="listbox"
aria-label={ariaLabel}
className={`absolute top-full z-50 mt-1.5 min-w-full rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl shadow-black/30 backdrop-blur-xl light-theme:border-gray-700/50 ${
align === "right" ? "right-0" : "left-0"
}`}
>
{options.map((option) => {
const selected = option.value === value;
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={selected}
className={`flex w-full items-center justify-between gap-4 whitespace-nowrap rounded-lg px-3 py-2 text-left text-xs transition-colors ${
selected
? "bg-white/[0.08] text-white light-theme:bg-gray-900 light-theme:text-white"
: "text-gray-400 hover:bg-white/[0.055] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => {
onChange(option.value);
setOpen(false);
}}
>
<span>{option.label}</span>
{selected ? (
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : null}
</button>
);
})}
</div>
) : null}
</div>
);
}
-412
View File
@@ -1,412 +0,0 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store";
import { ContextMenu, ImageTile } from "./Gallery";
const GAP = 6;
const HEADER_HEIGHT = 52;
const SCRUBBER_WIDTH = 48;
const MONTH_SHORT = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] as const;
interface TimelineGroup {
key: string;
label: string;
images: ImageRecord[];
}
// One virtualized row: either a month header or a row of up to `cols` tiles.
type TimelineRow =
| { type: "header"; group: TimelineGroup }
| { type: "tiles"; images: ImageRecord[] };
interface ScrubberMonth {
monthNum: number;
label: string;
groupIndex: number;
}
interface ScrubberYear {
year: string;
firstGroupIndex: number;
months: ScrubberMonth[];
}
function buildLabel(key: string): string {
if (key === "unknown") return "Unknown Date";
const [yearStr, monthStr] = key.split("-");
const year = Number(yearStr);
const month = Number(monthStr);
if (!isFinite(year) || !isFinite(month) || month < 1 || month > 12) return "Unknown Date";
const date = new Date(year, month - 1);
if (isNaN(date.getTime())) return "Unknown Date";
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
}
function groupImages(images: ImageRecord[]): TimelineGroup[] {
const map = new Map<string, ImageRecord[]>();
for (const img of images) {
const ds = img.taken_at ?? img.modified_at;
const key = ds ? ds.substring(0, 7) : "unknown";
let bucket = map.get(key);
if (bucket === undefined) {
bucket = [];
map.set(key, bucket);
}
bucket.push(img);
}
return Array.from(map.entries())
.sort(([a], [b]) => {
if (a === "unknown") return 1;
if (b === "unknown") return -1;
return a < b ? -1 : a > b ? 1 : 0;
})
.map(([key, imgs]) => ({ key, label: buildLabel(key), images: imgs }));
}
function buildScrubberYears(groups: TimelineGroup[]): ScrubberYear[] {
const byYear = new Map<string, ScrubberYear>();
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
if (group.key === "unknown") continue;
const year = group.key.substring(0, 4);
const monthNum = Number(group.key.substring(5, 7));
if (!byYear.has(year)) {
byYear.set(year, { year, firstGroupIndex: i, months: [] });
}
byYear.get(year)!.months.push({
monthNum,
label: MONTH_SHORT[monthNum - 1] ?? "",
groupIndex: i,
});
}
// Keep insertion order so the scrubber runs the same direction as the scrolled
// content (oldest at top with taken_asc), keeping the active highlight aligned.
return Array.from(byYear.values());
}
export function Timeline() {
const images = useGalleryStore((s) => s.images);
const loadMoreImages = useGalleryStore((s) => s.loadMoreImages);
const openImage = useGalleryStore((s) => s.openImage);
const totalImages = useGalleryStore((s) => s.totalImages);
const loadingImages = useGalleryStore((s) => s.loadingImages);
const imageLoadError = useGalleryStore((s) => s.imageLoadError);
const zoomPreset = useGalleryStore((s) => s.zoomPreset);
const parentRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [activeGroupIndex, setActiveGroupIndex] = useState(0);
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
image: ImageRecord;
} | null>(null);
// parentRef is the scroll container. Its clientWidth already excludes the
// scrubber because they are flex siblings, so no further adjustment is needed.
useLayoutEffect(() => {
const el = parentRef.current;
if (!el) return;
setContainerWidth(el.clientWidth);
const ro = new ResizeObserver((entries) => {
setContainerWidth(entries[0].contentRect.width);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const tileSize = tileSizeForZoom(zoomPreset);
const groups = useMemo(() => groupImages(images), [images]);
const scrubberYears = useMemo(() => buildScrubberYears(groups), [groups]);
// Show as soon as there's more than one month to jump between — not gated on
// a full year. With taken_asc sort the loaded set is oldest-first, so this
// reflects whatever range is currently loaded.
const showScrubber = groups.length > 1;
const cols = useMemo(
() => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))),
[containerWidth, tileSize],
);
// 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]);
const estimateSize = useCallback(
(index: number): number =>
rows[index]?.type === "header" ? HEADER_HEIGHT : tileSize + GAP,
[rows, tileSize],
);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize,
overscan: 6,
paddingStart: GAP,
});
// Refs so the scroll handler can read the latest mappings without re-binding.
const rowToGroupIndexRef = useRef(rowToGroupIndex);
rowToGroupIndexRef.current = rowToGroupIndex;
const groupFirstRowRef = useRef(groupFirstRow);
groupFirstRowRef.current = groupFirstRow;
useEffect(() => {
virtualizer.measure();
}, [cols, virtualizer]);
const handleScroll = useCallback(() => {
const el = parentRef.current;
if (!el) return;
// Active month = the group owning the first row still visible at the top.
const scrollTop = el.scrollTop;
const items = virtualizer.getVirtualItems();
let activeRow = items.length > 0 ? items[0].index : 0;
for (const item of items) {
if (item.start + item.size > scrollTop + HEADER_HEIGHT / 2) {
activeRow = item.index;
break;
}
}
setActiveGroupIndex(rowToGroupIndexRef.current[activeRow] ?? 0);
if (scrollTop < 24) return;
const nearBottom = scrollTop + el.clientHeight >= el.scrollHeight - 600;
if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages();
}
}, [virtualizer, images.length, loadMoreImages, loadingImages, totalImages]);
useEffect(() => {
const el = parentRef.current;
if (!el) return;
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
useEffect(() => {
const close = (e: PointerEvent) => {
if ((e.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return;
setContextMenu(null);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setContextMenu(null);
};
window.addEventListener("pointerdown", close);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("pointerdown", close);
window.removeEventListener("keydown", onKey);
};
}, []);
const scrollToGroup = useCallback(
(groupIndex: number) => {
const row = groupFirstRowRef.current[groupIndex] ?? 0;
virtualizer.scrollToIndex(row, { align: "start" });
},
[virtualizer],
);
return (
// Outer flex-row: fills remaining height in <main>'s flex-col, then
// splits horizontally between the scroll area and the scrubber.
<div className="flex flex-1 min-h-0 bg-gray-950">
{/* Scroll container — flex-1 takes all width except the scrubber */}
<div
ref={parentRef}
className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0"
>
{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
key={virtualItem.key}
style={{
position: "absolute",
top: virtualItem.start,
width: "100%",
height: virtualItem.size,
}}
>
{row.type === "header" ? (
<div
className="flex items-center gap-3 px-4"
style={{ height: HEADER_HEIGHT }}
>
<span className="text-sm font-semibold text-white/80 shrink-0">
{row.group.label}
</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>
)}
{images.length > 0 && loadingImages ? (
<div className="flex justify-center py-8">
<div className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
</div>
) : null}
</div>
{/* Scrubber — flex sibling so it stays visible while the left panel scrolls */}
{showScrubber ? (
<div
className="overflow-y-auto border-l border-white/[0.04] py-2 flex flex-col items-center gap-0.5"
style={{ width: SCRUBBER_WIDTH }}
>
{scrubberYears.map((yearEntry) => (
<ScrubberYearBlock
key={yearEntry.year}
yearEntry={yearEntry}
activeGroupIndex={activeGroupIndex}
onScrollTo={scrollToGroup}
/>
))}
</div>
) : null}
{contextMenu ? (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
image={contextMenu.image}
onClose={() => setContextMenu(null)}
/>
) : null}
</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>
);
}
+8 -33
View File
@@ -1,7 +1,6 @@
import { useState, useEffect } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useGalleryStore } from "../store";
import { PhokusMark } from "./PhokusMark";
// SVG icons for window controls
function MinimizeIcon() {
@@ -24,7 +23,7 @@ function RestoreIcon() {
return (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<rect x="2.5" y="0.5" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1" />
<rect x="0.5" y="2.5" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1" fill="var(--color-gray-950)" />
<rect x="0.5" y="2.5" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1" fill="#030712" />
</svg>
);
}
@@ -40,9 +39,6 @@ function CloseIcon() {
export function TitleBar() {
const [isMaximized, setIsMaximized] = useState(false);
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
const updateStatus = useGalleryStore((state) => state.updateStatus);
const updateVersion = useGalleryStore((state) => state.updateVersion);
const installUpdate = useGalleryStore((state) => state.installUpdate);
const appWindow = getCurrentWindow();
useEffect(() => {
@@ -63,10 +59,6 @@ export function TitleBar() {
const handleMaximize = () => appWindow.toggleMaximize();
const handleClose = () => appWindow.close();
// An update is waiting for the user to act. Covers the "clicked Later" case too,
// since dismissing the toast doesn't change updateStatus.
const updatePending = updateStatus === "available";
return (
// data-tauri-drag-region is the recommended Tauri approach for drag regions.
// WebkitAppRegion is kept as a CSS fallback for compatibility.
@@ -75,32 +67,15 @@ export function TitleBar() {
className="titlebar relative z-50 flex h-9 shrink-0 items-center bg-gray-950 select-none"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
{/* App icon + name left side. When an update is waiting, the iris lights
up its focal point and the chip becomes a button that re-opens the prompt. */}
{/* App icon + name — left side */}
<div className="flex items-center gap-2 pl-3 pr-4">
{updatePending ? (
<div
className="group relative"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<button
onClick={() => void installUpdate()}
aria-label={`Update available — click to update to Phokus v${updateVersion}`}
className="relative flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300 transition-colors hover:bg-white/12"
>
<span className="pointer-events-none absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/60 animate-ping" />
<PhokusMark className="relative h-4 w-4" dotClassName="fill-amber-400" />
</button>
{/* Custom tooltip — fades in ~100ms instead of the native ~500ms delay. */}
<span className="pointer-events-none absolute left-0 top-full z-50 mt-1.5 whitespace-nowrap rounded-md border border-white/10 bg-gray-800 px-2 py-1 text-[11px] text-gray-200 opacity-0 shadow-lg transition-opacity duration-100 group-hover:opacity-100">
Click to update v{updateVersion}
</span>
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden">
{/* Phokus logo placeholder — replace with <img src={logo} /> if you have an SVG */}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<circle cx="6" cy="6" r="4" stroke="#a78bfa" strokeWidth="1.5" />
<circle cx="6" cy="6" r="1.5" fill="#a78bfa" />
</svg>
</div>
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300">
<PhokusMark className="h-4 w-4" />
</div>
)}
<span className="text-[11px] font-semibold text-gray-400 tracking-wide">Phokus</span>
</div>
+17 -34
View File
@@ -1,13 +1,10 @@
import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
{ value: "date_desc", label: "Newest first" },
{ value: "date_asc", label: "Oldest first" },
{ value: "taken_desc", label: "Taken: newest" },
{ value: "taken_asc", label: "Taken: oldest" },
{ value: "name_asc", label: "Name AZ" },
{ value: "name_desc", label: "Name ZA" },
{ value: "rating_desc", label: "Highest rated" },
@@ -55,8 +52,8 @@ function SortDropdown({
onClick={() => setOpen((v) => !v)}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${
open
? "border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white"
: "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:border-gray-700/40 light-theme:text-gray-600 light-theme:hover:border-gray-700 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
? "border-white/15 bg-white/8 text-white"
: "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200"
}`}
>
<span>{current?.label ?? "Sort"}</span>
@@ -68,14 +65,14 @@ function SortDropdown({
</svg>
</button>
{open ? (
<div className="absolute right-0 top-full z-30 mt-1.5 min-w-44 rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur light-theme:border-gray-700/50">
<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">
{options.map((option) => (
<button
key={option.value}
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
option.value === value
? "bg-white/6 text-white 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"
? "bg-white/6 text-white"
: "text-gray-400 hover:bg-white/5 hover:text-white"
}`}
onClick={() => { onChange(option.value); setOpen(false); }}
>
@@ -106,14 +103,14 @@ function FilterPill({
}) {
const activeClass =
variant === "amber"
? "bg-amber-500/15 text-amber-300 border border-amber-500/30 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:border-amber-500/50"
: "bg-white/10 text-white light-theme:bg-gray-900 light-theme:text-white";
? "bg-amber-500/15 text-amber-300 border border-amber-500/30"
: "bg-white/10 text-white";
return (
<button
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
active
? activeClass
: "text-gray-500 hover:text-gray-200 hover:bg-white/5 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
onClick={onClick}
>
@@ -158,17 +155,13 @@ export function Toolbar() {
const setMinimumRating = useGalleryStore((state) => state.setMinimumRating);
const failedEmbeddingsOnly = useGalleryStore((state) => state.failedEmbeddingsOnly);
const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly);
const failedTaggingOnly = useGalleryStore((state) => state.failedTaggingOnly);
const setFailedTaggingOnly = useGalleryStore((state) => state.setFailedTaggingOnly);
const similarScope = useGalleryStore((state) => state.similarScope);
const setSimilarScope = useGalleryStore((state) => state.setSimilarScope);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
const activeView = useGalleryStore((state) => state.activeView);
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
const hasAnyFailedTagging = Object.values(mediaJobProgress).some((p) => p.tagging_failed > 0);
const [searchCommand, setSearchCommand] = useState<SearchCommand | null>(null);
const [searchQuery, setSearchQuery] = useState(search);
@@ -274,8 +267,6 @@ export function Toolbar() {
)}
</div>
{activeView === "timeline" ? <FolderScopeDropdown /> : null}
<div className="flex-1" />
{/* Search */}
@@ -422,7 +413,7 @@ export function Toolbar() {
<div className="h-4 w-px bg-white/10 shrink-0" />
{/* Zoom */}
<div className="flex items-center rounded-lg border border-white/8 overflow-hidden light-theme:border-gray-700/40">
<div className="flex items-center rounded-lg border border-white/8 overflow-hidden">
{(["compact", "comfortable", "detail"] as const).map((preset, i) => (
<button
key={preset}
@@ -430,8 +421,8 @@ export function Toolbar() {
i > 0 ? "border-l border-white/8" : ""
} ${
zoomPreset === preset
? "bg-white/10 text-white light-theme:bg-gray-900 light-theme:text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
? "bg-white/10 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
title={`${tileSize}px tiles`}
onClick={() => setZoomPreset(preset)}
@@ -444,12 +435,12 @@ export function Toolbar() {
{/* Filter row */}
<div className="flex items-center gap-1 px-4 pb-1.5">
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly && minimumRating === 0} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Rated" active={minimumRating === 1} onClick={() => { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="4★+" active={minimumRating === 4} onClick={() => { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly && minimumRating === 0} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="Rated" active={minimumRating === 1} onClick={() => { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="4★+" active={minimumRating === 4} onClick={() => { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="Similar: Folder" active={similarScope === "current_folder"} onClick={() => setSimilarScope("current_folder")} />
<FilterPill label="Similar: All" active={similarScope === "all_media"} onClick={() => setSimilarScope("all_media")} />
{hasAnyFailedEmbeddings ? (
@@ -460,14 +451,6 @@ export function Toolbar() {
onClick={() => setFailedEmbeddingsOnly(!failedEmbeddingsOnly)}
/>
) : null}
{hasAnyFailedTagging ? (
<FilterPill
label="Failed Tags"
active={failedTaggingOnly}
variant="amber"
onClick={() => setFailedTaggingOnly(!failedTaggingOnly)}
/>
) : null}
{isSimilarResults ? <span className="ml-2 text-[11px] text-gray-500">Current similar scope: {similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
</div>
</div>
-67
View File
@@ -1,67 +0,0 @@
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../store";
export function UpdateToast() {
const updateStatus = useGalleryStore((s) => s.updateStatus);
const updateVersion = useGalleryStore((s) => s.updateVersion);
const updateProgress = useGalleryStore((s) => s.updateProgress);
const updateDismissed = useGalleryStore((s) => s.updateDismissed);
const installUpdate = useGalleryStore((s) => s.installUpdate);
const dismissUpdate = useGalleryStore((s) => s.dismissUpdate);
const visible =
!updateDismissed &&
(updateStatus === "available" || updateStatus === "downloading" || updateStatus === "installing");
return (
<AnimatePresence>
{visible ? (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 12 }}
transition={{ duration: 0.18 }}
className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-white/10 bg-gray-900 p-4 shadow-xl"
>
{updateStatus === "available" ? (
<>
<p className="text-sm font-medium text-white">Update available</p>
<p className="mt-1 text-xs text-gray-500">
Phokus v{updateVersion} is ready to download and install.
</p>
<div className="mt-3 flex items-center gap-2">
<button
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700 light-theme:hover:bg-emerald-200"
onClick={() => void installUpdate()}
>
Install &amp; restart
</button>
<button
className="rounded-md border border-transparent px-3 py-1.5 text-xs text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-gray-200"
onClick={dismissUpdate}
>
Later
</button>
</div>
</>
) : (
<>
<p className="text-sm font-medium text-white">
{updateStatus === "installing" ? "Installing update..." : "Downloading update..."}
</p>
<div className="mt-3 h-1 overflow-hidden rounded-full bg-white/10">
<div
className={`h-full rounded-full bg-emerald-400/80 transition-[width] duration-200 ${
updateProgress === null ? "w-full animate-pulse" : ""
}`}
style={updateProgress !== null ? { width: `${Math.round(updateProgress * 100)}%` } : undefined}
/>
</div>
<p className="mt-2 text-xs text-gray-600">The app will restart when it finishes.</p>
</>
)}
</motion.div>
) : null}
</AnimatePresence>
);
}
-456
View File
@@ -1,456 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
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 CONTROLS_HIDE_DELAY_MS = 2500;
const SEEK_STEP_SECONDS = 5;
const VOLUME_STEP = 0.1;
// Session-wide playback preferences shared across player instances.
let persistedVolume = 1;
let persistedMuted = false;
function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const total = Math.floor(seconds);
const s = total % 60;
const m = Math.floor(total / 60) % 60;
const h = Math.floor(total / 3600);
if (h > 0) return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
return `${m}:${s.toString().padStart(2, "0")}`;
}
interface BufferedRange {
start: number;
end: number;
}
function ControlButton({ onClick, title, active = false, children }: {
onClick: () => void;
title: string;
active?: boolean;
children: React.ReactNode;
}) {
return (
<button
className={`rounded-md p-1.5 transition-colors ${active ? "text-white" : "text-gray-300 hover:text-white"} hover:bg-white/10`}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
title={title}
>
{children}
</button>
);
}
export function VideoPlayer({ src }: { src: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scrubbingRef = useRef(false);
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [buffered, setBuffered] = useState<BufferedRange[]>([]);
const [volume, setVolume] = useState(persistedVolume);
const [muted, setMuted] = useState(
() => useGalleryStore.getState().lightboxAutoMute || persistedMuted,
);
const [playbackRate, setPlaybackRate] = useState(1);
const [loop, setLoop] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [controlsVisible, setControlsVisible] = useState(true);
const [speedMenuOpen, setSpeedMenuOpen] = useState(false);
const speedMenuOpenRef = useRef(speedMenuOpen);
speedMenuOpenRef.current = speedMenuOpen;
// ── Controls visibility ────────────────────────────────────────────────────
const showControls = useCallback(() => {
setControlsVisible(true);
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
hideTimerRef.current = setTimeout(() => {
const video = videoRef.current;
if (video && !video.paused && !scrubbingRef.current && !speedMenuOpenRef.current) {
setControlsVisible(false);
}
}, CONTROLS_HIDE_DELAY_MS);
}, []);
useEffect(() => {
return () => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
};
}, []);
// ── Video element wiring ───────────────────────────────────────────────────
useEffect(() => {
const video = videoRef.current;
if (!video) return;
// Read playback prefs fresh for each opened video — not reactive, so a
// settings change applies to the next video rather than the current one.
const { lightboxAutoplay, lightboxAutoMute } = useGalleryStore.getState();
const startMuted = lightboxAutoMute || persistedMuted;
video.volume = persistedVolume;
video.muted = startMuted;
setMuted(startMuted);
// Autoplay when enabled; if the webview blocks it the catch leaves us paused
// with controls visible, which is a fine fallback.
if (lightboxAutoplay) video.play().catch(() => {});
}, [src]);
const readBuffered = useCallback(() => {
const video = videoRef.current;
if (!video || !Number.isFinite(video.duration) || video.duration <= 0) return;
const ranges: BufferedRange[] = [];
for (let i = 0; i < video.buffered.length; i++) {
ranges.push({
start: video.buffered.start(i) / video.duration,
end: video.buffered.end(i) / video.duration,
});
}
setBuffered(ranges);
}, []);
const togglePlay = useCallback(() => {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
void video.play().catch(() => {});
} else {
video.pause();
}
showControls();
}, [showControls]);
const seekBy = useCallback(
(deltaSeconds: number) => {
const video = videoRef.current;
if (!video || !Number.isFinite(video.duration)) return;
video.currentTime = Math.min(video.duration, Math.max(0, video.currentTime + deltaSeconds));
showControls();
},
[showControls],
);
const applyVolume = useCallback(
(nextVolume: number, nextMuted?: boolean) => {
const video = videoRef.current;
const clamped = Math.min(1, Math.max(0, nextVolume));
setVolume(clamped);
persistedVolume = clamped;
if (nextMuted !== undefined) {
setMuted(nextMuted);
persistedMuted = nextMuted;
if (video) video.muted = nextMuted;
}
if (video) video.volume = clamped;
showControls();
},
[showControls],
);
const toggleMute = useCallback(() => {
const next = !muted;
setMuted(next);
persistedMuted = next;
const video = videoRef.current;
if (video) video.muted = next;
showControls();
}, [muted, showControls]);
const toggleLoop = useCallback(() => {
setLoop((value) => {
const video = videoRef.current;
if (video) video.loop = !value;
return !value;
});
showControls();
}, [showControls]);
const toggleFullscreen = useCallback(() => {
if (document.fullscreenElement) {
void document.exitFullscreen().catch(() => {});
} else {
void containerRef.current?.requestFullscreen().catch(() => {});
}
showControls();
}, [showControls]);
useEffect(() => {
const onChange = () => setFullscreen(document.fullscreenElement !== null);
document.addEventListener("fullscreenchange", onChange);
return () => document.removeEventListener("fullscreenchange", onChange);
}, []);
const setSpeed = useCallback(
(rate: number) => {
setPlaybackRate(rate);
const video = videoRef.current;
if (video) video.playbackRate = rate;
setSpeedMenuOpen(false);
showControls();
},
[showControls],
);
// ── Scrubber ───────────────────────────────────────────────────────────────
const seekToPointer = useCallback((clientX: number) => {
const video = videoRef.current;
const track = trackRef.current;
if (!video || !track || !Number.isFinite(video.duration) || video.duration <= 0) return;
const bounds = track.getBoundingClientRect();
const fraction = Math.min(1, Math.max(0, (clientX - bounds.left) / bounds.width));
video.currentTime = fraction * video.duration;
setCurrentTime(video.currentTime);
}, []);
const handleTrackPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
scrubbingRef.current = true;
seekToPointer(event.clientX);
},
[seekToPointer],
);
const handleTrackPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!scrubbingRef.current) return;
seekToPointer(event.clientX);
},
[seekToPointer],
);
const handleTrackPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
event.currentTarget.releasePointerCapture(event.pointerId);
scrubbingRef.current = false;
}, []);
// ── Keyboard ───────────────────────────────────────────────────────────────
useEffect(() => {
const handler = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) {
return;
}
switch (event.key) {
case " ":
event.preventDefault();
togglePlay();
break;
case "m":
case "M":
toggleMute();
break;
case "f":
case "F":
toggleFullscreen();
break;
case "l":
case "L":
toggleLoop();
break;
case "ArrowLeft":
if (event.shiftKey) {
event.preventDefault();
seekBy(-SEEK_STEP_SECONDS);
}
break;
case "ArrowRight":
if (event.shiftKey) {
event.preventDefault();
seekBy(SEEK_STEP_SECONDS);
}
break;
case "ArrowUp":
event.preventDefault();
// Adjusting volume always unmutes, matching the slider behavior.
applyVolume(persistedVolume + VOLUME_STEP, false);
break;
case "ArrowDown":
event.preventDefault();
applyVolume(persistedVolume - VOLUME_STEP, false);
break;
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [togglePlay, toggleMute, toggleFullscreen, toggleLoop, seekBy, applyVolume]);
const playedFraction = duration > 0 ? currentTime / duration : 0;
const effectiveVolume = muted ? 0 : volume;
return (
<div
ref={containerRef}
className={`media-dark-surface relative flex h-full w-full items-center justify-center bg-black ${controlsVisible ? "" : "cursor-none"}`}
onPointerMove={showControls}
onClick={(event) => event.stopPropagation()}
>
<video
ref={videoRef}
src={src}
className="h-full w-full object-contain"
onClick={togglePlay}
onDoubleClick={toggleFullscreen}
onPlay={() => {
setPlaying(true);
showControls();
}}
onPause={() => {
setPlaying(false);
setControlsVisible(true);
}}
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
onLoadedMetadata={(event) => {
setDuration(event.currentTarget.duration);
readBuffered();
}}
onDurationChange={(event) => setDuration(event.currentTarget.duration)}
onProgress={readBuffered}
/>
<AnimatePresence>
{controlsVisible ? (
<motion.div
className="absolute inset-x-0 bottom-0 z-10 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-4 pb-2.5 pt-12"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{/* Scrubber */}
<div
ref={trackRef}
className="group/track relative flex h-4 cursor-pointer items-center"
onPointerDown={handleTrackPointerDown}
onPointerMove={handleTrackPointerMove}
onPointerUp={handleTrackPointerUp}
>
<div className="relative h-1 w-full overflow-hidden rounded-full bg-white/15 transition-[height] group-hover/track:h-1.5">
{buffered.map((range, index) => (
<div
key={index}
className="absolute inset-y-0 bg-white/20"
style={{ left: `${range.start * 100}%`, width: `${(range.end - range.start) * 100}%` }}
/>
))}
<div className="absolute inset-y-0 left-0 bg-white/90" style={{ width: `${playedFraction * 100}%` }} />
</div>
<div
className="pointer-events-none absolute h-3 w-3 -translate-x-1/2 rounded-full bg-white opacity-0 shadow transition-opacity group-hover/track:opacity-100"
style={{ left: `${playedFraction * 100}%` }}
/>
</div>
{/* Control row */}
<div className="mt-1 flex items-center gap-1.5">
<ControlButton onClick={togglePlay} title={playing ? "Pause (Space)" : "Play (Space)"}>
{playing ? (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 5h4v14H7zM13 5h4v14h-4z" />
</svg>
) : (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5.14v13.72L19 12 8 5.14z" />
</svg>
)}
</ControlButton>
<span className="ml-1 text-xs tabular-nums text-gray-300">
{formatTime(currentTime)} <span className="text-gray-500">/ {formatTime(duration)}</span>
</span>
<div className="flex-1" />
{/* Volume */}
<ControlButton onClick={toggleMute} title={muted ? "Unmute (M)" : "Mute (M)"}>
{effectiveVolume === 0 ? (
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M17 9l4 6m0-6l-4 6" />
</svg>
) : (
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15.536 8.464a5 5 0 010 7.072M18.364 5.636a9 9 0 010 12.728" />
</svg>
)}
</ControlButton>
<input
type="range"
min={0}
max={1}
step={0.02}
value={effectiveVolume}
className="h-1 w-20 cursor-pointer accent-white"
onChange={(event) => applyVolume(parseFloat(event.target.value), false)}
onClick={(event) => event.stopPropagation()}
title="Volume (↑/↓)"
/>
{/* Playback speed */}
<div className="relative">
<button
className={`min-w-12 rounded-md px-2 py-1.5 text-xs tabular-nums transition-colors hover:bg-white/10 ${playbackRate !== 1 ? "text-white" : "text-gray-300 hover:text-white"}`}
onClick={(event) => {
event.stopPropagation();
setSpeedMenuOpen((value) => !value);
}}
title="Playback speed"
>
{playbackRate}×
</button>
{speedMenuOpen ? (
<div className="absolute bottom-full right-0 z-20 mb-2 min-w-20 rounded-lg border border-white/10 bg-gray-950/95 p-1 shadow-2xl backdrop-blur">
{SPEED_OPTIONS.map((rate) => (
<button
key={rate}
className={`flex w-full items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-left text-xs tabular-nums transition-colors ${
rate === playbackRate ? "bg-white/10 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white"
}`}
onClick={(event) => {
event.stopPropagation();
setSpeed(rate);
}}
>
{rate}×
</button>
))}
</div>
) : null}
</div>
{/* Loop */}
<ControlButton onClick={toggleLoop} title={loop ? "Loop on (L)" : "Loop off (L)"} active={loop}>
<svg className={`h-4.5 w-4.5 ${loop ? "text-emerald-300" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h5M20 20v-5h-5M4 9a8 8 0 0114-3M20 15a8 8 0 01-14 3" />
</svg>
</ControlButton>
{/* Fullscreen */}
<ControlButton onClick={toggleFullscreen} title={fullscreen ? "Exit fullscreen (F)" : "Fullscreen (F)"}>
{fullscreen ? (
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9H4m5 0V4m6 5h5m-5 0V4M9 15H4m5 0v5m6-5h5m-5 0v5" />
</svg>
) : (
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 8V4h4M20 8V4h-4M4 16v4h4M20 16v4h-4" />
</svg>
)}
</ControlButton>
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
@@ -1,116 +0,0 @@
import { useEffect } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../../store";
import { StepWelcome } from "./StepWelcome";
import { StepAddLibrary } from "./StepAddLibrary";
import { StepPipeline } from "./StepPipeline";
import { StepGalleryPreview } from "./StepGalleryPreview";
import { StepSearchDemo } from "./StepSearchDemo";
import { StepViews } from "./StepViews";
import { StepAiFeatures } from "./StepAiFeatures";
import { StepUpdates } from "./StepUpdates";
const STEPS: { id: string; title: string; component: () => React.ReactNode }[] = [
{ id: "welcome", title: "Welcome", component: () => <StepWelcome /> },
{ id: "library", title: "Your library", component: () => <StepAddLibrary /> },
{ id: "pipeline", title: "Background work", component: () => <StepPipeline /> },
{ id: "gallery", title: "The gallery", component: () => <StepGalleryPreview /> },
{ id: "search", title: "Search", component: () => <StepSearchDemo /> },
{ id: "views", title: "Views", component: () => <StepViews /> },
{ id: "ai", title: "AI features", component: () => <StepAiFeatures /> },
{ id: "updates", title: "Staying current", component: () => <StepUpdates /> },
];
export function OnboardingOverlay() {
const onboardingOpen = useGalleryStore((s) => s.onboardingOpen);
const onboardingStep = useGalleryStore((s) => s.onboardingStep);
const setOnboardingStep = useGalleryStore((s) => s.setOnboardingStep);
const completeOnboarding = useGalleryStore((s) => s.completeOnboarding);
useEffect(() => {
if (!onboardingOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") completeOnboarding();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onboardingOpen, completeOnboarding]);
if (!onboardingOpen) return null;
const step = STEPS[Math.min(onboardingStep, STEPS.length - 1)];
const isFirst = onboardingStep === 0;
const isLast = onboardingStep >= STEPS.length - 1;
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/65 backdrop-blur-sm 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-gray-950 shadow-2xl shadow-black/60 light-theme:border-gray-300/70">
{/* Header */}
<div className="flex items-center justify-between border-b border-white/[0.07] px-7 py-4 light-theme:border-gray-300/70">
<div>
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">
Step {onboardingStep + 1} of {STEPS.length}
</p>
<h2 className="mt-0.5 text-base font-semibold text-white">{step.title}</h2>
</div>
<div className="flex items-center gap-1.5">
{STEPS.map((s, i) => (
<button
key={s.id}
aria-label={s.title}
className={`h-1.5 rounded-full transition-all ${
i === onboardingStep
? "w-5 bg-white/70 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)}
/>
))}
</div>
</div>
{/* Step body */}
<div className="min-h-0 flex-1 overflow-y-auto px-7 py-6">
<AnimatePresence mode="wait">
<motion.div
key={step.id}
initial={{ opacity: 0, x: 14 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -14 }}
transition={{ duration: 0.16 }}
>
{step.component()}
</motion.div>
</AnimatePresence>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-white/[0.07] px-7 py-4 light-theme:border-gray-300/70">
<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 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}
>
Skip tour
</button>
<div className="flex items-center gap-2">
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 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)}
disabled={isFirst}
>
Back
</button>
<button
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-4 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700 light-theme:hover:bg-emerald-200"
onClick={() => (isLast ? completeOnboarding() : setOnboardingStep(onboardingStep + 1))}
>
{isLast ? "Finish" : "Next"}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -1,61 +0,0 @@
import { useGalleryStore } from "../../store";
import { FakeTile } from "./fakes";
export function StepAddLibrary() {
const folders = useGalleryStore((s) => s.folders);
const setFolderPickerOpen = useGalleryStore((s) => s.setFolderPickerOpen);
const handlePick = () => {
setFolderPickerOpen(true);
};
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
A library is just a folder on disk Phokus watches it and keeps itself in sync as files are
added, renamed, or removed. Nothing is moved or copied.
</p>
<div className="mt-5 flex items-center justify-between gap-6 border-y border-white/[0.05] py-4 light-theme:border-gray-300/70">
<div className="min-w-0">
{folders.length > 0 ? (
<>
<p className="text-sm text-white">
{folders.length === 1 ? `${folders[0].name}” added` : `${folders.length} folders in your library`}
</p>
<p className="mt-1 text-xs text-gray-500">
Indexing runs in the background you can add more folders from the sidebar any time.
</p>
</>
) : (
<>
<p className="text-sm text-white">Add your first folder</p>
<p className="mt-1 text-xs text-gray-500">
Pick somewhere with photos or videos. You can add more later from the sidebar.
</p>
</>
)}
</div>
<button
className="shrink-0 rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700 light-theme:hover:bg-emerald-200"
onClick={handlePick}
>
{folders.length > 0 ? "Add another folder" : "Choose a folder"}
</button>
</div>
<p className="mt-5 text-xs leading-relaxed text-gray-500">
As indexing runs, the gallery fills in roughly like this tiles appear immediately and sharpen
as thumbnails are generated:
</p>
<div className="media-dark-surface mt-3 grid grid-cols-6 gap-1.5">
<FakeTile index={0} />
<FakeTile index={1} favorite />
<FakeTile index={2} duration="0:42" />
<FakeTile index={3} rating={4} />
<FakeTile index={4} loaded={false} />
<FakeTile index={5} loaded={false} />
</div>
</div>
);
}
@@ -1,113 +0,0 @@
import { useEffect } from "react";
import { TaggerModelProgress, useGalleryStore } from "../../store";
import { FakeProgressBar, formatBytes } from "./fakes";
const FAKE_TAGS = ["landscape", "sunset", "outdoors", "no_humans", "ocean", "cloudy_sky"];
// Prefer the current file's byte fraction (the 1.3 GB model dominates); fall
// back to the coarse step count; null renders an indeterminate bar.
function taggerDownloadFraction(progress: TaggerModelProgress | null): number | null {
if (!progress) return null;
if (progress.downloaded_bytes != null && progress.total_bytes != null && progress.total_bytes > 0) {
return progress.downloaded_bytes / progress.total_bytes;
}
if (progress.total_files > 0) return progress.completed_files / progress.total_files;
return null;
}
export function StepAiFeatures() {
const taggerModelStatus = useGalleryStore((s) => s.taggerModelStatus);
const taggerModelPreparing = useGalleryStore((s) => s.taggerModelPreparing);
const taggerModelProgress = useGalleryStore((s) => s.taggerModelProgress);
const taggerModelError = useGalleryStore((s) => s.taggerModelError);
const prepareTaggerModel = useGalleryStore((s) => s.prepareTaggerModel);
const loadTaggerModelStatus = useGalleryStore((s) => s.loadTaggerModelStatus);
useEffect(() => {
void loadTaggerModelStatus();
}, [loadTaggerModelStatus]);
const taggerReady = taggerModelStatus?.ready ?? false;
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
Phokus's AI runs entirely on this machine nothing is sent anywhere. Semantic search sets
itself up automatically; AI tagging is optional and only downloads if you want it.
</p>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">AI tagging optional</h4>
<div className="mt-1 divide-y divide-white/[0.05] light-theme:divide-gray-300/70">
<div className="py-4">
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<p className="text-sm text-white">Automatic tags for every image</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">
The WD tagger model (~1.3 GB download) labels images so you can search with{" "}
<code className="rounded bg-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>
<span className="mt-2 flex flex-wrap gap-1.5">
{FAKE_TAGS.map((tag) => (
<span key={tag} className="rounded-md border border-white/10 bg-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}
</span>
))}
</span>
</div>
<div className="shrink-0">
{taggerReady ? (
<span className="inline-flex rounded-md border border-emerald-400/25 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-300 light-theme:border-emerald-600/40 light-theme:bg-emerald-100 light-theme:text-emerald-700">
Installed
</span>
) : (
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => void prepareTaggerModel()}
disabled={taggerModelPreparing}
>
{taggerModelPreparing ? "Downloading..." : "Download now"}
</button>
)}
</div>
</div>
{taggerModelPreparing ? (
<div className="mt-3">
<FakeProgressBar fraction={taggerDownloadFraction(taggerModelProgress)} />
<div className="mt-1.5 flex items-center justify-between gap-3 text-[11px] text-gray-600">
<span className="truncate">{taggerModelProgress?.current_file ?? "Preparing..."}</span>
{taggerModelProgress?.downloaded_bytes != null && taggerModelProgress.total_bytes != null ? (
<span className="shrink-0 tabular-nums">
{formatBytes(taggerModelProgress.downloaded_bytes)} / {formatBytes(taggerModelProgress.total_bytes)}
</span>
) : null}
</div>
</div>
) : null}
{!taggerModelPreparing && taggerModelError ? (
<p className="mt-2 text-xs leading-relaxed text-amber-300/90 light-theme:text-amber-700">
Download failed: {taggerModelError}
</p>
) : null}
</div>
</div>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">Semantic search & similarity built in</h4>
<div className="mt-1 divide-y divide-white/[0.05] light-theme:divide-gray-300/70">
<div className="py-4">
<p className="text-sm text-white">Search by meaning, find look-alikes</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">
Powers <code className="rounded bg-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
(~580 MB) downloads automatically the first time embeddings run. Nothing to do you'll see it
in the background-tasks bar.
</p>
</div>
</div>
<p className="mt-5 text-xs leading-relaxed text-gray-500">
Semantic search and similarity are part of the standard pipeline; AI tagging stays optional and
can be downloaded any time from Settings.
</p>
</div>
);
}
@@ -1,61 +0,0 @@
import { useEffect, useState } from "react";
import { FakeTile, ReplayButton } from "./fakes";
const REVEAL_MS = 280;
// Two rows (cols-4) keeps the explainer text visible without scrolling.
const TILE_PROPS: { favorite?: boolean; rating?: number; duration?: string }[] = [
{},
{ favorite: true },
{},
{ duration: "1:24" },
{ rating: 5 },
{ favorite: true, rating: 3 },
{ duration: "0:09" },
{},
];
const TILE_COUNT = TILE_PROPS.length;
/// Placeholder tiles "loading in" once (skeleton → image), then stopping fully
/// revealed with a replay control.
export function StepGalleryPreview() {
const [revealed, setRevealed] = useState(0);
useEffect(() => {
if (revealed >= TILE_COUNT) return;
const timer = setTimeout(() => setRevealed((n) => n + 1), REVEAL_MS);
return () => clearTimeout(timer);
}, [revealed]);
const finished = revealed >= TILE_COUNT;
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
The gallery is a virtualized grid it stays fast with hundreds of thousands of items. Tiles
carry your favorites, star ratings, and video durations; hover for filename and quick actions.
</p>
<div className="media-dark-surface mt-5 grid grid-cols-4 gap-1.5">
{TILE_PROPS.map((props, i) => (
<FakeTile key={i} index={i} loaded={i < revealed} {...props} />
))}
</div>
<div className="mt-6 flex items-start justify-between gap-4">
<div className="divide-y divide-white/[0.05] text-xs leading-relaxed text-gray-500 light-theme:divide-gray-300/70">
<p className="pb-2.5">
<span className="text-gray-300">Click any tile</span> to open the lightbox keyboard navigation,
zoom, inline tag editing, ratings, and a full video player.
</p>
<p className="pt-2.5">
<span className="text-gray-300">The toolbar</span> filters by type, favorites, or rating, sorts by
date/name/size, and switches grid density.
</p>
</div>
{finished ? <ReplayButton onClick={() => setRevealed(0)} /> : null}
</div>
</div>
);
}
@@ -1,89 +0,0 @@
import { useEffect, useState } from "react";
import { FakeProgressBar, FakeStageTag, ReplayButton } from "./fakes";
const STAGES = ["Thumbnails", "Metadata", "Embeddings", "Tags"] as const;
const STAGE_TOTAL = 128;
const TICK_MS = 80;
const STEP_PER_TICK = 8;
/// A one-shot fake of the background-tasks bar: each stage drains in order,
/// then it stops at "all done" with a replay control.
export function StepPipeline() {
const [stageIndex, setStageIndex] = useState(0);
const [filled, setFilled] = useState(0);
const finished = stageIndex >= STAGES.length;
useEffect(() => {
if (finished) return;
const timer = setTimeout(() => {
// Read from the closure and call setters directly — nesting one setter
// inside another's updater double-advances under React StrictMode.
if (filled + STEP_PER_TICK < STAGE_TOTAL) {
setFilled(filled + STEP_PER_TICK);
} else {
setStageIndex(stageIndex + 1);
setFilled(0);
}
}, TICK_MS);
return () => clearTimeout(timer);
}, [filled, stageIndex, finished]);
const replay = () => {
setStageIndex(0);
setFilled(0);
};
const remaining = STAGE_TOTAL - filled;
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
After indexing, Phokus works through a strict pipeline: thumbnails first, then video metadata,
then visual embeddings (for similarity and semantic search), then AI tags. One stage at a time,
so each runs at full speed.
</p>
<p className="mt-5 text-xs text-gray-500">
While it's working you'll see this bar above the gallery here's a preview:
</p>
{/* Fake BackgroundTasks slim bar */}
<div className="mt-3 rounded-lg border border-white/[0.07] bg-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">
<span className="relative flex h-1.5 w-1.5 shrink-0">
{!finished ? (
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-60" />
) : null}
<span className={`relative inline-flex h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-blue-400"}`} />
</span>
<span className="text-[13px] font-medium text-white/60 light-theme:text-gray-500">Holiday Photos</span>
<div className="flex items-center gap-1.5">
{STAGES.map((stage, i) => (
<FakeStageTag
key={stage}
label={!finished && i === stageIndex ? `${stage} · ${remaining}` : stage}
state={finished || i < stageIndex ? "done" : i === stageIndex ? "active" : "waiting"}
/>
))}
</div>
<FakeProgressBar fraction={finished ? 1 : filled / STAGE_TOTAL} className="ml-auto w-24" />
</div>
</div>
<div className="mt-6 flex items-start justify-between gap-4">
<div className="divide-y divide-white/[0.05] text-xs leading-relaxed text-gray-500 light-theme:divide-gray-300/70">
<p className="pb-2.5">
<span className="text-gray-300">It's all interruptible.</span> Close the app whenever you like
the queue picks up where it left off next launch.
</p>
<p className="pt-2.5">
<span className="text-gray-300">Per-folder control.</span> Right-click a folder in the sidebar to
pause its background work, and click the bar itself to expand a detailed per-folder view.
</p>
</div>
{finished ? <ReplayButton onClick={replay} /> : null}
</div>
</div>
);
}
@@ -1,108 +0,0 @@
import { useEffect, useState } from "react";
import { ReplayButton, SEARCH_RESULTS } from "./fakes";
const DEMOS = [
{
query: "beach-day_042.jpg",
mode: "Filename",
description: "Plain text matches file names — the default, instant search. One name, one file.",
results: SEARCH_RESULTS.filename,
},
{
query: "/s golden sunset over water",
mode: "Semantic",
description: "Describe what's in the picture. Visual embeddings find matches even when filenames say nothing.",
results: SEARCH_RESULTS.semantic,
},
{
query: "/t landscape",
mode: "Tags",
description: "Search by AI or manual tags. Autocomplete suggests tags as you type.",
results: SEARCH_RESULTS.tags,
},
] as const;
const TYPE_MS = 55;
const HOLD_MS = 2600;
/// Types each demo query, shows matching results, advances through all three
/// modes once, then stops on the last with a replay control.
export function StepSearchDemo() {
const [demoIndex, setDemoIndex] = useState(0);
const [typed, setTyped] = useState(0);
const [finished, setFinished] = useState(false);
const demo = DEMOS[demoIndex];
const isLastDemo = demoIndex === DEMOS.length - 1;
useEffect(() => {
if (finished) return;
if (typed < demo.query.length) {
const timer = setTimeout(() => setTyped((n) => n + 1), TYPE_MS);
return () => clearTimeout(timer);
}
// Fully typed: hold, then advance — or finish on the last demo.
const timer = setTimeout(() => {
if (isLastDemo) {
setFinished(true);
} else {
setDemoIndex((i) => i + 1);
setTyped(0);
}
}, HOLD_MS);
return () => clearTimeout(timer);
}, [typed, demo.query.length, isLastDemo, finished]);
const replay = () => {
setDemoIndex(0);
setTyped(0);
setFinished(false);
};
const fullyTyped = typed >= demo.query.length;
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
One search bar, three modes picked by prefix. No prefix searches filenames, <code className="rounded bg-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-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>
{/* Mock search bar */}
<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}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<span className="text-sm text-white">
{demo.query.slice(0, typed)}
{!finished ? <span className="animate-pulse text-gray-500">|</span> : null}
</span>
<span className="ml-auto shrink-0 rounded-md border border-sky-400/25 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium text-sky-300 light-theme:border-sky-600/40 light-theme:bg-sky-100 light-theme:text-sky-700">
{demo.mode}
</span>
</div>
{/* Results hidden (not greyed) until the query finishes typing, so it
doesn't look like the app is reading the user's mind. Space is held
by the invisible tiles so the layout doesn't jump when they appear.
Keyed by demoIndex so switching demos remounts the row fresh at
opacity-0 instead of fading the new images out from the previous
demo's visible state. */}
<div
key={demoIndex}
className={`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) => (
<div key={i} className="aspect-square w-36 overflow-hidden rounded-xl bg-white/[0.04]">
<img src={src} alt="" className="h-full w-full object-cover" />
</div>
))}
</div>
<div className="mt-4 flex min-h-10 items-start justify-between gap-4">
<p className="text-xs leading-relaxed text-gray-500">{demo.description}</p>
{finished ? <ReplayButton onClick={replay} /> : null}
</div>
</div>
);
}
-86
View File
@@ -1,86 +0,0 @@
import { PhokusMark } from "../PhokusMark";
// Closing step. Introduces the app mark (the aperture in the title bar) and,
// since that same mark doubles as the update indicator, explains how updates
// work in one place. The mini app-window mockup shows the lit mark exactly
// where it appears, so there's nothing abstract to decode.
export function StepUpdates() {
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
Phokus keeps itself up to date it quietly checks for new versions on startup, so you don't
have to go looking for one.
</p>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">
When an update is ready
</h4>
<div className="mt-1 py-4">
<p className="text-sm text-white">The mark in the title bar lights up</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">
That aperture in the top-left corner is Phokus. When a new version is waiting, its focal point
glows amber click it to update and relaunch. Nothing installs on its own; you're always in
control.
</p>
{/* Mini app-window mockup: the lit mark shown in a real title bar. */}
<div className="mt-3 overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-lg light-theme:border-gray-300/70">
<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-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 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/55 animate-ping" />
<PhokusMark className="relative h-[18px] w-[18px]" dotClassName="fill-amber-400" />
</div>
<span className="text-xs font-semibold tracking-wide text-gray-300">Phokus</span>
<div className="flex-1" />
<div className="flex items-center gap-3.5 text-gray-600">
<svg width="9" height="9" viewBox="0 0 10 10" fill="none">
<rect y="4.5" width="10" height="1" rx="0.5" fill="currentColor" />
</svg>
<svg width="9" height="9" viewBox="0 0 10 10" fill="none">
<rect x="0.5" y="0.5" width="9" height="9" rx="1.5" stroke="currentColor" />
</svg>
<svg width="9" height="9" viewBox="0 0 10 10" fill="none">
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
</svg>
</div>
</div>
{/* Ghosted window body, just enough to read as the app. */}
<div className="flex h-[72px] bg-gray-900/30">
<div className="w-14 shrink-0 border-r border-white/[0.05] p-2.5 light-theme:border-gray-300/70">
<div className="h-1.5 w-full rounded bg-gray-800" />
<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-gray-900" />
</div>
<div className="flex-1 p-2.5">
<div className="h-2 w-20 rounded bg-gray-800" />
<div className="mt-2.5 grid grid-cols-5 gap-1.5">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-7 rounded bg-gray-900" />
))}
</div>
</div>
</div>
</div>
</div>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">
Prefer to manage it yourself?
</h4>
<div className="mt-1 py-4">
<p className="text-sm text-white">Check any time from Settings</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">
Settings General shows your current version with a manual{" "}
<span className="text-gray-300">Check for updates</span> button, so you can update on your own
schedule.
</p>
</div>
<p className="mt-5 text-xs leading-relaxed text-gray-500">
That's the tour. Add folders from the sidebar, and revisit any of this from Settings including
re-running this tour.
</p>
</div>
);
}
-94
View File
@@ -1,94 +0,0 @@
import { FakeTile, tileGradient } from "./fakes";
function ViewRow({ title, description, preview }: { title: string; description: string; preview: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm text-white">{title}</p>
<p className="mt-1 max-w-md text-xs leading-relaxed text-gray-500">{description}</p>
</div>
<div className="shrink-0">{preview}</div>
</div>
);
}
function ExplorePreview() {
// Cluster blobs of varying size, like the tag cloud / cluster map.
const blobs = [
{ size: 34, x: 4, y: 10, i: 0 },
{ size: 24, x: 46, y: 0, i: 2 },
{ size: 18, x: 86, y: 22, i: 4 },
{ size: 26, x: 30, y: 36, i: 1 },
{ size: 16, x: 70, y: 44, i: 6 },
];
return (
<div className="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) => (
<div
key={idx}
className={`absolute rounded-full bg-gradient-to-br opacity-80 ${tileGradient(blob.i)}`}
style={{ width: blob.size, height: blob.size, left: blob.x, top: blob.y }}
/>
))}
</div>
);
}
function TimelinePreview() {
return (
<div className="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) => (
<div key={year} className="flex items-center gap-1.5">
<span className="w-7 text-[9px] tabular-nums text-gray-600">{year}</span>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={`h-4 w-4 rounded-sm bg-gradient-to-br ${tileGradient(row * 3 + i)}`} />
))}
</div>
))}
</div>
);
}
function DuplicatesPreview() {
return (
<div className="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">
<FakeTile index={3} className="rounded-md" />
</div>
<div className="relative w-10">
<FakeTile index={3} className="rounded-md" />
<span className="absolute -right-1 -top-1 rounded-full bg-amber-500/90 px-1 text-[8px] font-semibold text-black">2×</span>
</div>
</div>
);
}
export function StepViews() {
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
Beyond the main grid, the sidebar switches between three more ways to look at your library:
</p>
<div className="mt-3 divide-y divide-white/[0.05] light-theme:divide-gray-300/70">
<ViewRow
title="Explore"
description="A visual cluster map and tag cloud — browse by theme instead of folder, and jump into any cluster."
preview={<ExplorePreview />}
/>
<ViewRow
title="Timeline"
description="Everything grouped chronologically by capture date (EXIF), from years down to days."
preview={<TimelinePreview />}
/>
<ViewRow
title="Duplicates"
description="A three-phase exact-duplicate scan with bulk delete — reclaim space from copies safely."
preview={<DuplicatesPreview />}
/>
</div>
<p className="mt-4 text-xs leading-relaxed text-gray-500">
Each view can be scoped to a single folder from its header no need to bounce through the sidebar.
</p>
</div>
);
}
-173
View File
@@ -1,173 +0,0 @@
import { AppTheme, useGalleryStore } from "../../store";
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() {
const ffmpegStatus = useGalleryStore((s) => s.ffmpegStatus);
const ffmpegProgress = useGalleryStore((s) => s.ffmpegProgress);
const ffmpegError = useGalleryStore((s) => s.ffmpegError);
const retryFfmpegDownload = useGalleryStore((s) => s.retryFfmpegDownload);
if (ffmpegStatus === "installed") {
return (
<div className="flex items-center gap-2.5 py-3">
<svg className="h-4 w-4 shrink-0 text-emerald-400 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" />
</svg>
<div>
<p className="text-sm text-white">Media engine ready</p>
<p className="text-xs text-gray-500">FFmpeg is installed video thumbnails and metadata are available.</p>
</div>
</div>
);
}
if (ffmpegStatus === "error") {
return (
<div className="py-3">
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<p className="text-sm text-white">Media engine download failed</p>
<p className="mt-1 text-xs leading-relaxed text-amber-300/90 light-theme:text-amber-700">{ffmpegError}</p>
<p className="mt-1.5 text-xs leading-relaxed text-gray-500">
You can keep going photos work without it. Video thumbnails and metadata will start
automatically once the download succeeds.
</p>
</div>
<button
className="shrink-0 rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white 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()}
>
Retry download
</button>
</div>
</div>
);
}
const fraction =
ffmpegStatus === "downloading" && ffmpegProgress && ffmpegProgress.total_bytes > 0
? ffmpegProgress.downloaded_bytes / ffmpegProgress.total_bytes
: null;
return (
<div className="py-3">
<div className="flex items-baseline justify-between gap-6">
<p className="text-sm text-white">
{ffmpegStatus === "unpacking" ? "Unpacking media engine..." : "Downloading media engine (FFmpeg)..."}
</p>
{ffmpegProgress ? (
<span className="text-[11px] tabular-nums text-gray-500">
{formatBytes(ffmpegProgress.downloaded_bytes)} / {formatBytes(ffmpegProgress.total_bytes)}
</span>
) : null}
</div>
<FakeProgressBar fraction={ffmpegStatus === "unpacking" ? null : fraction} className="mt-2.5" />
<p className="mt-2 text-xs leading-relaxed text-gray-500">
FFmpeg powers video thumbnails and metadata. It downloads once in the background you can keep
using the tour and the app while it finishes.
</p>
</div>
);
}
export function StepWelcome() {
const theme = useGalleryStore((s) => s.theme);
const setTheme = useGalleryStore((s) => s.setTheme);
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
Phokus is a local-first media library: point it at your folders and it builds a fast, searchable
gallery with thumbnails, AI tags, and visual search. Everything is processed on this machine
your photos and videos never leave it.
</p>
<p className="mt-2.5 text-sm leading-relaxed text-gray-500">
This short tour shows what to expect. Every step is skippable, and you can re-run it any time
from Settings.
</p>
<h4 className="mt-7 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">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 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700 light-theme:hover:bg-emerald-200"
: "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>
<div className="mt-1 divide-y divide-white/[0.05] light-theme:divide-gray-300/70">
<FfmpegStatusRow />
</div>
</div>
);
}
-161
View File
@@ -1,161 +0,0 @@
// Shared placeholder primitives for the onboarding tour. Everything here is
// deliberately fake — rights-clean demo stills (generated, owned), looping
// demo progress — so new users see the real UI's shapes before their own
// library exists.
import sunset from "../../assets/onboarding/sunset.webp";
import sunsetcoast from "../../assets/onboarding/sunsetcoast.webp";
import sunsetlake from "../../assets/onboarding/sunsetlake.webp";
import beach from "../../assets/onboarding/beach.webp";
import landscape1 from "../../assets/onboarding/landscape1.webp";
import landscape2 from "../../assets/onboarding/landscape2.webp";
import forest from "../../assets/onboarding/forest.webp";
import citynight from "../../assets/onboarding/citynight.webp";
import flower from "../../assets/onboarding/flower.webp";
import alpinelake from "../../assets/onboarding/alpinelake.webp";
import dunes from "../../assets/onboarding/dunes.webp";
import cozy from "../../assets/onboarding/cozy.webp";
import cat from "../../assets/onboarding/cat.webp";
import balloon from "../../assets/onboarding/balloon.webp";
import architecture from "../../assets/onboarding/architecture.webp";
// Ordered for grid variety: adjacent indices are visually distinct.
const FAKE_IMAGES = [
sunset,
cozy,
landscape1,
citynight,
flower,
dunes,
alpinelake,
cat,
forest,
balloon,
landscape2,
beach,
architecture,
];
export function fakeImage(index: number): string {
return FAKE_IMAGES[index % FAKE_IMAGES.length];
}
// Result sets matched to what each query would genuinely return.
// - filename: one exact file (a filename search hits a specific name)
// - semantic "golden sunset over water": only the ocean-sunset stills
// - tags "landscape": mountain valleys + the alpine lake (all landscapes)
export const SEARCH_RESULTS = {
filename: [beach],
semantic: [sunsetcoast, sunset, sunsetlake],
tags: [landscape1, landscape2, alpinelake],
} as const;
// Abstract gradient blobs/ticks for the Explore & Timeline mini-previews,
// where small non-photographic shapes read more clearly than tiny stills.
const TILE_GRADIENTS = [
"from-sky-900/70 via-slate-800 to-slate-900",
"from-amber-900/60 via-stone-800 to-stone-900",
"from-emerald-900/60 via-slate-800 to-gray-900",
"from-rose-900/50 via-slate-800 to-slate-900",
"from-indigo-900/60 via-slate-800 to-gray-900",
"from-cyan-900/60 via-slate-800 to-slate-900",
"from-fuchsia-900/40 via-slate-800 to-gray-900",
"from-orange-900/50 via-stone-800 to-stone-900",
];
export function tileGradient(index: number): string {
return TILE_GRADIENTS[index % TILE_GRADIENTS.length];
}
export function FakeTile({
index,
loaded = true,
favorite = false,
rating = 0,
duration,
className = "",
}: {
index: number;
loaded?: boolean;
favorite?: boolean;
rating?: number;
duration?: string;
className?: string;
}) {
return (
<div className={`media-dark-surface group relative aspect-square overflow-hidden rounded-xl bg-white/[0.04] ${className}`}>
{loaded ? (
<img src={fakeImage(index)} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
) : (
<div className="absolute inset-0 animate-pulse bg-white/[0.04]" />
)}
{loaded && favorite ? (
<div className="absolute right-1.5 top-1.5 rounded-full bg-black/50 p-1 text-rose-400 backdrop-blur-sm">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg>
</div>
) : null}
{loaded && rating > 0 ? (
<div className="absolute bottom-1.5 left-1.5 flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-1 text-amber-300">
{Array.from({ length: rating }).map((_, i) => (
<svg key={i} className="h-2.5 w-2.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.563.563 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
</svg>
))}
</div>
) : null}
{loaded && duration ? (
<div className="absolute bottom-1.5 right-1.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80">
{duration}
</div>
) : null}
</div>
);
}
export function FakeStageTag({ label, state }: { label: string; state: "active" | "done" | "waiting" }) {
const className =
state === "active"
? "bg-gray-900 text-gray-300 light-theme:bg-gray-900 light-theme:text-gray-300"
: state === "done"
? "bg-emerald-500/10 text-emerald-400 light-theme:bg-emerald-100 light-theme:text-emerald-700"
: "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>;
}
export function FakeProgressBar({ fraction, className = "" }: { fraction: number | null; className?: string }) {
return (
<div className={`h-px overflow-hidden rounded-full bg-gray-200/60 light-theme:bg-gray-300 ${className}`}>
{fraction === null ? (
<div className="h-full w-full animate-pulse bg-blue-500/40" />
) : (
<div
className="h-full bg-blue-500 transition-[width] duration-300"
style={{ width: `${Math.round(Math.min(fraction, 1) * 100)}%` }}
/>
)}
</div>
);
}
export function ReplayButton({ onClick, label = "Replay" }: { onClick: () => void; label?: string }) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-200 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}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12a7.5 7.5 0 0012.9 5.3M19.5 12A7.5 7.5 0 006.6 6.7M4.5 6.5v3h3M19.5 17.5v-3h-3" />
</svg>
{label}
</button>
);
}
export function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${bytes} B`;
}
+3 -249
View File
@@ -1,7 +1,5 @@
@import "tailwindcss";
@custom-variant light-theme (&:is(html[data-theme="subtle-light"] *));
* {
box-sizing: border-box;
}
@@ -12,255 +10,11 @@ body,
height: 100%;
margin: 0;
padding: 0;
background: var(--color-gray-950);
background: #030712;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
-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 {
background: linear-gradient(
to top,
rgb(251 250 246 / 0.9),
rgb(251 250 246 / 0.52) 34%,
rgb(251 250 246 / 0.06) 68%,
transparent
) !important;
}
html[data-theme="subtle-light"] .explore-cluster-label {
color: #6b6257 !important;
}
html[data-theme="subtle-light"] .explore-cluster-count {
color: #111827 !important;
}
html[data-theme="subtle-light"] .explore-tag-word:hover {
background: #e8e2d6 !important;
}
html[data-theme="subtle-light"] .explore-tag-word span:first-child {
color: #374151 !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 */
::-webkit-scrollbar {
width: 6px;
@@ -270,9 +24,9 @@ html[data-theme="subtle-light"] .feature-scope-option.bg-white\/6 {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--color-white, #fff) 12%, transparent);
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--color-white, #fff) 22%, transparent);
background: rgba(255, 255, 255, 0.2);
}
+58 -845
View File
File diff suppressed because it is too large Load Diff
-73
View File
@@ -1,73 +0,0 @@
#!/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.

Before

Width:  |  Height:  |  Size: 1002 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

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