20 Commits

Author SHA1 Message Date
LyAhn 7367845f8b Merge 0.1.1 QoL fixes
github/actions/ci GitHub Actions CI finished: failure
Post-release quality-of-life work for 0.1.1:

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

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

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

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

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

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

Screenshots are transcoded to AVIF/WebP at build via vite-imagetools (sharp);
~5.3MB of masters -> ~0.5MB served. Commits only the 7 in-use captures.
2026-06-15 19:39:56 +01:00
LyAhn 584a92b7cd ci: report GitHub Actions status to Gitea
github/actions/ci GitHub Actions CI finished: failure
2026-06-15 00:36:25 +01:00
LyAhn 6a5cf0afe3 docs(changelog): date the 0.1.0 release (2026-06-14)
CI / check (push) Has been cancelled
2026-06-14 20:28:22 +01:00
LyAhn ce804f5aa5 chore(gitignore): drop blanket *.json ignore, track configs directly
CI / check (push) Has been cancelled
The global *.json rule (added for transient ComfyUI workflow dumps) forced
git add -f on real configs and hid tauri.conf.json from tauri-action's
gitignore-aware globbing. Removed it and the negation workarounds; local-only
ignores (e.g. skills-lock.json) live in .git/info/exclude instead.
2026-06-14 20:10:18 +01:00
60 changed files with 3330 additions and 391 deletions
+20
View File
@@ -0,0 +1,20 @@
# External CI
CI and release builds run on the GitHub mirror because they require Windows
runner capacity. The GitHub workflows report their state back to this Gitea
repository through the commit status API.
Keep this directory in the repository. Gitea checks `.gitea/workflows` before
falling back to `.github/workflows`; an existing directory with no workflow
files prevents the GitHub-only workflows from being queued by Gitea Actions.
## Setup
1. In Gitea, create an access token with `write:repository` permission for an
account that can update `JezzWTF/phokus`.
2. In the GitHub repository, add that token as the Actions repository secret
`GITEA_STATUS_TOKEN`.
The status steps are non-blocking. If Gitea is temporarily unavailable, the
GitHub build result is preserved and the failed status update remains visible
in the workflow log.
+62
View File
@@ -3,7 +3,13 @@ name: CI
on:
push:
branches: [main]
paths:
- 'src/**'
- 'src-tauri/**'
pull_request:
paths:
- 'src/**'
- 'src-tauri/**'
workflow_dispatch:
concurrency:
@@ -15,7 +21,33 @@ jobs:
# windows-latest to match the release target — the ML crates (ort, candle)
# and the NSIS bundle only ever ship from Windows.
runs-on: windows-latest
env:
GITEA_STATUS_TOKEN: ${{ secrets.GITEA_STATUS_TOKEN }}
steps:
- name: Report pending status to Gitea
if: github.event_name != 'pull_request' && env.GITEA_STATUS_TOKEN != ''
continue-on-error: true
shell: pwsh
env:
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
$headers = @{
Authorization = "token $env:GITEA_STATUS_TOKEN"
Accept = "application/json"
}
$body = @{
state = "pending"
context = "github/actions/ci"
description = "GitHub Actions CI is running"
target_url = $env:GITHUB_RUN_URL
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$env:GITHUB_SHA" `
-Headers $headers `
-ContentType "application/json" `
-Body $body
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
@@ -52,3 +84,33 @@ jobs:
- name: Clippy
working-directory: src-tauri
run: cargo clippy --all-targets --locked --no-default-features -- -D warnings
- name: Report final status to Gitea
if: always() && github.event_name != 'pull_request' && env.GITEA_STATUS_TOKEN != ''
continue-on-error: true
shell: pwsh
env:
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
JOB_STATUS: ${{ job.status }}
run: |
$state = switch ($env:JOB_STATUS) {
"success" { "success" }
"failure" { "failure" }
default { "error" }
}
$headers = @{
Authorization = "token $env:GITEA_STATUS_TOKEN"
Accept = "application/json"
}
$body = @{
state = $state
context = "github/actions/ci"
description = "GitHub Actions CI finished: $env:JOB_STATUS"
target_url = $env:GITHUB_RUN_URL
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$env:GITHUB_SHA" `
-Headers $headers `
-ContentType "application/json" `
-Body $body
+56
View File
@@ -14,7 +14,33 @@ jobs:
permissions:
contents: write
runs-on: windows-latest
env:
GITEA_STATUS_TOKEN: ${{ secrets.GITEA_STATUS_TOKEN }}
steps:
- name: Report pending status to Gitea
if: env.GITEA_STATUS_TOKEN != ''
continue-on-error: true
shell: pwsh
env:
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
$headers = @{
Authorization = "token $env:GITEA_STATUS_TOKEN"
Accept = "application/json"
}
$body = @{
state = "pending"
context = "github/actions/release"
description = "GitHub Actions release is running"
target_url = $env:GITHUB_RUN_URL
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$env:GITHUB_SHA" `
-Headers $headers `
-ContentType "application/json" `
-Body $body
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
@@ -55,3 +81,33 @@ jobs:
# Cargo args after `--`: ship the CPU/DirectML build — default
# features enable candle-cuda, which runners (and most users) lack.
args: '-- --no-default-features'
- name: Report final status to Gitea
if: always() && env.GITEA_STATUS_TOKEN != ''
continue-on-error: true
shell: pwsh
env:
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
JOB_STATUS: ${{ job.status }}
run: |
$state = switch ($env:JOB_STATUS) {
"success" { "success" }
"failure" { "failure" }
default { "error" }
}
$headers = @{
Authorization = "token $env:GITEA_STATUS_TOKEN"
Accept = "application/json"
}
$body = @{
state = $state
context = "github/actions/release"
description = "GitHub Actions release finished: $env:JOB_STATUS"
target_url = $env:GITHUB_RUN_URL
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$env:GITHUB_SHA" `
-Headers $headers `
-ContentType "application/json" `
-Body $body
-9
View File
@@ -32,17 +32,8 @@ dist-ssr
# Misc
*.py
*.json
*.pyc
# Bundled CUDA runtime DLLs for the CUDA build (copied from the toolkit
# locally; ~600 MB, never committed). See RELEASE_PLAN.md "CUDA release variant".
src-tauri/cuda-redist/
# Keep the Tauri configs tracked despite the *.json rule above. tauri.conf.json
# must also be un-ignored so tauri-action can find it: it globs for the config
# honoring .gitignore, and an ignored config makes the release build fail with
# "Failed to resolve Tauri path".
!src-tauri/tauri.conf.json
!src-tauri/tauri.cuda.conf.json
+47 -1
View File
@@ -5,7 +5,53 @@ All notable changes to Phokus are documented here. The format is based on
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
(0.x: anything may change between minor versions).
## [0.1.0] — Unreleased
## [Unreleased]
### Added
- **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
- 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
- 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.
+1 -1
View File
@@ -34,7 +34,7 @@ A local-first desktop media library for browsing, filtering, and curating image
| Images | Videos |
|--------|--------|
| jpg, jpeg, png, gif, bmp | mp4, mov, m4v |
| tiff, tif, webp, avif, heic, heif | webm |
| tiff, tif, webp, avif | webm |
## Installation
+3
View File
@@ -7,11 +7,14 @@
"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"
+395
View File
@@ -76,6 +76,49 @@ 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':
@@ -161,6 +204,9 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@emnapi/runtime@1.11.1':
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
'@esbuild/aix-ppc64@0.27.7':
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
engines: {node: '>=18'}
@@ -317,6 +363,165 @@ packages:
cpu: [x64]
os: [win32]
'@fontsource-variable/inter@5.2.8':
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
'@fontsource-variable/space-grotesk@5.2.10':
resolution: {integrity: sha512-yJQO/o35/hAP3CFnpdFTwQku2yzJOae2HIpBmqkOVoxhhXJaQP3g+b6Jrz7u+eI7A5ZdCIf88uMWpBJdFiGr5w==}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -336,6 +541,15 @@ 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]
@@ -770,6 +984,9 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -805,6 +1022,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
imagetools-core@9.1.0:
resolution: {integrity: sha512-xQjs+2vrxLnAjCq+omuNkd5UQTld9/bP8+YT0LyYTlKfuSQtgUBvqhUwGugzSAh6sCdN+LnROMuLswn5hZ9Fhg==}
engines: {node: '>=20.0.0'}
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
@@ -955,6 +1176,15 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.8.4:
resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==}
engines: {node: '>=10'}
hasBin: true
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -984,6 +1214,12 @@ 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}
@@ -1159,6 +1395,11 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@emnapi/runtime@1.11.1':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.27.7':
optional: true
@@ -1237,6 +1478,106 @@ snapshots:
'@esbuild/win32-x64@0.27.7':
optional: true
'@fontsource-variable/inter@5.2.8': {}
'@fontsource-variable/space-grotesk@5.2.10': {}
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.11.1
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -1258,6 +1599,14 @@ 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
@@ -1599,6 +1948,8 @@ snapshots:
escalade@3.2.0: {}
estree-walker@2.0.2: {}
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@@ -1619,6 +1970,8 @@ snapshots:
graceful-fs@4.2.11: {}
imagetools-core@9.1.0: {}
jiti@2.6.1: {}
js-tokens@4.0.0: {}
@@ -1750,6 +2103,39 @@ snapshots:
semver@6.3.1: {}
semver@7.8.4: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.4
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
source-map-js@1.2.1: {}
tailwindcss@4.2.2: {}
@@ -1771,6 +2157,15 @@ 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
+4
View File
@@ -1,2 +1,6 @@
packages:
- website
allowBuilds:
esbuild: true
sharp: true
+43
View File
@@ -0,0 +1,43 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256" role="img" aria-label="Phokus">
<title>Phokus</title>
<!--
Phokus app mark — a camera iris.
The hexagon in the middle is the lens OPENING; each blade edge sweeps from a
corner of the opening out to the rim, all leaning the same way (the pinwheel).
Tuning knobs (edit and re-open in any browser/Figma):
* opening roundness -> the "52" radius in the opening <path> arcs.
smaller (toward 30) = rounder/softer hole; larger (toward 90) = flatter, sharper.
* blade curve -> the "110" radius in each blade <path>.
smaller = more pronounced curve; larger = straighter blade.
* blade sweep amount -> the +40deg baked into the blade endpoint (70.71,-84.27).
* curve DIRECTION -> the final flag in each "A r r 0 0 X" command (0<->1 flips the bow).
* weight -> stroke-width on the <g>.
* color -> stroke on the <g>; swap "#e9e9ec" for currentColor to inherit.
A brand-accent focal dot is provided at the bottom (commented out).
-->
<rect x="8" y="8" width="240" height="240" rx="52" fill="#0e0f14"/>
<g transform="translate(128 128)" fill="none" stroke="#e9e9ec" stroke-width="9"
stroke-linecap="round" stroke-linejoin="round">
<!-- outer rim -->
<circle r="110"/>
<!-- lens opening: soft, arc-rounded hexagon -->
<path d="M0,-46 A52,52 0 0 0 39.84,-23 A52,52 0 0 0 39.84,23 A52,52 0 0 0 0,46 A52,52 0 0 0 -39.84,23 A52,52 0 0 0 -39.84,-23 A52,52 0 0 0 0,-46 Z"/>
<!-- blades: one curved edge, swept six times -->
<g transform="rotate(0)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
<g transform="rotate(60)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
<g transform="rotate(120)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
<g transform="rotate(180)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
<g transform="rotate(240)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
<g transform="rotate(300)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
</g>
<!-- optional brand focal point -->
<!-- <circle cx="128" cy="128" r="9" fill="#e6a23c"/> -->
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+49 -1
View File
@@ -38,6 +38,7 @@ pub struct GetImagesParams {
pub favorites_only: Option<bool>,
pub rating_min: Option<i64>,
pub embedding_failed_only: Option<bool>,
pub tagging_failed_only: Option<bool>,
pub sort: Option<String>,
pub offset: Option<i64>,
pub limit: Option<i64>,
@@ -267,6 +268,20 @@ pub async fn get_folders(db: State<'_, DbState>) -> Result<Vec<Folder>, String>
db::get_folders(&conn).map_err(|e| e.to_string())
}
#[derive(Deserialize)]
pub struct ReorderFoldersParams {
pub folder_ids: Vec<i64>,
}
#[tauri::command]
pub async fn reorder_folders(
db: State<'_, DbState>,
params: ReorderFoldersParams,
) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::reorder_folders(&conn, &params.folder_ids).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_background_job_progress(
db: State<'_, DbState>,
@@ -315,6 +330,7 @@ pub async fn get_images(
let favorites_only = params.favorites_only.unwrap_or(false);
let rating_min = params.rating_min.unwrap_or(0);
let embedding_failed_only = params.embedding_failed_only.unwrap_or(false);
let tagging_failed_only = params.tagging_failed_only.unwrap_or(false);
let total = db::count_images(
&conn,
@@ -324,6 +340,7 @@ pub async fn get_images(
favorites_only,
rating_min,
embedding_failed_only,
tagging_failed_only,
)
.map_err(|e| e.to_string())?;
@@ -335,6 +352,7 @@ pub async fn get_images(
favorites_only,
rating_min,
embedding_failed_only,
tagging_failed_only,
sort,
offset,
limit,
@@ -580,6 +598,34 @@ pub async fn retry_failed_embeddings(
db::retry_failed_embedding_jobs(&conn, params.folder_id).map_err(|e| e.to_string())
}
#[derive(Serialize)]
pub struct FailedImageItem {
pub image_id: i64,
pub filename: String,
pub path: String,
pub error: Option<String>,
}
#[tauri::command]
pub async fn get_failed_tagging_images(
db: State<'_, DbState>,
folder_id: i64,
) -> Result<Vec<FailedImageItem>, String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::get_failed_tagging_images(&conn, folder_id)
.map(|rows| {
rows.into_iter()
.map(|(image_id, filename, path, error)| FailedImageItem {
image_id,
filename,
path,
error,
})
.collect()
})
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn semantic_search_images(
db: State<'_, DbState>,
@@ -1468,6 +1514,7 @@ fn kmeans_cosine(
pub struct FailedEmbeddingItem {
pub image_id: i64,
pub filename: String,
pub path: String,
pub error: Option<String>,
}
@@ -1480,9 +1527,10 @@ pub async fn get_failed_embedding_images(
let rows = db::get_failed_embedding_images(&conn, folder_id).map_err(|e| e.to_string())?;
Ok(rows
.into_iter()
.map(|(image_id, filename, error)| FailedEmbeddingItem {
.map(|(image_id, filename, path, error)| FailedEmbeddingItem {
image_id,
filename,
path,
error,
})
.collect())
+95 -9
View File
@@ -31,6 +31,7 @@ pub struct Folder {
pub image_count: i64,
pub indexed_at: Option<String>,
pub scan_error: Option<String>,
pub sort_order: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -322,6 +323,11 @@ pub fn migrate(conn: &Connection) -> Result<()> {
// on databases that predate Phase 1.
conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_images_taken_at ON images(taken_at);")?;
ensure_column(conn, "folders", "scan_error", "TEXT")?;
ensure_column(conn, "folders", "sort_order", "INTEGER NOT NULL DEFAULT 0")?;
conn.execute(
"UPDATE folders SET sort_order = id WHERE sort_order = 0",
[],
)?;
vector::migrate(conn)?;
Ok(())
@@ -329,7 +335,8 @@ pub fn migrate(conn: &Connection) -> Result<()> {
pub fn insert_folder(conn: &Connection, path: &str, name: &str) -> Result<i64> {
conn.execute(
"INSERT OR IGNORE INTO folders (path, name) VALUES (?1, ?2)",
"INSERT OR IGNORE INTO folders (path, name, sort_order)
VALUES (?1, ?2, COALESCE((SELECT MAX(sort_order) + 1 FROM folders), 1))",
params![path, name],
)?;
let id: i64 = conn.query_row(
@@ -814,6 +821,7 @@ pub fn get_pending_embedding_jobs(
FROM embedding_jobs j
JOIN images i ON i.id = j.image_id
WHERE status = 'pending'
AND NOT (i.media_kind = 'video' AND i.thumbnail_path IS NULL)
{}
ORDER BY j.updated_at, j.image_id
LIMIT ?1",
@@ -1224,7 +1232,12 @@ pub fn has_claimable_embedding_jobs(
conn: &Connection,
excluded_folder_ids: &std::collections::HashSet<i64>,
) -> Result<bool> {
has_claimable_jobs(conn, "embedding_jobs", "", excluded_folder_ids)
has_claimable_jobs(
conn,
"embedding_jobs",
"AND NOT (i.media_kind = 'video' AND i.thumbnail_path IS NULL)",
excluded_folder_ids,
)
}
pub fn claim_thumbnail_jobs(
@@ -1507,7 +1520,9 @@ pub fn get_images_by_ids(conn: &Connection, image_ids: &[i64]) -> Result<Vec<Ima
pub fn get_folders(conn: &Connection) -> Result<Vec<Folder>> {
let mut stmt = conn.prepare(
"SELECT id, path, name, image_count, indexed_at, scan_error FROM folders ORDER BY name",
"SELECT id, path, name, image_count, indexed_at, scan_error, sort_order
FROM folders
ORDER BY sort_order, id",
)?;
let rows = stmt.query_map([], |row| {
Ok(Folder {
@@ -1517,11 +1532,47 @@ pub fn get_folders(conn: &Connection) -> Result<Vec<Folder>> {
image_count: row.get(3)?,
indexed_at: row.get(4)?,
scan_error: row.get(5)?,
sort_order: row.get(6)?,
})
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
pub fn reorder_folders(conn: &Connection, folder_ids: &[i64]) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for (index, folder_id) in folder_ids.iter().enumerate() {
tx.execute(
"UPDATE folders SET sort_order = ?2 WHERE id = ?1",
params![folder_id, index as i64 + 1],
)?;
}
tx.commit()?;
Ok(())
}
pub fn repair_deferred_embedding_jobs(conn: &Connection) -> Result<usize> {
let pattern = "No thumbnail available yet for%";
let repaired = conn.execute(
"UPDATE embedding_jobs
SET status = 'pending', last_error = NULL, updated_at = datetime('now')
WHERE status = 'failed'
AND last_error LIKE ?1
AND image_id IN (
SELECT id FROM images WHERE media_kind = 'video'
)",
[pattern],
)?;
conn.execute(
"UPDATE images
SET embedding_status = 'pending', embedding_error = NULL
WHERE embedding_status = 'failed'
AND embedding_error LIKE ?1
AND media_kind = 'video'",
[pattern],
)?;
Ok(repaired)
}
pub fn rename_folder(conn: &Connection, folder_id: i64, new_name: &str) -> Result<()> {
conn.execute(
"UPDATE folders SET name = ?2 WHERE id = ?1",
@@ -1581,6 +1632,7 @@ pub fn get_images(
favorites_only: bool,
rating_min: i64,
embedding_failed_only: bool,
tagging_failed_only: bool,
sort: &str,
offset: i64,
limit: i64,
@@ -1604,6 +1656,7 @@ pub fn get_images(
let search_pattern = search.map(|value| format!("%{value}%"));
let favorites_flag = i64::from(favorites_only);
let embedding_failed_flag = i64::from(embedding_failed_only);
let tagging_failed_flag = i64::from(tagging_failed_only);
let sql = format!(
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type,
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
@@ -1617,8 +1670,9 @@ pub fn get_images(
AND (?4 = 0 OR favorite = 1)
AND rating >= ?5
AND (?6 = 0 OR embedding_status = 'failed')
AND (?7 = 0 OR ai_tagger_error IS NOT NULL)
ORDER BY {order}
LIMIT ?7 OFFSET ?8"
LIMIT ?8 OFFSET ?9"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(
@@ -1629,6 +1683,7 @@ pub fn get_images(
favorites_flag,
rating_min,
embedding_failed_flag,
tagging_failed_flag,
limit,
offset
],
@@ -1645,11 +1700,13 @@ pub fn count_images(
favorites_only: bool,
rating_min: i64,
embedding_failed_only: bool,
tagging_failed_only: bool,
) -> Result<i64> {
let search_pattern = search.map(|value| format!("%{value}%"));
let favorites_flag = i64::from(favorites_only);
let embedding_failed_flag = i64::from(embedding_failed_only);
let tagging_failed_flag = i64::from(tagging_failed_only);
let count = conn.query_row(
"SELECT COUNT(*) FROM images
WHERE (?1 IS NULL OR folder_id = ?1)
@@ -1657,14 +1714,16 @@ pub fn count_images(
AND (?3 IS NULL OR media_kind = ?3)
AND (?4 = 0 OR favorite = 1)
AND rating >= ?5
AND (?6 = 0 OR embedding_status = 'failed')",
AND (?6 = 0 OR embedding_status = 'failed')
AND (?7 = 0 OR ai_tagger_error IS NOT NULL)",
params![
folder_id,
search_pattern,
media_kind,
favorites_flag,
rating_min,
embedding_failed_flag
embedding_failed_flag,
tagging_failed_flag
],
|row| row.get(0),
)?;
@@ -1876,12 +1935,14 @@ pub fn get_explore_tags(
Ok(rows)
}
type FailedEmbeddingRow = (i64, String, String, Option<String>);
pub fn get_failed_embedding_images(
conn: &Connection,
folder_id: i64,
) -> Result<Vec<(i64, String, Option<String>)>> {
) -> Result<Vec<FailedEmbeddingRow>> {
let mut stmt = conn.prepare(
"SELECT id, filename, embedding_error
"SELECT id, filename, path, embedding_error
FROM images
WHERE folder_id = ?1 AND embedding_status = 'failed'
ORDER BY filename",
@@ -1890,7 +1951,32 @@ pub fn get_failed_embedding_images(
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?,
))
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
type FailedTaggingRow = (i64, String, String, Option<String>);
pub fn get_failed_tagging_images(
conn: &Connection,
folder_id: i64,
) -> Result<Vec<FailedTaggingRow>> {
let mut stmt = conn.prepare(
"SELECT i.id, i.filename, i.path, COALESCE(j.last_error, i.ai_tagger_error)
FROM images i
LEFT JOIN tagging_jobs j ON j.image_id = i.id AND j.status = 'failed'
WHERE i.folder_id = ?1 AND i.ai_tagger_error IS NOT NULL
ORDER BY i.filename",
)?;
let rows = stmt.query_map([folder_id], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?,
))
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
+2 -2
View File
@@ -221,8 +221,8 @@ fn load_images(paths: &[PathBuf], image_size: usize) -> Result<Tensor> {
/// Returns the path that should be fed to the CLIP image embedder for a given media file.
///
/// For videos the thumbnail image is used (because CLIP only understands still images).
/// If a video has no thumbnail yet, an error is returned — the caller should mark the
/// embedding job as failed rather than trying to decode the raw video file.
/// Video jobs without thumbnails are excluded when jobs are claimed. The error remains
/// as a guard against a race where a thumbnail disappears after a job is claimed.
pub fn embedding_source_path(
path: &str,
thumbnail_path: Option<&str>,
+7 -5
View File
@@ -18,7 +18,7 @@ use tauri::{AppHandle, Emitter};
use walkdir::WalkDir;
const IMAGE_EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "avif", "heic", "heif",
"jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "avif",
];
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mov", "m4v", "webm"];
@@ -550,6 +550,9 @@ fn build_record(
let filename = path.file_name()?.to_string_lossy().to_string();
let metadata = std::fs::metadata(path).ok()?;
let file_size = metadata.len() as i64;
if file_size == 0 {
return None;
}
let modified_at = metadata.modified().ok().map(|time| {
let date_time: chrono::DateTime<chrono::Utc> = time.into();
date_time.to_rfc3339()
@@ -869,14 +872,14 @@ fn process_embedding_batch(
let embedder = embedder.as_ref().expect("embedder should be initialized");
let infer_started_at = Instant::now();
// Resolve the source path for each job. Videos without a thumbnail produce an Err
// here — those jobs are marked failed immediately without going to the embedder.
// Resolve each source path. Video jobs without thumbnails are not claimable, so an
// error here represents a real race or missing thumbnail rather than normal deferral.
let source_results: Vec<Result<PathBuf>> = jobs
.iter()
.map(|job| embedding_source_path(&job.path, job.thumbnail_path.as_deref(), &job.media_kind))
.collect();
// Separate jobs with a valid source from those that fail early (e.g. video with no thumbnail).
// Separate jobs with a valid source from genuine early failures.
let mut embeddable_indices: Vec<usize> = Vec::new();
let mut embeddable_paths: Vec<PathBuf> = Vec::new();
// image_id -> early error message for jobs that cannot be embedded yet
@@ -1372,7 +1375,6 @@ fn mime_for_ext(ext: &str) -> &'static str {
"webp" => "image/webp",
"tiff" | "tif" => "image/tiff",
"avif" => "image/avif",
"heic" | "heif" => "image/heif",
"mp4" | "m4v" => "video/mp4",
"mov" => "video/quicktime",
"webm" => "video/webm",
+7
View File
@@ -65,6 +65,11 @@ 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 video embedding jobs.");
}
let backfilled =
db::backfill_embedding_jobs(&conn).expect("Failed to backfill embedding jobs");
if backfilled > 0 {
@@ -124,6 +129,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
commands::add_folder,
commands::get_folders,
commands::reorder_folders,
commands::get_background_job_progress,
commands::remove_folder,
commands::get_images,
@@ -156,6 +162,7 @@ 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,
+109 -44
View File
@@ -1,5 +1,6 @@
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";
const WORKER_FOR_STAGE: Record<string, WorkerKey> = {
@@ -30,23 +31,52 @@ interface Task {
snapshot: string;
}
interface FailedEmbeddingItem {
interface FailedWorkerItem {
image_id: number;
filename: string;
path: string;
error: string | null;
}
function FailedWorkerItemRow({ item }: { item: FailedWorkerItem }) {
return (
<div className="flex min-w-0 items-start gap-1.5">
<svg className="mt-px h-2.5 w-2.5 shrink-0 text-amber-500 light-theme:text-amber-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<div className="min-w-0 flex-1">
<p className="truncate text-[10px] font-medium text-amber-400/80 light-theme:text-amber-700">{item.filename}</p>
{item.error && (
<p className="truncate text-[9px] text-gray-600">{item.error}</p>
)}
</div>
<button
className="shrink-0 text-gray-700 transition-colors hover:text-gray-300 light-theme:text-gray-600 light-theme:hover:text-gray-100"
title="Reveal in Explorer"
onClick={() => void revealItemInDir(item.path)}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
</button>
</div>
);
}
export function BackgroundTasks() {
const folders = useGalleryStore((state) => state.folders);
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs);
const showFailedTagging = useGalleryStore((state) => state.showFailedTagging);
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
const [expanded, setExpanded] = useState(false);
const [dismissed, setDismissed] = useState<Record<number, string>>({});
const [failedItems, setFailedItems] = useState<Record<number, FailedEmbeddingItem[]>>({});
const [failedEmbeddingItems, setFailedEmbeddingItems] = useState<Record<number, FailedWorkerItem[]>>({});
const [failedTaggingItems, setFailedTaggingItems] = useState<Record<number, FailedWorkerItem[]>>({});
const workerPaused = useGalleryStore((state) => state.workerPaused);
const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates);
@@ -56,27 +86,43 @@ export function BackgroundTasks() {
void loadWorkerStates();
}, [folders, loadWorkerStates]);
// Fetch failed embedding filenames whenever the expanded panel opens or failure counts change.
const failedCounts = useMemo(
// Fetch failed filenames whenever the expanded panel opens or failure counts change.
const failedEmbeddingCounts = useMemo(
() =>
Object.fromEntries(
Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.embedding_failed ?? 0]),
),
[mediaJobProgress],
);
const failedTaggingCounts = useMemo(
() =>
Object.fromEntries(
Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.tagging_failed ?? 0]),
),
[mediaJobProgress],
);
useEffect(() => {
if (!expanded) return;
for (const [folderId, count] of Object.entries(failedCounts)) {
for (const [folderId, count] of Object.entries(failedEmbeddingCounts)) {
if (count > 0) {
invoke<FailedEmbeddingItem[]>("get_failed_embedding_images", {
invoke<FailedWorkerItem[]>("get_failed_embedding_images", {
folderId: Number(folderId),
})
.then((items) => setFailedItems((prev) => ({ ...prev, [folderId]: items })))
.then((items) => setFailedEmbeddingItems((prev) => ({ ...prev, [folderId]: items })))
.catch(() => undefined);
}
}
}, [expanded, failedCounts]);
for (const [folderId, count] of Object.entries(failedTaggingCounts)) {
if (count > 0) {
invoke<FailedWorkerItem[]>("get_failed_tagging_images", {
folderId: Number(folderId),
})
.then((items) => setFailedTaggingItems((prev) => ({ ...prev, [folderId]: items })))
.catch(() => undefined);
}
}
}, [expanded, failedEmbeddingCounts, failedTaggingCounts]);
const isWorkerPaused = (folderId: number, worker: WorkerKey) => {
return workerPaused[folderId]?.[worker] ?? false;
@@ -302,14 +348,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"
? "bg-amber-500/10 text-amber-400 light-theme:border light-theme:border-amber-500/40 light-theme:bg-amber-100 light-theme:text-amber-700"
: isPaused
? "bg-white/4 text-gray-600"
: "bg-white/5 text-gray-400"
}`}
>
<span>{stage.label}</span>
<span className={`tabular-nums ${stage.failed ? "text-amber-500" : isPaused ? "text-gray-700" : "text-gray-600"}`}>
<span className={`tabular-nums ${stage.failed ? "text-amber-500 light-theme:text-amber-700" : isPaused ? "text-gray-700" : "text-gray-600"}`}>
{stage.detail}
</span>
{workerKey && (
@@ -355,14 +401,27 @@ export function BackgroundTasks() {
</span>
)}
{/* 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 hover:bg-amber-500/20 transition-colors shrink-0"
onClick={(e) => { e.stopPropagation(); void retryFailedEmbeddings(primary.id); }}
>
Retry
</button>
{primary.pendingMediaWork === 0 && (primary.hasFailedEmbeddings || primary.hasFailedTagging) && (
<div className="flex shrink-0 items-center gap-1.5">
{primary.hasFailedTagging ? (
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
onClick={(e) => { e.stopPropagation(); showFailedTagging(primary.id); }}
>
Locate
</button>
) : null}
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
onClick={(e) => {
e.stopPropagation();
if (primary.hasFailedEmbeddings) void retryFailedEmbeddings(primary.id);
if (primary.hasFailedTagging) void queueTaggingJobs(primary.id);
}}
>
Retry
</button>
</div>
)}
{/* Expand chevron (only when multiple tasks) */}
@@ -414,7 +473,7 @@ export function BackgroundTasks() {
key={stage.label}
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${
stage.failed
? "bg-amber-500/10 text-amber-400"
? "bg-amber-500/10 text-amber-400 light-theme:border light-theme:border-amber-500/40 light-theme:bg-amber-100 light-theme:text-amber-700"
: isPaused
? "bg-white/4 text-gray-600"
: "bg-white/5 text-gray-500"
@@ -426,7 +485,7 @@ export function BackgroundTasks() {
</svg>
)}
<span>{stage.label}</span>
<span className={`tabular-nums ${stage.failed ? "text-amber-500" : "text-gray-600"}`}>
<span className={`tabular-nums ${stage.failed ? "text-amber-500 light-theme:text-amber-700" : "text-gray-600"}`}>
{stage.detail}
</span>
{workerKey && (
@@ -465,15 +524,25 @@ export function BackgroundTasks() {
</div>
{taskHasFailed && (
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300 hover:bg-amber-500/20 transition-colors shrink-0"
onClick={() => {
if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id);
if (task.hasFailedTagging) void queueTaggingJobs(task.id);
}}
>
Retry
</button>
<div className="flex shrink-0 items-center gap-1.5">
{task.hasFailedTagging ? (
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
onClick={() => showFailedTagging(task.id)}
>
Locate
</button>
) : null}
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
onClick={() => {
if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id);
if (task.hasFailedTagging) void queueTaggingJobs(task.id);
}}
>
Retry
</button>
</div>
)}
{task.id >= 0 && (
@@ -495,22 +564,18 @@ export function BackgroundTasks() {
</p>
)}
{/* Failed embedding file list */}
{taskHasFailed && failedItems[task.id] && failedItems[task.id].length > 0 && (
{/* Failed worker file lists */}
{taskHasFailed && failedEmbeddingItems[task.id] && failedEmbeddingItems[task.id].length > 0 && (
<div className="mt-2 pl-[calc(7rem+0.75rem)] space-y-0.5">
{failedItems[task.id].map((item) => (
<div key={item.image_id} className="flex items-start gap-1.5 min-w-0">
<svg className="h-2.5 w-2.5 text-amber-500 shrink-0 mt-px" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<div className="min-w-0">
<p className="text-[10px] text-amber-400/80 truncate font-medium">{item.filename}</p>
{item.error && (
<p className="text-[9px] text-gray-600 truncate">{item.error}</p>
)}
</div>
</div>
{failedEmbeddingItems[task.id].map((item) => (
<FailedWorkerItemRow key={item.image_id} item={item} />
))}
</div>
)}
{taskHasFailed && failedTaggingItems[task.id] && failedTaggingItems[task.id].length > 0 && (
<div className="mt-2 pl-[calc(7rem+0.75rem)] space-y-0.5">
{failedTaggingItems[task.id].map((item) => (
<FailedWorkerItemRow key={item.image_id} item={item} />
))}
</div>
)}
+5 -5
View File
@@ -71,7 +71,7 @@ function DuplicateGroupCard({ group }: { group: DuplicateGroup }) {
return (
<button
key={image.id}
className={`group relative overflow-hidden rounded-xl border transition-all ${
className={`media-dark-surface group relative overflow-hidden rounded-xl border transition-all ${
isSelected
? "border-red-400/50 ring-1 ring-red-400/30"
: "border-white/8 hover:border-white/20"
@@ -165,7 +165,7 @@ export function DuplicateFinder() {
: null;
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[#07080f]">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-gray-950">
{/* Header */}
<div className="shrink-0 border-b border-white/[0.05] px-6 py-4">
<div className="flex items-center justify-between gap-4">
@@ -193,7 +193,7 @@ export function DuplicateFinder() {
{/* 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"
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={selectKeepFirstAllGroups}
title={`Mark ${totalDuplicateImages} duplicate${totalDuplicateImages === 1 ? "" : "s"} for deletion across all groups (keeps first in each)`}
>
@@ -204,7 +204,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"
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={clearDuplicateSelection}
disabled={deleting}
>
@@ -220,7 +220,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"
className="rounded-lg border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => { setDeleteResult(null); void scanDuplicates(selectedFolderId); }}
disabled={duplicateScanning}
>
+9 -9
View File
@@ -33,13 +33,13 @@ export function FolderScopeDropdown() {
};
return (
<div ref={ref} className="relative">
<div ref={ref} className="feature-scope-dropdown relative">
<button
onClick={() => setOpen((v) => !v)}
className={`flex max-w-56 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${
className={`feature-scope-trigger flex max-w-56 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${
open
? "border-white/15 bg-white/8 text-white"
: "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200"
? "border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white"
: "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:border-gray-700/40 light-theme:text-gray-600 light-theme:hover:border-gray-700 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
title="Change folder scope"
>
@@ -55,10 +55,10 @@ export function FolderScopeDropdown() {
</svg>
</button>
{open ? (
<div className="absolute right-0 top-full z-30 mt-1.5 max-h-80 min-w-52 overflow-y-auto rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur">
<div className="feature-scope-menu absolute right-0 top-full z-30 mt-1.5 max-h-80 min-w-52 overflow-y-auto rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur light-theme:border-gray-700/50">
<button
className={`flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
selectedFolderId === null ? "bg-white/6 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white"
className={`feature-scope-option flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
selectedFolderId === null ? "bg-white/6 text-white light-theme:bg-gray-900 light-theme:text-white" : "text-gray-400 hover:bg-white/5 hover:text-white light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => select(null)}
>
@@ -74,8 +74,8 @@ export function FolderScopeDropdown() {
return (
<button
key={folder.id}
className={`flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
active ? "bg-white/6 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white"
className={`feature-scope-option flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
active ? "bg-white/6 text-white light-theme:bg-gray-900 light-theme:text-white" : "text-gray-400 hover:bg-white/5 hover:text-white light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => select(folder.id)}
>
+85 -39
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { useEffect, useLayoutEffect, useRef, useCallback, useMemo, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { convertFileSrc } from "@tauri-apps/api/core";
import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store";
@@ -119,15 +120,11 @@ export function ImageTile({
const similarScope = useGalleryStore((state) => state.similarScope);
const canFindSimilar = image.embedding_status === "ready";
const src = image.thumbnail_path
? convertFileSrc(image.thumbnail_path)
: image.media_kind === "image" && image.path
? convertFileSrc(image.path)
: null;
const src = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) : null;
return (
<button
className="group relative overflow-hidden rounded-xl bg-white/[0.04] text-left focus:outline-none"
className="media-dark-surface 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}
@@ -268,29 +265,62 @@ export function Gallery() {
const parsedSearch = parseSearchValue(search);
const parentRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null);
useLayoutEffect(() => {
const el = parentRef.current;
if (!el) return;
setContainerWidth(el.clientWidth);
const ro = new ResizeObserver((entries) => {
setContainerWidth(entries[0].contentRect.width);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const tileSize = tileSizeForZoom(zoomPreset);
const cols = useMemo(
() => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))),
[containerWidth, tileSize],
);
const rowCount = Math.ceil(images.length / cols);
const estimateSize = useCallback(() => tileSize + GAP, [tileSize]);
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => parentRef.current,
estimateSize,
overscan: 3,
paddingStart: GAP,
});
useEffect(() => {
virtualizer.measure();
}, [cols, virtualizer]);
useEffect(() => {
parentRef.current?.scrollTo({ top: 0, left: 0 });
}, [galleryScrollResetKey]);
const handleScroll = useCallback(() => {
const element = parentRef.current;
if (!element) return;
if (element.scrollTop < 24) return;
const nearBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 600;
const el = parentRef.current;
if (!el) return;
if (el.scrollTop < 24) return;
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600;
if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages();
}
}, [images.length, loadMoreImages, loadingImages, totalImages]);
useEffect(() => {
const element = parentRef.current;
if (!element) return;
element.addEventListener("scroll", handleScroll, { passive: true });
return () => element.removeEventListener("scroll", handleScroll);
const el = parentRef.current;
if (!el) return;
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
useEffect(() => {
parentRef.current?.scrollTo({ top: 0, left: 0 });
}, [galleryScrollResetKey]);
useEffect(() => {
const close = (event: PointerEvent) => {
if ((event.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return;
@@ -308,7 +338,7 @@ export function Gallery() {
}, []);
return (
<div ref={parentRef} className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#07080f]">
<div ref={parentRef} className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-gray-950">
{images.length === 0 && loadingImages ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 min-w-72">
@@ -365,25 +395,41 @@ export function Gallery() {
</div>
</div>
) : (
<div
className="grid content-start"
style={{
padding: GAP,
gap: GAP,
gridTemplateColumns: `repeat(auto-fill, minmax(${tileSizeForZoom(zoomPreset)}px, 1fr))`,
}}
>
{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 style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const startIndex = virtualRow.index * cols;
const rowImages = images.slice(startIndex, startIndex + cols);
return (
<div
key={virtualRow.key}
style={{
position: "absolute",
top: virtualRow.start,
width: "100%",
height: virtualRow.size,
display: "grid",
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
gap: GAP,
paddingLeft: GAP,
paddingRight: GAP,
paddingBottom: GAP,
boxSizing: "border-box",
}}
>
{rowImages.map((image) => (
<ImageTile
key={image.id}
image={image}
onClick={() => openImage(image)}
onContextMenu={(event) => {
event.preventDefault();
setContextMenu({ x: event.clientX, y: event.clientY, image });
}}
/>
))}
</div>
);
})}
</div>
)}
+15 -3
View File
@@ -1,6 +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";
@@ -377,7 +378,7 @@ export function Lightbox() {
{selectedImage ? (
<motion.div
key="lightbox"
className="fixed inset-0 z-50 flex bg-black/90 backdrop-blur-sm"
className="media-dark-surface fixed inset-0 z-50 flex bg-black/90 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
@@ -495,7 +496,7 @@ export function Lightbox() {
</AnimatePresence>
</div>
<div className="flex w-72 shrink-0 flex-col border-l border-white/5 bg-gray-900/95">
<div className="lightbox-panel flex w-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>
@@ -780,7 +781,18 @@ export function Lightbox() {
</div>
<div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Path</p>
<div className="mb-1 flex items-center justify-between">
<p className="text-xs uppercase tracking-wider text-gray-500">Path</p>
<button
className="rounded p-0.5 text-gray-600 transition-colors hover:text-gray-300"
title="Reveal in Explorer"
onClick={() => void revealItemInDir(selectedImage.path)}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
</button>
</div>
<p className="break-all text-xs text-gray-400">{selectedImage.path}</p>
</div>
</div>
+41 -23
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store";
import { AppTheme, CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store";
import { FfmpegStatusRow } from "./onboarding/StepWelcome";
import { ThemedDropdown } from "./ThemedDropdown";
type SettingsSection = "workspace" | "general";
@@ -18,9 +19,9 @@ function formatBytesShort(bytes: number): string {
function StatusPill({ children, tone }: { children: React.ReactNode; tone: "ready" | "muted" | "busy" }) {
const className =
tone === "ready"
? "border-emerald-400/25 bg-emerald-500/10 text-emerald-300"
? "border-emerald-400/25 bg-emerald-500/10 text-emerald-300 light-theme:border-emerald-600/40 light-theme:bg-emerald-100 light-theme:text-emerald-700"
: tone === "busy"
? "border-sky-400/25 bg-sky-500/10 text-sky-300"
? "border-sky-400/25 bg-sky-500/10 text-sky-300 light-theme:border-sky-600/40 light-theme:bg-sky-100 light-theme:text-sky-700"
: "border-white/10 bg-white/[0.04] text-gray-500";
return <span className={`inline-flex rounded-md border px-2 py-0.5 text-[11px] font-medium ${className}`}>{children}</span>;
@@ -88,8 +89,8 @@ function ScopeButton({ scope, current, onSelect, children }: {
type="button"
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200"
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200"
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700"
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => onSelect(scope)}
>
@@ -110,8 +111,8 @@ function TaggerAccelerationButton({ acceleration, current, onSelect, children }:
type="button"
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200"
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200"
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700"
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => onSelect(acceleration)}
>
@@ -192,6 +193,8 @@ export function SettingsModal() {
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);
useEffect(() => {
if (!settingsOpen) return;
@@ -309,7 +312,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-[#07080f] shadow-2xl shadow-black/60"
className="relative flex h-[min(85vh,900px)] w-[min(85vw,1400px)] overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60"
onClick={(event) => event.stopPropagation()}
>
<aside className="flex w-64 shrink-0 flex-col border-r border-white/[0.07] bg-white/[0.025]">
@@ -368,14 +371,14 @@ export function SettingsModal() {
{taggerReady ? (
<>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => void probeTaggerRuntime()}
disabled={taggerRuntimeChecking}
>
{taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"}
</button>
<button
className="rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45"
className="rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-red-400/50 light-theme:bg-red-50 light-theme:text-red-700 light-theme:hover:bg-red-100"
onClick={() => void deleteTaggerModel()}
disabled={taggerModelPreparing}
>
@@ -384,7 +387,7 @@ export function SettingsModal() {
</>
) : (
<button
className="relative overflow-hidden rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="relative overflow-hidden rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => void prepareTaggerModel()}
disabled={taggerModelPreparing}
>
@@ -397,7 +400,7 @@ export function SettingsModal() {
<SettingsItem label="Tagger acceleration" description="Use DirectML when available, or fall back to CPU for reliability.">
<div className="flex flex-col items-end gap-1.5">
<div className="flex rounded-lg border border-white/[0.07] p-0.5">
<div className="flex rounded-lg border border-white/[0.07] p-0.5 light-theme:border-gray-300/80">
{(["auto", "directml", "cpu"] as const).map((acceleration) => (
<TaggerAccelerationButton
key={acceleration}
@@ -519,7 +522,7 @@ export function SettingsModal() {
<SettingsGroup title="Queue targets" description="Choose which folders to include when queuing tagging jobs.">
<SettingsItem label="Target scope" description="Queue across the full library, or choose a specific folder set and keep the modal open while you work through it.">
<div className="flex rounded-lg border border-white/[0.07] p-0.5">
<div className="flex rounded-lg border border-white/[0.07] p-0.5 light-theme:border-gray-300/80">
<ScopeButton scope="all" current={taggingQueueScope} onSelect={setTaggingQueueScope}>All media</ScopeButton>
<ScopeButton scope="selected" current={taggingQueueScope} onSelect={setTaggingQueueScope}>Selected folders</ScopeButton>
</div>
@@ -533,14 +536,14 @@ export function SettingsModal() {
</div>
<div className="flex gap-2">
<button
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40 disabled:cursor-not-allowed"
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40 disabled:cursor-not-allowed light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => setTaggingQueueFolderIds(folders.map((folder) => folder.id))}
disabled={taggingQueueScope === "all" || folders.length === 0}
>
Select all
</button>
<button
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40 disabled:cursor-not-allowed"
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40 disabled:cursor-not-allowed light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => setTaggingQueueFolderIds([])}
disabled={taggingQueueScope === "all" || taggingQueueFolderIds.length === 0}
>
@@ -579,14 +582,14 @@ export function SettingsModal() {
<SettingsItem label="Queue tagging jobs" description="Generate missing AI tags for the current target. Results flow back into the library as the background worker finishes.">
<div className="flex items-center gap-2">
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => runQueueAction("queue")}
disabled={!taggerReady || taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
>
{taggerQueueing ? "Queueing..." : "Queue tagging"}
</button>
<button
className="rounded-md border border-amber-400/20 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 transition-colors hover:bg-amber-500/15 disabled:cursor-not-allowed disabled:opacity-45"
className="rounded-md border border-amber-400/20 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 transition-colors hover:bg-amber-500/15 disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-amber-500/50 light-theme:bg-amber-50 light-theme:text-amber-700 light-theme:hover:bg-amber-100"
onClick={() => runQueueAction("clear")}
disabled={taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
>
@@ -600,6 +603,21 @@ export function SettingsModal() {
</div>
) : (
<div className="mt-8 space-y-9">
<SettingsGroup title="Appearance">
<SettingsItem label="Theme" description="Choose the app palette. Subtle Light uses a warm, low-glare background.">
<ThemedDropdown
value={theme}
onChange={(value) => setTheme(value as AppTheme)}
ariaLabel="App theme"
options={[
{ value: "phokus", label: "Phokus" },
{ value: "subtle-light", label: "Subtle Light" },
{ value: "conventional-dark", label: "Conventional Dark" },
]}
/>
</SettingsItem>
</SettingsGroup>
<SettingsGroup title="Updates">
<SettingsItem
label={
@@ -624,14 +642,14 @@ export function SettingsModal() {
>
{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"
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"
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"}
>
@@ -648,7 +666,7 @@ export function SettingsModal() {
description="Replay the guided first-run tour — library setup, the background pipeline, search modes, and AI features."
>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => {
setSettingsOpen(false);
openOnboarding();
@@ -665,7 +683,7 @@ export function SettingsModal() {
description="Open the folder in Explorer to inspect or back up the database, thumbnails, and models."
>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => {
setOpeningDataFolder(true);
void openAppDataFolder().finally(() => setOpeningDataFolder(false));
@@ -730,7 +748,7 @@ export function SettingsModal() {
}
>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => {
setVacuuming(true);
setVacuumResult(null);
@@ -791,7 +809,7 @@ export function SettingsModal() {
}
>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => {
setCleaningThumbnails(true);
cleanupOrphanedThumbnails()
+203 -8
View File
@@ -1,6 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Reorder, useDragControls } from "framer-motion";
import { open } from "@tauri-apps/plugin-dialog";
import { useGalleryStore, Folder, IndexProgress } from "../store";
import { ThemedDropdown } from "./ThemedDropdown";
interface ContextMenuState {
folderId: number;
@@ -8,6 +10,9 @@ interface ContextMenuState {
y: number;
}
type LibrarySort = "az" | "za" | "custom";
const LIBRARY_SORT_KEY = "phokus-library-sort";
function FolderContextMenu({
menu,
folder,
@@ -82,11 +87,22 @@ function FolderItem({
folder,
selected,
progress,
customOrdering,
dragging,
onDragStart,
onDragEnd,
onKeyboardMove,
}: {
folder: Folder;
selected: boolean;
progress: IndexProgress | undefined;
customOrdering: boolean;
dragging: boolean;
onDragStart: (pointerY: number) => void;
onDragEnd: () => void;
onKeyboardMove: (direction: -1 | 1) => void;
}) {
const dragControls = useDragControls();
const { selectFolder, removeFolder, reindexFolder, renameFolder, updateFolderPath, toggleMutedFolder } = useGalleryStore();
const mutedFolderIds = useGalleryStore((state) => state.mutedFolderIds);
const setAllWorkersPaused = useGalleryStore((state) => state.setAllWorkersPaused);
@@ -141,16 +157,57 @@ function FolderItem({
};
return (
<>
<Reorder.Item
as="div"
value={folder.id}
drag={customOrdering ? "y" : false}
dragControls={dragControls}
dragListener={false}
dragElastic={0.08}
onDragEnd={onDragEnd}
layout
transition={{ layout: { type: "spring", stiffness: 520, damping: 38, mass: 0.55 } }}
className={`relative z-0 ${dragging ? "z-20" : ""}`}
style={{ position: "relative" }}
>
<div
className={`group relative flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
selected
? "bg-white/8 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
} ${dragging ? "scale-[1.02] bg-white/[0.11] text-white shadow-xl shadow-black/25 ring-1 ring-white/15" : ""}`}
onClick={() => !renaming && selectFolder(folder.id)}
onContextMenu={handleContextMenu}
>
{customOrdering ? (
<button
type="button"
aria-label={`Reorder ${folder.name}`}
title={`Drag to reorder ${folder.name}`}
className={`-ml-1 flex h-7 w-5 shrink-0 touch-none items-center justify-center rounded-md transition-colors ${
dragging
? "cursor-grabbing bg-white/10 text-gray-300"
: "cursor-grab text-gray-700 hover:bg-white/[0.06] hover:text-gray-400"
}`}
onPointerDown={(event) => {
event.stopPropagation();
onDragStart(event.clientY);
dragControls.start(event);
}}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
event.preventDefault();
event.stopPropagation();
onKeyboardMove(event.key === "ArrowUp" ? -1 : 1);
}}
>
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="currentColor">
<circle cx="3" cy="3" r="1" /><circle cx="9" cy="3" r="1" />
<circle cx="3" cy="9" r="1" /><circle cx="9" cy="9" r="1" />
</svg>
</button>
) : null}
{isMissing ? (
<span className="shrink-0 text-amber-400">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -278,7 +335,7 @@ function FolderItem({
onRemove={() => setConfirmingRemoval(true)}
/>
)}
</>
</Reorder.Item>
);
}
@@ -290,6 +347,117 @@ export function Sidebar() {
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;
}
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 = async () => {
const selected = await open({ directory: true, multiple: false, title: "Select Media Folder" });
@@ -387,28 +555,55 @@ export function Sidebar() {
{/* Section label */}
{folders.length > 0 && (
<div className="px-5 pt-3 pb-1">
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-1">
<span className="text-[10px] font-semibold uppercase tracking-[0.15em] text-gray-600">Libraries</span>
<ThemedDropdown
value={librarySort}
onChange={(value) => setLibrarySort(value as LibrarySort)}
ariaLabel="Library order"
compact
options={[
{ value: "az", label: "A-Z" },
{ value: "za", label: "Z-A" },
{ value: "custom", label: "Custom" },
]}
/>
</div>
)}
{/* Folder list */}
<div className="flex-1 overflow-y-auto px-2 pb-2 space-y-px min-h-0">
<Reorder.Group
ref={folderListRef}
as="div"
axis="y"
values={displayedFolders.map((folder) => folder.id)}
onReorder={librarySort === "custom" ? handleReorder : () => {}}
layoutScroll
className="flex-1 overflow-y-auto px-2 pb-2 space-y-px min-h-0"
>
{folders.length === 0 ? (
<p className="text-gray-700 text-xs px-3 py-6 text-center leading-relaxed">
Add a folder to get started
</p>
) : (
folders.map((folder) => (
displayedFolders.map((folder) => (
<FolderItem
key={folder.id}
folder={folder}
selected={selectedFolderId === folder.id}
progress={indexingProgress[folder.id]}
customOrdering={librarySort === "custom"}
dragging={draggedFolderId === folder.id}
onDragStart={(pointerY) => {
pointerYRef.current = pointerY;
setDraggedFolderId(folder.id);
}}
onDragEnd={finishReorder}
onKeyboardMove={(direction) => moveFolderByKeyboard(folder.id, direction)}
/>
))
)}
</div>
</Reorder.Group>
</aside>
);
}
+58 -35
View File
@@ -1,5 +1,5 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion } from "framer-motion";
import { motion, useReducedMotion } from "framer-motion";
import { convertFileSrc } from "@tauri-apps/api/core";
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
@@ -110,29 +110,44 @@ function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: nu
}));
}
function CloudCard({ node, onOpen }: { node: PlacedNode; onOpen: (imageIds: number[]) => void }) {
function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imageIds: number[]) => void; animated: boolean }) {
const src = node.entry.thumbnail_path ? convertFileSrc(node.entry.thumbnail_path) : null;
const { 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="group absolute overflow-hidden rounded-2xl border border-white/8 bg-white/[0.04] text-left shadow-[0_8px_40px_rgba(0,0,0,0.5)] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
className="explore-cluster-card group absolute overflow-hidden rounded-2xl border border-white/8 bg-white/[0.04] text-left shadow-[0_8px_28px_rgba(0,0,0,0.38)] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
style={{ width: w, height: h, left: node.x - w / 2, top: node.y - h / 2 }}
initial={{ opacity: 0, scale: 0.75 }}
animate={{
opacity: 1,
scale: 1,
x: [0, node.driftX, 0],
y: [0, node.driftY, 0],
rotate: [node.rotateSeed, node.rotateSeed + 1.4, node.rotateSeed],
}}
transition={{
opacity: { duration: 0.3, delay: Math.min(node.index * 0.035, 0.7) },
scale: { duration: 0.3, delay: Math.min(node.index * 0.035, 0.7) },
x: { duration: node.driftDuration, repeat: Infinity, ease: "easeInOut", delay: seeded(node.index + 41) * 3 },
y: { duration: node.driftDuration + 1.6, repeat: Infinity, ease: "easeInOut", delay: seeded(node.index + 51) * 3 },
rotate: { duration: node.driftDuration + 0.9, repeat: Infinity, ease: "easeInOut" },
}}
initial={animated ? { opacity: 0, scale: 0.82, rotate: node.rotateSeed } : { opacity: 0, scale: 0.96 }}
animate={
animated
? {
opacity: 1,
scale: 1,
x: [0, node.driftX * 0.65, 0],
y: [0, node.driftY * 0.65, 0],
rotate: [node.rotateSeed, node.rotateSeed + 0.8, node.rotateSeed],
}
: { opacity: 1, scale: 1, rotate: node.rotateSeed }
}
transition={
animated
? {
opacity: { duration: 0.24, delay: Math.min(node.index * 0.024, 0.45) },
scale: { duration: 0.24, delay: Math.min(node.index * 0.024, 0.45) },
x: driftTransition,
y: { ...driftTransition, duration: node.driftDuration + 1.2, delay: seeded(node.index + 51) * 1.6 },
rotate: { ...driftTransition, duration: node.driftDuration + 0.8, delay: seeded(node.index + 61) * 1.2 },
}
: { opacity: { duration: 0.18, delay: Math.min(node.index * 0.016, 0.28) }, scale: { duration: 0.18, delay: Math.min(node.index * 0.016, 0.28) } }
}
whileHover={{ scale: 1.06, rotate: 0, transition: { duration: 0.18 } }}
onClick={() => onOpen(node.entry.image_ids)}
title={`Open cluster — ${node.entry.count.toLocaleString()} images`}
@@ -143,22 +158,24 @@ function CloudCard({ node, onOpen }: { node: PlacedNode; onOpen: (imageIds: numb
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="absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" />
<div className="explore-cluster-overlay absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" />
{/* Accent glow on hover */}
<div
className="absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
style={{ background: `radial-gradient(ellipse at bottom, ${accent}25, transparent 70%)` }}
/>
<div className="absolute inset-x-0 bottom-0 p-3">
<div className="mb-2 h-px rounded-full" style={{ background: `linear-gradient(to right, ${accent}80, transparent)` }} />
<div className="explore-cluster-rule mb-2 h-px rounded-full" style={{ background: `linear-gradient(to right, ${accent}80, transparent)` }} />
<div className="flex items-end justify-between gap-2">
<div>
<p className="text-[9px] uppercase tracking-[0.18em] text-white/35">Cluster</p>
<p className="text-base font-semibold leading-none text-white">{node.entry.count.toLocaleString()}</p>
<p className="explore-cluster-label text-[9px] uppercase tracking-[0.18em] text-white/35">Cluster</p>
<p className="explore-cluster-count text-base font-semibold leading-none text-white">{node.entry.count.toLocaleString()}</p>
</div>
<span
className="rounded-full border px-2 py-0.5 text-[9px] uppercase tracking-[0.1em] opacity-0 transition-opacity duration-200 group-hover:opacity-100"
@@ -197,7 +214,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="group inline-flex items-center gap-1 rounded-full px-2 py-1 transition-colors hover:bg-white/[0.07]"
className="explore-tag-word group inline-flex items-center gap-1 rounded-full px-2 py-1 transition-colors hover:bg-white/[0.07]"
style={{ fontSize, rotate: tilt }}
onClick={() => onSearch(entry.tag)}
title={`${entry.tag}${entry.count.toLocaleString()} images`}
@@ -221,7 +238,7 @@ function TagWord({
function Spinner() {
return (
<motion.div
className="h-5 w-5 rounded-full border-2 border-white/15 border-t-white/50"
className="explore-spinner h-5 w-5 rounded-full border-2 border-white/15 border-t-white/50"
animate={{ rotate: 360 }}
transition={{ duration: 0.85, repeat: Infinity, ease: "linear" }}
/>
@@ -238,6 +255,7 @@ function ClusterCloud({
entries: TagCloudEntry[];
onOpen: (imageIds: number[]) => void;
}) {
const reducedMotion = useReducedMotion();
const canvasRef = useRef<HTMLDivElement>(null);
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
@@ -261,9 +279,14 @@ function ClusterCloud({
return (
<div ref={canvasRef} className="relative min-h-0 flex-1 overflow-hidden">
<div className="pointer-events-none absolute inset-0 opacity-[0.07] [background-image:radial-gradient(circle,rgba(255,255,255,0.6)_1px,transparent_1px)] [background-size:28px_28px]" />
<div className="explore-cluster-grid pointer-events-none absolute inset-0 opacity-[0.07] [background-image:radial-gradient(circle,rgba(255,255,255,0.6)_1px,transparent_1px)] [background-size:28px_28px]" />
{nodes.map((node) => (
<CloudCard key={`${node.entry.representative_image_id}:${node.index}`} node={node} onOpen={onOpen} />
<CloudCard
key={`${node.entry.representative_image_id}:${node.index}`}
node={node}
onOpen={onOpen}
animated={!reducedMotion && node.index < 12}
/>
))}
</div>
);
@@ -300,13 +323,13 @@ export function TagCloud() {
const entryCount = exploreMode === "visual" ? tagCloudEntries.length : exploreTagEntries.length;
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.08),transparent_50%),radial-gradient(ellipse_at_80%_75%,rgba(168,85,247,0.07),transparent_40%),#07080f]">
<div className="explore-view flex min-h-0 flex-1 flex-col overflow-hidden bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.08),transparent_50%),radial-gradient(ellipse_at_80%_75%,rgba(168,85,247,0.07),transparent_40%),#07080f]">
{/* Header */}
<div className="shrink-0 border-b border-white/[0.05] px-6 py-4">
<div className="explore-header shrink-0 border-b border-white/[0.05] px-6 py-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<h2 className="text-[15px] font-semibold text-white">Explore</h2>
<p className="mt-0.5 truncate text-[11px] text-white/30">
<h2 className="explore-title text-[15px] font-semibold text-white">Explore</h2>
<p className="explore-subtitle mt-0.5 truncate text-[11px] text-white/30">
{loading
? exploreMode === "visual" ? "Computing visual clusters…" : "Loading tags…"
: hasEntries
@@ -320,9 +343,9 @@ export function TagCloud() {
</div>
<div className="flex shrink-0 items-center gap-2">
<FolderScopeDropdown />
<div className="flex rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
<div className="explore-mode-toggle flex rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
<button
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
className={`explore-mode-button rounded-md px-3 py-1.5 text-xs transition-colors ${
exploreMode === "visual" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
}`}
onClick={() => setExploreMode("visual")}
@@ -330,7 +353,7 @@ export function TagCloud() {
Clusters
</button>
<button
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
className={`explore-mode-button rounded-md px-3 py-1.5 text-xs transition-colors ${
exploreMode === "tags" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
}`}
onClick={() => setExploreMode("tags")}
@@ -343,13 +366,13 @@ export function TagCloud() {
</div>
{loading ? (
<div className="flex flex-1 items-center justify-center gap-3 text-white/25">
<div className="explore-empty flex flex-1 items-center justify-center gap-3 text-white/25">
<Spinner />
<span className="text-sm">{exploreMode === "visual" ? "Computing clusters…" : "Loading tags…"}</span>
</div>
) : !hasEntries ? (
<div className="flex flex-1 items-center justify-center px-8">
<p className="max-w-xs text-center text-sm leading-relaxed text-white/25">
<p className="explore-empty max-w-xs text-center text-sm leading-relaxed text-white/25">
{exploreMode === "visual"
? "No visual clusters yet. Images need embeddings before they can be grouped. Check indexing progress in the sidebar."
: "No tags yet. Run the AI tagger from Settings, or add tags manually in the image preview."}
+106
View File
@@ -0,0 +1,106 @@
import { useEffect, useRef, useState } from "react";
export interface DropdownOption {
value: string;
label: string;
}
export function ThemedDropdown({
value,
options,
onChange,
ariaLabel,
compact = false,
align = "right",
}: {
value: string;
options: DropdownOption[];
onChange: (value: string) => void;
ariaLabel: string;
compact?: boolean;
align?: "left" | "right";
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const current = options.find((option) => option.value === value) ?? options[0];
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
if (!ref.current?.contains(event.target as Node)) setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
};
}, []);
return (
<div ref={ref} className="relative">
<button
type="button"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((currentOpen) => !currentOpen)}
className={`flex items-center justify-between gap-2 rounded-md border transition-colors ${
compact
? "border-white/10 bg-white/[0.04] px-1.5 py-0.5 text-[10px] font-medium light-theme:border-gray-700/50 light-theme:bg-gray-900"
: "min-w-40 border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs light-theme:border-gray-700/50 light-theme:bg-gray-900"
} ${open ? "border-white/20 text-white light-theme:border-gray-700 light-theme:text-white" : "text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:text-white light-theme:hover:border-gray-700 light-theme:hover:bg-gray-800 light-theme:hover:text-white"}`}
>
<span>{current?.label}</span>
<svg
className={`h-3 w-3 shrink-0 text-gray-500 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open ? (
<div
role="listbox"
aria-label={ariaLabel}
className={`absolute top-full z-50 mt-1.5 min-w-full rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl shadow-black/30 backdrop-blur-xl light-theme:border-gray-700/50 ${
align === "right" ? "right-0" : "left-0"
}`}
>
{options.map((option) => {
const selected = option.value === value;
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={selected}
className={`flex w-full items-center justify-between gap-4 whitespace-nowrap rounded-lg px-3 py-2 text-left text-xs transition-colors ${
selected
? "bg-white/[0.08] text-white light-theme:bg-gray-900 light-theme:text-white"
: "text-gray-400 hover:bg-white/[0.055] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => {
onChange(option.value);
setOpen(false);
}}
>
<span>{option.label}</span>
{selected ? (
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : null}
</button>
);
})}
</div>
) : null}
</div>
);
}
+267 -110
View File
@@ -5,6 +5,8 @@ import { ContextMenu, ImageTile } from "./Gallery";
const GAP = 6;
const HEADER_HEIGHT = 52;
const SCRUBBER_WIDTH = 48;
const MONTH_SHORT = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] as const;
interface TimelineGroup {
key: string;
@@ -12,6 +14,23 @@ interface TimelineGroup {
images: ImageRecord[];
}
// One virtualized row: either a month header or a row of up to `cols` tiles.
type TimelineRow =
| { type: "header"; group: TimelineGroup }
| { type: "tiles"; images: ImageRecord[] };
interface ScrubberMonth {
monthNum: number;
label: string;
groupIndex: number;
}
interface ScrubberYear {
year: string;
firstGroupIndex: number;
months: ScrubberMonth[];
}
function buildLabel(key: string): string {
if (key === "unknown") return "Unknown Date";
const [yearStr, monthStr] = key.split("-");
@@ -44,6 +63,27 @@ function groupImages(images: ImageRecord[]): TimelineGroup[] {
.map(([key, imgs]) => ({ key, label: buildLabel(key), images: imgs }));
}
function buildScrubberYears(groups: TimelineGroup[]): ScrubberYear[] {
const byYear = new Map<string, ScrubberYear>();
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
if (group.key === "unknown") continue;
const year = group.key.substring(0, 4);
const monthNum = Number(group.key.substring(5, 7));
if (!byYear.has(year)) {
byYear.set(year, { year, firstGroupIndex: i, months: [] });
}
byYear.get(year)!.months.push({
monthNum,
label: MONTH_SHORT[monthNum - 1] ?? "",
groupIndex: i,
});
}
// Keep insertion order so the scrubber runs the same direction as the scrolled
// content (oldest at top with taken_asc), keeping the active highlight aligned.
return Array.from(byYear.values());
}
export function Timeline() {
const images = useGalleryStore((s) => s.images);
const loadMoreImages = useGalleryStore((s) => s.loadMoreImages);
@@ -55,13 +95,15 @@ export function Timeline() {
const parentRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [activeGroupIndex, setActiveGroupIndex] = useState(0);
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
image: ImageRecord;
} | null>(null);
// Measure container width before first paint to avoid a single-column flash.
// parentRef is the scroll container. Its clientWidth already excludes the
// scrubber because they are flex siblings, so no further adjustment is needed.
useLayoutEffect(() => {
const el = parentRef.current;
if (!el) return;
@@ -74,35 +116,59 @@ export function Timeline() {
}, []);
const tileSize = tileSizeForZoom(zoomPreset);
const groups = useMemo(() => groupImages(images), [images]);
const scrubberYears = useMemo(() => buildScrubberYears(groups), [groups]);
// Show as soon as there's more than one month to jump between — not gated on
// a full year. With taken_asc sort the loaded set is oldest-first, so this
// reflects whatever range is currently loaded.
const showScrubber = groups.length > 1;
const cols = useMemo(
() => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))),
[containerWidth, tileSize],
);
const groups = useMemo(() => groupImages(images), [images]);
// Flatten the month groups into a single list of fixed-height rows — one
// header row per group, then one tile-row per `cols` images. This lets the
// virtualizer render only the on-screen rows, exactly like the Gallery.
// Previously each *group* was one virtual item that rendered ALL of its
// images, so scrolling into a busy month mounted thousands of tiles at once.
const { rows, rowToGroupIndex, groupFirstRow } = useMemo(() => {
const rows: TimelineRow[] = [];
const rowToGroupIndex: number[] = [];
const groupFirstRow: number[] = [];
groups.forEach((group, groupIndex) => {
groupFirstRow[groupIndex] = rows.length;
rows.push({ type: "header", group });
rowToGroupIndex.push(groupIndex);
for (let i = 0; i < group.images.length; i += cols) {
rows.push({ type: "tiles", images: group.images.slice(i, i + cols) });
rowToGroupIndex.push(groupIndex);
}
});
return { rows, rowToGroupIndex, groupFirstRow };
}, [groups, cols]);
// estimateSize must be exact so virtualizer positions groups correctly.
// Each group height = header + rowCount * (tileSize + GAP) where the last row's
// GAP acts as spacing between this group and the next header.
const estimateSize = useCallback(
(index: number): number => {
const group = groups[index];
if (!group) return HEADER_HEIGHT;
const rowCount = Math.ceil(group.images.length / cols);
return HEADER_HEIGHT + rowCount * (tileSize + GAP);
},
[groups, cols, tileSize],
(index: number): number =>
rows[index]?.type === "header" ? HEADER_HEIGHT : tileSize + GAP,
[rows, tileSize],
);
const virtualizer = useVirtualizer({
count: groups.length,
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize,
overscan: 2,
overscan: 6,
paddingStart: GAP,
});
// Re-measure all items when cols changes so virtualizer positions stay accurate
// after a window resize (react-virtual v3 doesn't invalidate cached sizes on its own).
// Refs so the scroll handler can read the latest mappings without re-binding.
const rowToGroupIndexRef = useRef(rowToGroupIndex);
rowToGroupIndexRef.current = rowToGroupIndex;
const groupFirstRowRef = useRef(groupFirstRow);
groupFirstRowRef.current = groupFirstRow;
useEffect(() => {
virtualizer.measure();
}, [cols, virtualizer]);
@@ -110,12 +176,25 @@ export function Timeline() {
const handleScroll = useCallback(() => {
const el = parentRef.current;
if (!el) return;
if (el.scrollTop < 24) return;
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600;
// Active month = the group owning the first row still visible at the top.
const scrollTop = el.scrollTop;
const items = virtualizer.getVirtualItems();
let activeRow = items.length > 0 ? items[0].index : 0;
for (const item of items) {
if (item.start + item.size > scrollTop + HEADER_HEIGHT / 2) {
activeRow = item.index;
break;
}
}
setActiveGroupIndex(rowToGroupIndexRef.current[activeRow] ?? 0);
if (scrollTop < 24) return;
const nearBottom = scrollTop + el.clientHeight >= el.scrollHeight - 600;
if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages();
}
}, [images.length, loadMoreImages, loadingImages, totalImages]);
}, [virtualizer, images.length, loadMoreImages, loadingImages, totalImages]);
useEffect(() => {
const el = parentRef.current;
@@ -140,105 +219,135 @@ export function Timeline() {
};
}, []);
return (
<div
ref={parentRef}
className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#07080f]"
>
{images.length === 0 && loadingImages ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 min-w-72">
<div className="h-5 w-5 mx-auto rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
<p className="mt-4 text-sm text-white/40 font-medium">Loading timeline</p>
<p className="text-xs text-white/20 mt-1">Fetching results</p>
</div>
</div>
) : images.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8">
<svg
className="h-12 w-12 mx-auto text-white/10 mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={0.75}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<p className="text-sm text-white/30 font-medium">
{imageLoadError ? "Could not load timeline" : "No media found"}
</p>
<p className="text-xs text-white/15 mt-1">
{imageLoadError ?? "Add a folder to see your timeline"}
</p>
</div>
</div>
) : (
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const group = groups[virtualItem.index];
if (!group) return null;
return (
<div
key={virtualItem.key}
style={{
position: "absolute",
top: virtualItem.start,
width: "100%",
height: virtualItem.size,
}}
>
{/* Group header */}
<div
className="flex items-center gap-3 px-4"
style={{ height: HEADER_HEIGHT }}
>
<span className="text-sm font-semibold text-white/80 shrink-0">
{group.label}
</span>
<span className="text-xs text-white/25 shrink-0 tabular-nums">
{group.images.length}
</span>
<div className="flex-1 h-px bg-white/[0.06]" />
</div>
const scrollToGroup = useCallback(
(groupIndex: number) => {
const row = groupFirstRowRef.current[groupIndex] ?? 0;
virtualizer.scrollToIndex(row, { align: "start" });
},
[virtualizer],
);
{/* Image grid paddingBottom:GAP gives the gap below the last row,
matching the row-to-row gap and making estimateSize exact. */}
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={{
display: "grid",
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
gap: GAP,
paddingLeft: GAP,
paddingRight: GAP,
paddingBottom: GAP,
position: "absolute",
top: virtualItem.start,
width: "100%",
height: virtualItem.size,
}}
>
{group.images.map((image) => (
<ImageTile
key={image.id}
image={image}
onClick={() => openImage(image)}
onContextMenu={(event) => {
event.preventDefault();
setContextMenu({ x: event.clientX, y: event.clientY, image });
{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>
);
})}
</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" />
{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}
@@ -253,3 +362,51 @@ export function Timeline() {
</div>
);
}
interface ScrubberYearBlockProps {
yearEntry: ScrubberYear;
activeGroupIndex: number;
onScrollTo: (index: number) => void;
}
function ScrubberYearBlock({ yearEntry, activeGroupIndex, onScrollTo }: ScrubberYearBlockProps) {
const isYearActive = yearEntry.months.some((m) => m.groupIndex === activeGroupIndex);
return (
<div className="w-full flex flex-col items-center">
<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>
);
}
+2 -2
View File
@@ -24,7 +24,7 @@ function RestoreIcon() {
return (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<rect x="2.5" y="0.5" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1" />
<rect x="0.5" y="2.5" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1" fill="#030712" />
<rect x="0.5" y="2.5" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1" fill="var(--color-gray-950)" />
</svg>
);
}
@@ -88,7 +88,7 @@ export function TitleBar() {
aria-label={`Update available — click to update to Phokus v${updateVersion}`}
className="relative flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300 transition-colors hover:bg-white/12"
>
<span className="pointer-events-none absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/60 animate-ping" />
<span className="pointer-events-none absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/60 animate-ping" />
<PhokusMark className="relative h-4 w-4" dotClassName="fill-amber-400" />
</button>
{/* Custom tooltip — fades in ~100ms instead of the native ~500ms delay. */}
+28 -17
View File
@@ -55,8 +55,8 @@ function SortDropdown({
onClick={() => setOpen((v) => !v)}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${
open
? "border-white/15 bg-white/8 text-white"
: "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200"
? "border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white"
: "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:border-gray-700/40 light-theme:text-gray-600 light-theme:hover:border-gray-700 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
>
<span>{current?.label ?? "Sort"}</span>
@@ -68,14 +68,14 @@ function SortDropdown({
</svg>
</button>
{open ? (
<div className="absolute right-0 top-full z-30 mt-1.5 min-w-44 rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur">
<div className="absolute right-0 top-full z-30 mt-1.5 min-w-44 rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur light-theme:border-gray-700/50">
{options.map((option) => (
<button
key={option.value}
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
option.value === value
? "bg-white/6 text-white"
: "text-gray-400 hover:bg-white/5 hover:text-white"
? "bg-white/6 text-white light-theme:bg-gray-900 light-theme:text-white"
: "text-gray-400 hover:bg-white/5 hover:text-white light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => { onChange(option.value); setOpen(false); }}
>
@@ -106,14 +106,14 @@ function FilterPill({
}) {
const activeClass =
variant === "amber"
? "bg-amber-500/15 text-amber-300 border border-amber-500/30"
: "bg-white/10 text-white";
? "bg-amber-500/15 text-amber-300 border border-amber-500/30 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:border-amber-500/50"
: "bg-white/10 text-white light-theme:bg-gray-900 light-theme:text-white";
return (
<button
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
active
? activeClass
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={onClick}
>
@@ -158,6 +158,8 @@ 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);
@@ -166,6 +168,7 @@ export function Toolbar() {
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);
@@ -419,7 +422,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">
<div className="flex items-center rounded-lg border border-white/8 overflow-hidden light-theme:border-gray-700/40">
{(["compact", "comfortable", "detail"] as const).map((preset, i) => (
<button
key={preset}
@@ -427,8 +430,8 @@ export function Toolbar() {
i > 0 ? "border-l border-white/8" : ""
} ${
zoomPreset === preset
? "bg-white/10 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
? "bg-white/10 text-white light-theme:bg-gray-900 light-theme:text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
title={`${tileSize}px tiles`}
onClick={() => setZoomPreset(preset)}
@@ -441,12 +444,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 && 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="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="Similar: Folder" active={similarScope === "current_folder"} onClick={() => setSimilarScope("current_folder")} />
<FilterPill label="Similar: All" active={similarScope === "all_media"} onClick={() => setSimilarScope("all_media")} />
{hasAnyFailedEmbeddings ? (
@@ -457,6 +460,14 @@ 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>
+1 -1
View File
@@ -31,7 +31,7 @@ export function UpdateToast() {
</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"
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
+1 -1
View File
@@ -282,7 +282,7 @@ export function VideoPlayer({ src }: { src: string }) {
return (
<div
ref={containerRef}
className={`relative flex h-full w-full items-center justify-center bg-black ${controlsVisible ? "" : "cursor-none"}`}
className={`media-dark-surface relative flex h-full w-full items-center justify-center bg-black ${controlsVisible ? "" : "cursor-none"}`}
onPointerMove={showControls}
onClick={(event) => event.stopPropagation()}
>
@@ -43,10 +43,10 @@ export function OnboardingOverlay() {
const isLast = onboardingStep >= STEPS.length - 1;
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/65 backdrop-blur-sm">
<div className="flex max-h-[min(85vh,760px)] w-[min(90vw,720px)] flex-col rounded-lg border border-white/10 bg-[#07080f] shadow-2xl shadow-black/60">
<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">
<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}
@@ -59,7 +59,11 @@ export function OnboardingOverlay() {
key={s.id}
aria-label={s.title}
className={`h-1.5 rounded-full transition-all ${
i === onboardingStep ? "w-5 bg-white/70" : i < onboardingStep ? "w-1.5 bg-white/35" : "w-1.5 bg-white/15"
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)}
/>
@@ -83,23 +87,23 @@ export function OnboardingOverlay() {
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-white/[0.07] px-7 py-4">
<div className="flex items-center justify-between border-t border-white/[0.07] px-7 py-4 light-theme:border-gray-300/70">
<button
className="rounded-md border border-transparent px-3 py-1.5 text-xs text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-gray-200"
className="rounded-md border border-transparent px-3 py-1.5 text-xs text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-gray-200 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={completeOnboarding}
>
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"
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"
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"}
+4 -4
View File
@@ -30,7 +30,7 @@ export function StepAddLibrary() {
added, renamed, or removed. Nothing is moved or copied.
</p>
<div className="mt-5 flex items-center justify-between gap-6 border-y border-white/[0.05] py-4">
<div className="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 ? (
<>
@@ -49,10 +49,10 @@ export function StepAddLibrary() {
</p>
</>
)}
{addError ? <p className="mt-1.5 text-xs text-amber-300/90">{addError}</p> : null}
{addError ? <p className="mt-1.5 text-xs text-amber-300/90 light-theme:text-amber-700">{addError}</p> : null}
</div>
<button
className="shrink-0 rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 disabled:cursor-not-allowed disabled:opacity-45"
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={() => void handlePick()}
disabled={adding}
>
@@ -64,7 +64,7 @@ export function StepAddLibrary() {
As indexing runs, the gallery fills in roughly like this tiles appear immediately and sharpen
as thumbnails are generated:
</p>
<div className="mt-3 grid grid-cols-6 gap-1.5">
<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" />
+8 -8
View File
@@ -37,18 +37,18 @@ export function StepAiFeatures() {
</p>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">AI tagging optional</h4>
<div className="mt-1 divide-y divide-white/[0.05]">
<div className="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-white/[0.07] px-1 py-0.5 text-[11px] text-gray-200">/t</code> tags look like:
<code className="rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200 light-theme:bg-gray-800 light-theme:text-gray-100">/t</code> tags look like:
</p>
<span className="mt-2 flex flex-wrap gap-1.5">
{FAKE_TAGS.map((tag) => (
<span key={tag} className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-400">
<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>
))}
@@ -56,12 +56,12 @@ export function StepAiFeatures() {
</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">
<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"
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}
>
@@ -84,7 +84,7 @@ export function StepAiFeatures() {
</div>
) : null}
{!taggerModelPreparing && taggerModelError ? (
<p className="mt-2 text-xs leading-relaxed text-amber-300/90">
<p className="mt-2 text-xs leading-relaxed text-amber-300/90 light-theme:text-amber-700">
Download failed: {taggerModelError}
</p>
) : null}
@@ -92,11 +92,11 @@ export function StepAiFeatures() {
</div>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">Semantic search & similarity built in</h4>
<div className="mt-1 divide-y divide-white/[0.05]">
<div className="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-white/[0.07] px-1 py-0.5 text-[11px] text-gray-200">/s</code> search,
Powers <code className="rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200 light-theme:bg-gray-800 light-theme:text-gray-100">/s</code> search,
"find similar", and the Explore view, so it's part of the standard pipeline: the CLIP model
(~580 MB) downloads automatically the first time embeddings run. Nothing to do you'll see it
in the background-tasks bar.
@@ -37,14 +37,14 @@ export function StepGalleryPreview() {
carry your favorites, star ratings, and video durations; hover for filename and quick actions.
</p>
<div className="mt-5 grid grid-cols-4 gap-1.5">
<div className="media-dark-surface mt-5 grid grid-cols-4 gap-1.5">
{TILE_PROPS.map((props, i) => (
<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">
<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.
+3 -3
View File
@@ -49,7 +49,7 @@ export function StepPipeline() {
</p>
{/* Fake BackgroundTasks slim bar */}
<div className="mt-3 rounded-lg border border-white/[0.07] bg-white/[0.02] px-4 py-3">
<div className="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 ? (
@@ -57,7 +57,7 @@ export function StepPipeline() {
) : null}
<span className={`relative inline-flex h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-blue-400"}`} />
</span>
<span className="text-[13px] font-medium text-white/60">Holiday Photos</span>
<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
@@ -72,7 +72,7 @@ export function StepPipeline() {
</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">
<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.
+5 -5
View File
@@ -64,12 +64,12 @@ export function StepSearchDemo() {
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
One search bar, three modes picked by prefix. No prefix searches filenames, <code className="rounded bg-white/[0.07] px-1 py-0.5 text-[11px] text-gray-200">/s</code> searches
by meaning, <code className="rounded bg-white/[0.07] px-1 py-0.5 text-[11px] text-gray-200">/t</code> searches tags.
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-white/[0.04] px-3.5 py-2.5">
<div className="mt-5 flex items-center gap-2.5 rounded-lg border border-white/10 bg-gray-900/50 px-3.5 py-2.5 light-theme:border-gray-300/70 light-theme:bg-gray-900">
<svg className="h-4 w-4 shrink-0 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<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>
@@ -77,7 +77,7 @@ export function StepSearchDemo() {
{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">
<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>
@@ -90,7 +90,7 @@ export function StepSearchDemo() {
demo's visible state. */}
<div
key={demoIndex}
className={`mt-3 flex flex-wrap justify-center gap-1.5 transition-opacity duration-300 ${fullyTyped ? "opacity-100" : "opacity-0"}`}
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]">
+9 -9
View File
@@ -24,9 +24,9 @@ export function StepUpdates() {
</p>
{/* Mini app-window mockup: the lit mark shown in a real title bar. */}
<div className="mt-3 overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-lg">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-3 py-2">
<div className="relative flex h-6 w-6 items-center justify-center rounded-md bg-white/[0.08] text-gray-300">
<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" />
@@ -48,16 +48,16 @@ export function StepUpdates() {
{/* Ghosted window body, just enough to read as the app. */}
<div className="flex h-[72px] bg-gray-900/30">
<div className="w-14 shrink-0 border-r border-white/[0.05] p-2.5">
<div className="h-1.5 w-full rounded bg-white/[0.07]" />
<div className="mt-2 h-1.5 w-3/4 rounded bg-white/[0.04]" />
<div className="mt-2 h-1.5 w-3/4 rounded bg-white/[0.04]" />
<div 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-white/[0.06]" />
<div className="h-2 w-20 rounded bg-gray-800" />
<div className="mt-2.5 grid grid-cols-5 gap-1.5">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-7 rounded bg-white/[0.04]" />
<div key={i} className="h-7 rounded bg-gray-900" />
))}
</div>
</div>
+4 -4
View File
@@ -22,7 +22,7 @@ function ExplorePreview() {
{ size: 16, x: 70, y: 44, i: 6 },
];
return (
<div className="relative h-[72px] w-32 overflow-hidden rounded-lg border border-white/[0.07] bg-white/[0.02]">
<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}
@@ -36,7 +36,7 @@ function ExplorePreview() {
function TimelinePreview() {
return (
<div className="flex h-[72px] w-32 flex-col justify-center gap-2 rounded-lg border border-white/[0.07] bg-white/[0.02] px-3">
<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>
@@ -51,7 +51,7 @@ function TimelinePreview() {
function DuplicatesPreview() {
return (
<div className="flex h-[72px] w-32 items-center justify-center gap-1.5 rounded-lg border border-white/[0.07] bg-white/[0.02]">
<div className="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>
@@ -69,7 +69,7 @@ export function StepViews() {
<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]">
<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."
+86 -5
View File
@@ -1,6 +1,52 @@
import { useGalleryStore } from "../../store";
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);
@@ -10,7 +56,7 @@ export function FfmpegStatusRow() {
if (ffmpegStatus === "installed") {
return (
<div className="flex items-center gap-2.5 py-3">
<svg className="h-4 w-4 shrink-0 text-emerald-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<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>
@@ -27,14 +73,14 @@ export function FfmpegStatusRow() {
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<p className="text-sm text-white">Media engine download failed</p>
<p className="mt-1 text-xs leading-relaxed text-amber-300/90">{ffmpegError}</p>
<p className="mt-1 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"
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
@@ -71,6 +117,9 @@ export function FfmpegStatusRow() {
}
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">
@@ -83,8 +132,40 @@ export function StepWelcome() {
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]">
<div className="mt-1 divide-y divide-white/[0.05] light-theme:divide-gray-300/70">
<FfmpegStatusRow />
</div>
</div>
+6 -6
View File
@@ -82,7 +82,7 @@ export function FakeTile({
className?: string;
}) {
return (
<div className={`group relative aspect-square overflow-hidden rounded-xl bg-white/[0.04] ${className}`}>
<div className={`media-dark-surface group relative aspect-square overflow-hidden rounded-xl bg-white/[0.04] ${className}`}>
{loaded ? (
<img src={fakeImage(index)} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
) : (
@@ -116,16 +116,16 @@ export function FakeTile({
export function FakeStageTag({ label, state }: { label: string; state: "active" | "done" | "waiting" }) {
const className =
state === "active"
? "bg-white/5 text-gray-300"
? "bg-gray-900 text-gray-300 light-theme:bg-gray-900 light-theme:text-gray-300"
: state === "done"
? "bg-emerald-500/10 text-emerald-400"
: "bg-white/4 text-gray-600";
? "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-white/8 ${className}`}>
<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" />
) : (
@@ -143,7 +143,7 @@ export function ReplayButton({ onClick, label = "Replay" }: { onClick: () => voi
<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"
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" />
+309 -3
View File
@@ -1,5 +1,7 @@
@import "tailwindcss";
@custom-variant light-theme (&:is(html[data-theme="subtle-light"] *));
* {
box-sizing: border-box;
}
@@ -10,11 +12,315 @@ body,
height: 100%;
margin: 0;
padding: 0;
background: #030712;
background: var(--color-gray-950);
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;
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;
}
/* 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"] .settings-modal {
background: #f4f2ea !important;
border-color: #d6d1c7 !important;
color: #1f2937 !important;
box-shadow: 0 24px 80px rgb(0 0 0 / 0.24) !important;
}
html[data-theme="subtle-light"] .settings-modal aside {
background: #ece9e1 !important;
border-color: #d6d1c7 !important;
}
html[data-theme="subtle-light"] .settings-modal aside > div:first-child {
border-color: #d6d1c7 !important;
}
html[data-theme="subtle-light"] .settings-modal .settings-nav-active {
background: #d8d4ca !important;
color: #111827 !important;
}
html[data-theme="subtle-light"] .settings-modal .settings-nav-inactive {
background: transparent !important;
color: #4b5563 !important;
}
html[data-theme="subtle-light"] .settings-modal .settings-nav-inactive:hover {
background: #e2ded5 !important;
color: #111827 !important;
}
html[data-theme="subtle-light"] .settings-modal .settings-model-card,
html[data-theme="subtle-light"] .settings-modal .settings-light-panel {
background: #fbfaf6 !important;
border-color: #d6d1c7 !important;
color: #1f2937 !important;
}
html[data-theme="subtle-light"] .settings-modal button:not([role="switch"]) {
background: #fbfaf6 !important;
border-color: #cfc8ba !important;
color: #374151 !important;
}
html[data-theme="subtle-light"] .settings-modal button:not([role="switch"]):hover {
background: #eeeae0 !important;
color: #111827 !important;
}
html[data-theme="subtle-light"] .settings-modal input {
background: #ffffff !important;
border-color: #cfc8ba !important;
color: #111827 !important;
}
html[data-theme="subtle-light"] .settings-modal .text-white,
html[data-theme="subtle-light"] .settings-modal .text-gray-200,
html[data-theme="subtle-light"] .settings-modal .text-gray-300,
html[data-theme="subtle-light"] .settings-modal .text-gray-400 {
color: #111827 !important;
}
html[data-theme="subtle-light"] .settings-modal .text-gray-500,
html[data-theme="subtle-light"] .settings-modal .text-gray-600 {
color: #5f5a52 !important;
}
html[data-theme="subtle-light"] .settings-modal .text-emerald-200,
html[data-theme="subtle-light"] .settings-modal .text-emerald-300 {
color: #047857 !important;
}
html[data-theme="subtle-light"] .settings-modal .text-sky-300 {
color: #0369a1 !important;
}
html[data-theme="subtle-light"] .settings-modal .text-amber-200,
html[data-theme="subtle-light"] .settings-modal .text-amber-300 {
color: #b45309 !important;
}
html[data-theme="subtle-light"] .settings-modal .text-red-200 {
color: #b91c1c !important;
}
html[data-theme="subtle-light"] .settings-modal .bg-white\/10,
html[data-theme="subtle-light"] .settings-modal .bg-white\/5,
html[data-theme="subtle-light"] .settings-modal .bg-white\/\[0\.025\],
html[data-theme="subtle-light"] .settings-modal .bg-white\/\[0\.04\],
html[data-theme="subtle-light"] .settings-modal .bg-white\/\[0\.045\],
html[data-theme="subtle-light"] .settings-modal .bg-white\/\[0\.055\] {
background: #fbfaf6 !important;
}
html[data-theme="subtle-light"] .settings-modal .border-white\/10,
html[data-theme="subtle-light"] .settings-modal .border-white\/\[0\.05\],
html[data-theme="subtle-light"] .settings-modal .border-white\/\[0\.06\],
html[data-theme="subtle-light"] .settings-modal .border-white\/\[0\.07\] {
border-color: #d6d1c7 !important;
}
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;
@@ -24,9 +330,9 @@ body,
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
background: color-mix(in srgb, var(--color-white, #fff) 12%, transparent);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
background: color-mix(in srgb, var(--color-white, #fff) 22%, transparent);
}
+103 -9
View File
@@ -11,6 +11,7 @@ import { notifyTaskComplete } from "./notifications";
// Keyed as `${folderId}:embedding` or `${folderId}:tagging`.
const notificationTimers = new Map<string, ReturnType<typeof setTimeout>>();
const NOTIFICATION_DEBOUNCE_MS = 6000;
const THEME_KEY = "phokus-theme";
export interface Folder {
id: number;
@@ -19,6 +20,7 @@ export interface Folder {
image_count: number;
indexed_at: string | null;
scan_error: string | null;
sort_order: number;
}
export type MediaKind = "image" | "video";
@@ -33,6 +35,7 @@ export type AiRating = "general" | "sensitive" | "questionable" | "explicit";
export type TaggingQueueScope = "all" | "selected";
export type SimilarScope = "all_media" | "current_folder";
export type ExploreMode = "visual" | "tags";
export type AppTheme = "phokus" | "subtle-light" | "conventional-dark";
export interface ImageRecord {
id: number;
@@ -306,6 +309,7 @@ interface GalleryState {
favoritesOnly: boolean;
minimumRating: number;
failedEmbeddingsOnly: boolean;
failedTaggingOnly: boolean;
zoomPreset: ZoomPreset;
selectedImage: ImageRecord | null;
collectionTitle: string | null;
@@ -341,6 +345,7 @@ interface GalleryState {
taggingQueueFolderIds: number[];
mutedFolderIds: number[];
notificationsPaused: boolean;
theme: AppTheme;
// Per-folder background-worker pause flags, shared by the BackgroundTasks
// bar and the sidebar folder context menu.
workerPaused: Record<number, Record<WorkerKey, boolean>>;
@@ -385,6 +390,7 @@ interface GalleryState {
reindexFolder: (folderId: number) => Promise<void>;
renameFolder: (folderId: number, newName: string) => Promise<void>;
updateFolderPath: (folderId: number, newPath: string) => Promise<void>;
reorderFolders: (folderIds: number[]) => Promise<void>;
selectFolder: (folderId: number | null) => void;
setViewFolderScope: (folderId: number | null) => void;
loadImages: (reset?: boolean) => Promise<void>;
@@ -398,6 +404,8 @@ interface GalleryState {
setFavoritesOnly: (favoritesOnly: boolean) => void;
setMinimumRating: (minimumRating: number) => void;
setFailedEmbeddingsOnly: (failedEmbeddingsOnly: boolean) => void;
setFailedTaggingOnly: (failedTaggingOnly: boolean) => void;
showFailedTagging: (folderId: number) => void;
setZoomPreset: (zoomPreset: ZoomPreset) => void;
openImage: (image: ImageRecord) => void;
closeImage: () => void;
@@ -436,6 +444,7 @@ interface GalleryState {
toggleMutedFolder: (folderId: number) => void;
loadNotificationsPaused: () => Promise<void>;
setNotificationsPaused: (paused: boolean) => void;
setTheme: (theme: AppTheme) => void;
loadWorkerStates: () => Promise<void>;
setWorkerPaused: (folderId: number, worker: WorkerKey, paused: boolean) => void;
setAllWorkersPaused: (folderId: number, paused: boolean) => void;
@@ -487,6 +496,10 @@ interface GalleryState {
}
const PAGE_SIZE = 200;
// Timeline loads its full filtered set in one indexed taken_at query so the
// scrubber can span the entire library and jump to any month. Rendering is
// virtualized, so the cost is one query + records in memory — fine at this scale.
const TIMELINE_PAGE_SIZE = 100000;
const AI_CAPTIONS_ENABLED_KEY = "phokus.aiCaptionsEnabled";
const SIMILAR_DISTANCE_THRESHOLD = 0.24;
@@ -502,6 +515,15 @@ function initialAiCaptionsEnabled(): boolean {
return window.localStorage.getItem(AI_CAPTIONS_ENABLED_KEY) === "true";
}
function initialTheme(): AppTheme {
if (typeof window === "undefined") return "phokus";
const saved = window.localStorage.getItem(THEME_KEY);
const theme: AppTheme =
saved === "subtle-light" || saved === "conventional-dark" ? saved : "phokus";
document.documentElement.dataset.theme = theme;
return theme;
}
function mergeIntoVisibleWindow(
currentImages: ImageRecord[],
newImages: ImageRecord[],
@@ -574,6 +596,7 @@ function matchesFilters(
favoritesOnly: boolean,
minimumRating: number,
failedEmbeddingsOnly: boolean,
failedTaggingOnly: boolean,
search: string,
): boolean {
const matchesFolder = selectedFolderId === null || image.folder_id === selectedFolderId;
@@ -581,7 +604,8 @@ function matchesFilters(
const matchesFavorite = !favoritesOnly || image.favorite;
const matchesRating = image.rating >= minimumRating;
const matchesFailedEmbedding = !failedEmbeddingsOnly || image.embedding_status === "failed";
return matchesFolder && matchesMedia && matchesFavorite && matchesRating && matchesFailedEmbedding && matchesSearch(image, search);
const matchesFailedTagging = !failedTaggingOnly || image.ai_tagger_error !== null;
return matchesFolder && matchesMedia && matchesFavorite && matchesRating && matchesFailedEmbedding && matchesFailedTagging && matchesSearch(image, search);
}
function compareNullableNumber(a: number | null, b: number | null): number {
@@ -658,11 +682,24 @@ function replaceImage(images: ImageRecord[], updatedImage: ImageRecord, sort: So
function replaceExistingImages(
currentImages: ImageRecord[],
updatedImages: ImageRecord[],
sort: SortOrder,
): ImageRecord[] {
// Replace matched records in place WITHOUT re-sorting. `media-updated` carries
// thumbnail/metadata fills that don't move an item in the list (Timeline
// re-buckets by taken_at separately), and it fires constantly while the
// background workers run. Re-sorting here meant an O(n log n) pass on every
// batch — fine for the ~200-item gallery window, but a UI-freezing churn in
// Timeline view where `images` can hold the entire library (TIMELINE_PAGE_SIZE).
// Returning the same array reference when nothing matched also avoids a wasted
// re-render. Relative order for just-updated items is corrected on next load.
const updatesByPath = new Map(updatedImages.map((image) => [image.path, image]));
const nextImages = currentImages.map((image) => updatesByPath.get(image.path) ?? image);
return nextImages.sort((a, b) => compareImages(a, b, sort));
let changed = false;
const nextImages = currentImages.map((image) => {
const update = updatesByPath.get(image.path);
if (!update) return image;
changed = true;
return update;
});
return changed ? nextImages : currentImages;
}
export function tileSizeForZoom(zoomPreset: ZoomPreset): number {
@@ -691,6 +728,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
favoritesOnly: false,
minimumRating: 0,
failedEmbeddingsOnly: false,
failedTaggingOnly: false,
zoomPreset: "comfortable",
selectedImage: null,
collectionTitle: null,
@@ -726,6 +764,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
taggingQueueFolderIds: [],
mutedFolderIds: [],
notificationsPaused: false,
theme: initialTheme(),
workerPaused: {},
appVersion: null,
@@ -840,8 +879,26 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
await loadBackgroundJobProgress();
},
reorderFolders: async (folderIds) => {
const previous = get().folders;
const byId = new Map(previous.map((folder) => [folder.id, folder]));
const folders = folderIds
.map((id, index) => {
const folder = byId.get(id);
return folder ? { ...folder, sort_order: index + 1 } : null;
})
.filter((folder): folder is Folder => folder !== null);
set({ folders });
try {
await invoke("reorder_folders", { params: { folder_ids: folderIds } });
} catch (error) {
set({ folders: previous });
throw error;
}
},
selectFolder: (folderId) => {
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, imageLoadError: null });
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, failedTaggingOnly: false, imageLoadError: null });
void get().loadImages(true);
},
@@ -874,7 +931,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
},
loadImages: async (reset = false) => {
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly } = get();
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly, failedTaggingOnly, activeView } = get();
const parsedSearch = parseSearchValue(search);
const requestToken = ++galleryRequestToken;
set({ loadingImages: true, imageLoadError: null });
@@ -961,9 +1018,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
favorites_only: favoritesOnly,
rating_min: minimumRating > 0 ? minimumRating : null,
embedding_failed_only: failedEmbeddingsOnly,
tagging_failed_only: failedTaggingOnly,
sort,
offset,
limit: PAGE_SIZE,
limit: activeView === "timeline" ? TIMELINE_PAGE_SIZE : PAGE_SIZE,
},
});
@@ -1070,7 +1128,35 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
},
setFailedEmbeddingsOnly: (failedEmbeddingsOnly) => {
set({ failedEmbeddingsOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
set({ failedEmbeddingsOnly, failedTaggingOnly: failedEmbeddingsOnly ? false : get().failedTaggingOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
void get().loadImages(true);
},
setFailedTaggingOnly: (failedTaggingOnly) => {
set({ failedTaggingOnly, failedEmbeddingsOnly: failedTaggingOnly ? false : get().failedEmbeddingsOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
void get().loadImages(true);
},
showFailedTagging: (folderId) => {
set({
selectedFolderId: folderId,
activeView: "gallery",
search: "",
mediaFilter: "all",
favoritesOnly: false,
minimumRating: 0,
failedEmbeddingsOnly: false,
failedTaggingOnly: true,
images: [],
loadedCount: 0,
collectionTitle: null,
similarSourceImageId: null,
similarSourceFolderId: null,
similarHasMore: false,
similarFolderId: null,
similarCrop: null,
imageLoadError: null,
});
void get().loadImages(true);
},
@@ -1557,6 +1643,12 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
void invoke("set_notifications_paused", { paused }).catch(() => {});
},
setTheme: (theme) => {
window.localStorage.setItem(THEME_KEY, theme);
document.documentElement.dataset.theme = theme;
set({ theme });
},
loadWorkerStates: async () => {
const folderIds = get().folders.map((folder) => folder.id);
if (folderIds.length === 0) {
@@ -2161,6 +2253,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
state.favoritesOnly,
state.minimumRating,
state.failedEmbeddingsOnly,
state.failedTaggingOnly,
state.search,
),
);
@@ -2200,6 +2293,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
state.favoritesOnly,
state.minimumRating,
state.failedEmbeddingsOnly,
state.failedTaggingOnly,
state.search,
),
);
@@ -2214,7 +2308,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}
return {
images: replaceExistingImages(state.images, visibleImages, state.sort),
images: replaceExistingImages(state.images, visibleImages),
selectedImage,
};
});
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
const SECTION_BY_TYPE = {
added: "Added",
changed: "Changed",
deprecated: "Deprecated",
removed: "Removed",
fixed: "Fixed",
security: "Security",
};
function usage() {
console.error([
"Usage:",
" pnpm changelog:add -- --type fixed --message \"Fix thing\"",
"",
`Types: ${Object.keys(SECTION_BY_TYPE).join(", ")}`,
].join("\n"));
process.exit(1);
}
function argValue(name) {
const index = process.argv.indexOf(`--${name}`);
if (index === -1) return null;
return process.argv[index + 1] ?? null;
}
const type = argValue("type")?.toLowerCase();
const message = argValue("message")?.trim();
if (!type || !message || !SECTION_BY_TYPE[type]) {
usage();
}
const section = SECTION_BY_TYPE[type];
const changelogPath = resolve("CHANGELOG.md");
let changelog = readFileSync(changelogPath, "utf8");
if (!/^## \[Unreleased\]/m.test(changelog)) {
changelog = changelog.replace(
/(\r?\n)(## \[[^\r\n]+\])/,
`$1## [Unreleased]$1$1$2`,
);
}
// JS regex has no \z anchor, so match to the next version heading or true
// end-of-input ($ with nothing following — \n-tolerant under the m flag).
const unreleasedMatch = changelog.match(/^## \[Unreleased\][\s\S]*?(?=^## \[|$(?![\s\S]))/m);
if (!unreleasedMatch) {
throw new Error("Could not find or create an Unreleased section.");
}
const unreleased = unreleasedMatch[0];
const lineEnding = changelog.includes("\r\n") ? "\r\n" : "\n";
const bullet = `- ${message}`;
let nextUnreleased;
const sectionRegex = new RegExp(`(^### ${section}\\s*)([\\s\\S]*?)(?=^### |$(?![\\s\\S]))`, "m");
const sectionMatch = unreleased.match(sectionRegex);
if (sectionMatch) {
const body = sectionMatch[2].trimEnd();
const replacement = `${sectionMatch[1]}${body ? `${body}${lineEnding}` : ""}${bullet}${lineEnding}${lineEnding}`;
nextUnreleased = unreleased.replace(sectionRegex, replacement);
} else {
const insert = `${lineEnding}### ${section}${lineEnding}${lineEnding}${bullet}${lineEnding}`;
nextUnreleased = unreleased.trimEnd() + insert + lineEnding;
}
changelog = changelog.replace(unreleased, nextUnreleased);
writeFileSync(changelogPath, changelog);
Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 785 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/brand/phokus-mark.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Phokus — your local media library, made searchable</title>
<meta
name="description"
content="Phokus is a local-first desktop media library for Windows. Browse, search by meaning, and curate large image and video folders with semantic search, visual discovery, and on-device AI. Nothing leaves your machine."
/>
<meta name="theme-color" content="#0a0b0d" />
<!-- Open Graph (social card image added in the metadata pass) -->
<meta property="og:title" content="Phokus — your local media library, made searchable" />
<meta
property="og:description"
content="Semantic search, visual discovery, and duplicate cleanup for the image and video folders already on your computer. Local-first, Windows."
/>
<meta property="og:type" content="website" />
<meta property="og:url" content="https://phokus.jezz.wtf" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@phokus/website",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fontsource-variable/inter": "5.2.8",
"@fontsource-variable/space-grotesk": "5.2.10",
"framer-motion": "^12.38.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"tailwindcss": "^4.2.2",
"typescript": "~5.8.3",
"vite": "^7.0.4",
"vite-imagetools": "^10.0.0"
}
}
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256" role="img" aria-label="Phokus">
<title>Phokus</title>
<rect x="8" y="8" width="240" height="240" rx="52" fill="#0e0f14"/>
<g transform="translate(128 128)" fill="none" stroke="#e9e9ec" stroke-width="9"
stroke-linecap="round" stroke-linejoin="round">
<circle r="110"/>
<path d="M0,-46 A52,52 0 0 0 39.84,-23 A52,52 0 0 0 39.84,23 A52,52 0 0 0 0,46 A52,52 0 0 0 -39.84,23 A52,52 0 0 0 -39.84,-23 A52,52 0 0 0 0,-46 Z"/>
<g transform="rotate(0)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
<g transform="rotate(60)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
<g transform="rotate(120)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
<g transform="rotate(180)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
<g transform="rotate(240)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
<g transform="rotate(300)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

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