Merge refactor/modularize-components: modular components, store slices, shared menus, prettier
github/actions/ci GitHub Actions CI finished: success

- Zustand store split from one god file into per-feature slices under
  src/store/ (library, gallery, search, explore, albums, duplicates,
  tagger, captions, settings, app) with shared helpers and event wiring.
- All large components modularized into per-feature subfolders:
  Lightbox, SettingsModal, Sidebar, ExploreView, FolderPickerModal,
  BackgroundTasks, Toolbar, Gallery, Timeline, DuplicateFinder,
  VideoPlayer, and BulkActionBar.
- Context menus, dropdowns, and dismissal handling consolidated into a
  shared menu system (src/components/menu/); dead MenuBar removed;
  shared icons extracted into icons.tsx.
- New: add-to-album submenu on the image right-click menu.
- Fixes: menu labels no longer text-selectable, duplicate tile React
  keys, mock IPC results cloned to match real invoke semantics, tile
  tooltip and folder picker glitches.
- Tooling: prettier added with format/format:rust scripts, one-shot
  format of the frontend, LF line endings enforced via .gitattributes.
This commit is contained in:
2026-07-04 20:31:47 +01:00
162 changed files with 16856 additions and 13541 deletions
+22
View File
@@ -0,0 +1,22 @@
# Keep LF in the working copy for all text files (prettier enforces LF)
* text=auto eol=lf
# Windows scripts that genuinely need CRLF
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
# Binary assets — never touch line endings
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.gif binary
*.ico binary
*.icns binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
*.mp4 binary
*.onnx binary
+30
View File
@@ -0,0 +1,30 @@
# Build outputs
dist/
build/
# Tauri / Rust (handled by cargo fmt)
src-tauri/
# Lock files & generated
pnpm-lock.yaml
*.lock
# Generated
src/vite-env.d.ts
# Public assets
public/
# Website subpackage (has its own config if needed)
website/
# Markdown & docs (hand-written or tool-generated, keep diffs quiet)
*.md
docs/
# CI workflows
.github/
.gitea/
# Tool-managed config
.claude/
+14
View File
@@ -0,0 +1,14 @@
{
"semi": false,
"singleQuote": true,
"jsxSingleQuote": false,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"endOfLine": "lf",
"plugins": ["prettier-plugin-tailwindcss"]
}
+14 -7
View File
@@ -31,9 +31,7 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
an album can now stay inside that album, jump back to the source folder, or
search everything.
- **Tag manager** — Explore's Tag Cloud now has a Manage mode for renaming,
merging, and deleting tags across the whole library. There is also an "Open tag
manager" shortcut in Settings -> AI Workspace when you want to get straight to
the cleanup.
merging, and deleting tags across the whole library.
- **Camera info in the lightbox** — the info panel now shows EXIF details like
camera, lens, aperture, shutter speed, ISO, and focal length. Geotagged photos
also get a browser link for their GPS coordinates, and already-indexed images
@@ -43,7 +41,6 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
- **Choose your tagging model** — Settings -> AI Workspace now lets you pick
between the anime-focused WD tagger and JoyTag, which is better suited to photo
libraries and stronger on NSFW concepts (if that's your thing, we don't judge).
Models download only when needed, and tags remember which model created them.
- **Related tags in Explore** — Hover over a tag in the Tag Cloud to see the tags
that most often appear with it, complete with connection lines and image counts.
Handy for finding little clusters you did not know were there.
@@ -53,12 +50,19 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
- **Editable folder path** — the folder picker now has an address bar, so you can
paste a path directly while still using breadcrumbs for quick jumps.
- **Slideshow mode** — turn the lightbox into a fullscreen, image-only slideshow
from whatever collection you are already browsing. Videos are skipped, controls
tuck themselves away after a few seconds, and Settings lets you pick the pace
and whether playback follows the current order or goes random.
from whatever collection you are already browsing.
- **Add to album from the right-click menu** — right-click any image in the
Gallery or Timeline and file it straight into an album from the new "Add to
Album" submenu. One image, one click, zero ceremony.
### Changed
- **Menus got their act together** — right-click menus (images, folders,
albums, the theme switcher) and every dropdown (sort, folder scope, settings,
sidebar) now share one style with one set of manners: they stay on screen
instead of wandering off the edge, all close on Escape, and right-click menus
can do proper submenus now. Subtle Light dresses them all the same way too,
instead of saving the nice outfit for one dropdown.
- **Neater lightbox details** — image and video metadata now sits in two columns,
so the info panel shows more at a glance with less scrolling.
- **Faster Explore revisits** — returning to a folder's visual clusters should
@@ -122,6 +126,9 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
cleaned up on startup. Your manually-added tags are left alone.
- **Selected Folders starts empty** — choosing "Selected Folders" for AI tagging
no longer pre-selects the first folder. You decide exactly what gets queued.
- **A couple of tiny UI papercuts are gone** — the zoom buttons now show the
right tile size when you hover them, and the folder picker no longer adds an
odd trailing slash to Windows drive breadcrumbs.
## [0.1.1] — 2026-06-23
+5 -4
View File
@@ -33,9 +33,10 @@ There are no test suites configured.
### Frontend (`src/`)
- **`store.ts`** — single Zustand store (`useGalleryStore`) that owns all app state and all `invoke()` calls to the Tauri backend. Every feature (folders, images, search, similar images, tags, captions, tagger, duplicates) is implemented as store actions here. React components are thin consumers.
- **`src/store/`** — single Zustand store (`useGalleryStore`) that owns all app state and all `invoke()` calls to the Tauri backend, split into per-feature slices combined in `index.ts` (which exports `useGalleryStore` and the `GalleryStore` type). `types.ts` holds all interfaces/type unions (including `ImageRecord`); `helpers.ts` holds pure functions and cross-slice module state (search parsing, image sort/merge, request-token guards). Slices: `librarySlice` (folders), `gallerySlice` (image paging/filters/bulk actions), `searchSlice` (search + similar-images), `exploreSlice` (visual clusters, tag cloud, tags), `albumSlice`, `duplicateSlice`, `taggerSlice`, `captionSlice`, `settingsSlice`, `appSlice` (updates, onboarding, ffmpeg, worker pauses); `events.ts` wires the Tauri event listeners (`subscribeToProgress`). Components still call `useGalleryStore(s => s.field)` against one flat state object — the slice split is internal. React components are thin consumers.
- **`App.tsx`** — sets up Tauri event listeners (`subscribeToProgress`) and renders the top-level layout (sidebar + active view).
- **`src/components/`** — UI components: `Gallery`, `Lightbox`, `Sidebar`, `Toolbar`, `TagCloud`, `DuplicateFinder`, `BackgroundTasks`, `SettingsModal`, `MenuBar`, `TitleBar`.
- **`src/components/`** — UI components: `Gallery`, `Lightbox`, `Sidebar`, `Toolbar`, `Timeline`, `ExploreView`, `DuplicateFinder`, `BackgroundTasks`, `SettingsModal`, `TitleBar`.
- **`src/components/menu/`** — shared floating-UI primitives: `useDismissable`, `MenuPanel`/`MenuItem`/`SubMenu`, the portal-based `ContextMenu`, and the app-wide `Dropdown`. Build menus, dropdowns, and popovers on these instead of hand-rolling.
- State management: Zustand v5, no selectors library — components call `useGalleryStore(s => s.field)` directly.
- Styling: Tailwind CSS v4 (Vite plugin, no config file).
- Virtualized gallery grid: `@tanstack/react-virtual`.
@@ -43,7 +44,7 @@ There are no test suites configured.
### Search modes
The search bar supports prefix syntax parsed by `parseSearchValue` in `store.ts`:
The search bar supports prefix syntax parsed by `parseSearchValue` in `src/store/helpers.ts`:
- No prefix / `f:` — filename search (paginated, DB-backed)
- `/s <query>` or `s: <query>` — semantic (embedding) search
- `/t <tag>` or `t: <tag>` — tag search
@@ -87,7 +88,7 @@ Database: SQLite with WAL mode, stored in the Tauri app data directory as `galle
### Key types
`ImageRecord` (mirrored in `store.ts` and `db.rs`) is the central data type. It carries embedding status, tagging status, caption data, and media metadata. The frontend type must stay in sync with the Rust struct serialization.
`ImageRecord` (mirrored in `src/store/types.ts` and `db.rs`) is the central data type. It carries embedding status, tagging status, caption data, and media metadata. The frontend type must stay in sync with the Rust struct serialization.
## Development notes
+7 -2
View File
@@ -16,8 +16,11 @@
"dev:ui": "vite --mode ui --host 127.0.0.1 --port 1422 --strictPort --open",
"dev:vite": "vite",
"dev:web": "cd website && pnpm dev",
"format:app": "cd src-tauri && cargo fmt",
"format:check": "cd src-tauri && cargo fmt --check",
"format": "prettier --write .",
"format:all": "pnpm format && pnpm format:rust",
"format:check": "prettier --check .",
"format:rust": "cd src-tauri && cargo fmt",
"format:rust:check": "cd src-tauri && cargo fmt --check",
"preview": "vite preview",
"tauri": "tauri"
},
@@ -45,6 +48,8 @@
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"prettier": "^3.9.4",
"prettier-plugin-tailwindcss": "^0.8.0",
"tailwindcss": "^4.2.2",
"typescript": "~5.8.3",
"vite": "^7.0.4"
+2 -2
View File
@@ -1,4 +1,4 @@
import { defineConfig, devices } from '@playwright/test';
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
@@ -76,4 +76,4 @@ export default defineConfig({
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// },
});
})
+72
View File
@@ -72,6 +72,12 @@ importers:
'@vitejs/plugin-react':
specifier: ^4.6.0
version: 4.7.0(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))
prettier:
specifier: ^3.9.4
version: 3.9.4
prettier-plugin-tailwindcss:
specifier: ^0.8.0
version: 0.8.0(prettier@3.9.4)
tailwindcss:
specifier: ^4.2.2
version: 4.2.2
@@ -1180,6 +1186,66 @@ packages:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
prettier-plugin-tailwindcss@0.8.0:
resolution: {integrity: sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==}
engines: {node: '>=20.19'}
peerDependencies:
'@ianvs/prettier-plugin-sort-imports': '*'
'@prettier/plugin-hermes': '*'
'@prettier/plugin-oxc': '*'
'@prettier/plugin-pug': '*'
'@shopify/prettier-plugin-liquid': '*'
'@trivago/prettier-plugin-sort-imports': '*'
'@zackad/prettier-plugin-twig': '*'
prettier: ^3.0
prettier-plugin-astro: '*'
prettier-plugin-css-order: '*'
prettier-plugin-jsdoc: '*'
prettier-plugin-marko: '*'
prettier-plugin-multiline-arrays: '*'
prettier-plugin-organize-attributes: '*'
prettier-plugin-organize-imports: '*'
prettier-plugin-sort-imports: '*'
prettier-plugin-svelte: '*'
peerDependenciesMeta:
'@ianvs/prettier-plugin-sort-imports':
optional: true
'@prettier/plugin-hermes':
optional: true
'@prettier/plugin-oxc':
optional: true
'@prettier/plugin-pug':
optional: true
'@shopify/prettier-plugin-liquid':
optional: true
'@trivago/prettier-plugin-sort-imports':
optional: true
'@zackad/prettier-plugin-twig':
optional: true
prettier-plugin-astro:
optional: true
prettier-plugin-css-order:
optional: true
prettier-plugin-jsdoc:
optional: true
prettier-plugin-marko:
optional: true
prettier-plugin-multiline-arrays:
optional: true
prettier-plugin-organize-attributes:
optional: true
prettier-plugin-organize-imports:
optional: true
prettier-plugin-sort-imports:
optional: true
prettier-plugin-svelte:
optional: true
prettier@3.9.4:
resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==}
engines: {node: '>=14'}
hasBin: true
react-dom@19.2.4:
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
peerDependencies:
@@ -2110,6 +2176,12 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
prettier-plugin-tailwindcss@0.8.0(prettier@3.9.4):
dependencies:
prettier: 3.9.4
prettier@3.9.4: {}
react-dom@19.2.4(react@19.2.4):
dependencies:
react: 19.2.4
+69 -69
View File
@@ -1,100 +1,100 @@
import { useEffect } from "react";
import { useGalleryStore } from "./store";
import { Sidebar } from "./components/Sidebar";
import { BackgroundTasks } from "./components/BackgroundTasks";
import { Toolbar } from "./components/Toolbar";
import { Gallery } from "./components/Gallery";
import { Lightbox } from "./components/Lightbox";
import { ExploreView } from "./components/ExploreView";
import { DuplicateFinder } from "./components/DuplicateFinder";
import { Timeline } from "./components/Timeline";
import { TitleBar } from "./components/TitleBar";
import { SettingsModal } from "./components/SettingsModal";
import { FolderPickerModal } from "./components/FolderPickerModal";
import { UpdateToast } from "./components/UpdateToast";
import { WhatsNewToast } from "./components/WhatsNewToast";
import { WhatsNewModal } from "./components/WhatsNewModal";
import { OnboardingOverlay } from "./components/onboarding/OnboardingOverlay";
import { DemoPanel } from "./components/DemoPanel";
import { initializeNotifications } from "./notifications";
import { useEffect } from 'react'
import { useGalleryStore } from './store'
import { Sidebar } from './components/Sidebar'
import { BackgroundTasks } from './components/BackgroundTasks'
import { Toolbar } from './components/Toolbar'
import { Gallery } from './components/Gallery'
import { Lightbox } from './components/Lightbox'
import { ExploreView } from './components/ExploreView'
import { DuplicateFinder } from './components/DuplicateFinder'
import { Timeline } from './components/Timeline'
import { TitleBar } from './components/TitleBar'
import { SettingsModal } from './components/SettingsModal'
import { FolderPickerModal } from './components/FolderPickerModal'
import { UpdateToast } from './components/UpdateToast'
import { WhatsNewToast } from './components/WhatsNewToast'
import { WhatsNewModal } from './components/WhatsNewModal'
import { OnboardingOverlay } from './components/onboarding/OnboardingOverlay'
import { DemoPanel } from './components/DemoPanel'
import { initializeNotifications } from './notifications'
export default function App() {
const loadFolders = useGalleryStore((state) => state.loadFolders);
const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress);
const loadImages = useGalleryStore((state) => state.loadImages);
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus);
const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus);
const loadTaggerModel = useGalleryStore((state) => state.loadTaggerModel);
const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache);
const loadAlbums = useGalleryStore((state) => state.loadAlbums);
const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds);
const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused);
const loadWorkerPausesPersist = useGalleryStore((state) => state.loadWorkerPausesPersist);
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
const loadAppVersion = useGalleryStore((state) => state.loadAppVersion);
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
const loadFfmpegStatus = useGalleryStore((state) => state.loadFfmpegStatus);
const loadOnboardingCompleted = useGalleryStore((state) => state.loadOnboardingCompleted);
const initWhatsNew = useGalleryStore((state) => state.initWhatsNew);
const activeView = useGalleryStore((state) => state.activeView);
const loadFolders = useGalleryStore((state) => state.loadFolders)
const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress)
const loadImages = useGalleryStore((state) => state.loadImages)
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus)
const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus)
const loadTaggerModel = useGalleryStore((state) => state.loadTaggerModel)
const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache)
const loadAlbums = useGalleryStore((state) => state.loadAlbums)
const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds)
const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused)
const loadWorkerPausesPersist = useGalleryStore((state) => state.loadWorkerPausesPersist)
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress)
const loadAppVersion = useGalleryStore((state) => state.loadAppVersion)
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates)
const loadFfmpegStatus = useGalleryStore((state) => state.loadFfmpegStatus)
const loadOnboardingCompleted = useGalleryStore((state) => state.loadOnboardingCompleted)
const initWhatsNew = useGalleryStore((state) => state.initWhatsNew)
const activeView = useGalleryStore((state) => state.activeView)
useEffect(() => {
void initializeNotifications();
void loadMutedFolderIds();
void loadNotificationsPaused();
void loadWorkerPausesPersist();
void loadFfmpegStatus();
void loadOnboardingCompleted();
void initializeNotifications()
void loadMutedFolderIds()
void loadNotificationsPaused()
void loadWorkerPausesPersist()
void loadFfmpegStatus()
void loadOnboardingCompleted()
// Load the app version first so the What's New toast/modal (which read
// appVersion from the store) have it before the greeting can appear.
void loadAppVersion().then(() => initWhatsNew());
void loadAppVersion().then(() => initWhatsNew())
// Quiet launch check — dev builds have no signed artifacts to update to.
if (import.meta.env.PROD) {
void checkForUpdates({ quiet: true });
void checkForUpdates({ quiet: true })
}
loadFolders().then(async () => {
void loadBackgroundJobProgress();
void loadCaptionModelStatus();
void loadTaggerModel();
void loadTaggerModelStatus();
void loadDuplicateScanCache();
await loadAlbums();
await loadImages(true);
if (import.meta.env.MODE === "ui") {
const { applyMockScenario } = await import("./dev/applyMockScenario");
applyMockScenario();
void loadBackgroundJobProgress()
void loadCaptionModelStatus()
void loadTaggerModel()
void loadTaggerModelStatus()
void loadDuplicateScanCache()
await loadAlbums()
await loadImages(true)
if (import.meta.env.MODE === 'ui') {
const { applyMockScenario } = await import('./dev/applyMockScenario')
applyMockScenario()
}
});
let unlisten: (() => void) | undefined;
})
let unlisten: (() => void) | undefined
subscribeToProgress().then((fn) => {
unlisten = fn;
});
unlisten = fn
})
return () => {
unlisten?.();
};
}, []);
unlisten?.()
}
}, [])
return (
<div className="flex h-screen flex-col bg-gray-950 text-white overflow-hidden select-none">
<div className="flex h-screen flex-col overflow-hidden bg-gray-950 text-white select-none">
{/* Custom title bar — sits at the very top */}
<TitleBar />
{/* Main app content below the title bar */}
<div className="flex flex-1 min-h-0">
<div className="flex min-h-0 flex-1">
<Sidebar />
<main className="flex-1 flex flex-col min-w-0">
{activeView === "timeline" ? (
<main className="flex min-w-0 flex-1 flex-col">
{activeView === 'timeline' ? (
<>
<Toolbar />
<BackgroundTasks />
<Timeline />
</>
) : activeView === "explore" ? (
) : activeView === 'explore' ? (
<>
<BackgroundTasks />
<ExploreView />
</>
) : activeView === "duplicates" ? (
) : activeView === 'duplicates' ? (
<>
<BackgroundTasks />
<DuplicateFinder />
@@ -118,5 +118,5 @@ export default function App() {
<OnboardingOverlay />
{import.meta.env.DEV && <DemoPanel />}
</div>
);
)
}
+55 -51
View File
@@ -2,113 +2,117 @@
// data so the "What's New" UI can render it nicely instead of dumping markdown.
// Keeping the changelog as the single source of truth means there's no separate
// per-release copy to maintain — whatever ships in CHANGELOG.md is what users see.
import changelogRaw from "../CHANGELOG.md?raw";
import changelogRaw from '../CHANGELOG.md?raw'
export interface ChangelogItem {
/** The bold lead-in at the start of a bullet (e.g. "Custom multi-folder picker"), if any. */
lead: string | null;
lead: string | null
/** The remaining descriptive text. May still contain inline `code` / **bold** markers. */
body: string;
body: string
}
export interface ChangelogSection {
/** "Added" | "Changed" | "Fixed" | "Removed" | "Deprecated" | "Security" */
title: string;
items: ChangelogItem[];
title: string
items: ChangelogItem[]
}
export interface ChangelogEntry {
version: string;
date: string | null;
sections: ChangelogSection[];
version: string
date: string | null
sections: ChangelogSection[]
}
// "## [0.1.1] — 2026-06-23" / "## [Unreleased]"
const VERSION_HEADING = /^##\s+\[([^\]]+)\]\s*(?:[—–-]\s*(.+?)\s*)?$/;
const VERSION_HEADING = /^##\s+\[([^\]]+)\]\s*(?:[—–-]\s*(.+?)\s*)?$/
// "### Added"
const SECTION_HEADING = /^###\s+(.+?)\s*$/;
const SECTION_HEADING = /^###\s+(.+?)\s*$/
// "- bullet text"
const BULLET = /^[-*]\s+(.*)$/;
const BULLET = /^[-*]\s+(.*)$/
// Leading "**Title**" optionally followed by an em dash, used as the item's lead-in.
// (Item text is whitespace-collapsed before matching, so no dotAll flag needed.)
const LEAD = /^\*\*(.+?)\*\*\s*(?:[—–-]\s*)?(.*)$/;
const LEAD = /^\*\*(.+?)\*\*\s*(?:[—–-]\s*)?(.*)$/
function toItem(text: string): ChangelogItem {
const collapsed = text.replace(/\s+/g, " ").trim();
const match = collapsed.match(LEAD);
const collapsed = text.replace(/\s+/g, ' ').trim()
const match = collapsed.match(LEAD)
if (match) {
return { lead: match[1].trim(), body: match[2].trim() };
return { lead: match[1].trim(), body: match[2].trim() }
}
return { lead: null, body: collapsed };
return { lead: null, body: collapsed }
}
function parseChangelog(raw: string): ChangelogEntry[] {
const lines = raw.split(/\r?\n/);
const entries: ChangelogEntry[] = [];
const lines = raw.split(/\r?\n/)
const entries: ChangelogEntry[] = []
let entry: ChangelogEntry | null = null;
let section: ChangelogSection | null = null;
let buffer: string[] = [];
let entry: ChangelogEntry | null = null
let section: ChangelogSection | null = null
let buffer: string[] = []
const flushItem = () => {
if (section && buffer.length > 0) {
section.items.push(toItem(buffer.join(" ")));
section.items.push(toItem(buffer.join(' ')))
}
buffer = []
}
buffer = [];
};
for (const line of lines) {
const versionMatch = line.match(VERSION_HEADING);
const versionMatch = line.match(VERSION_HEADING)
if (versionMatch) {
flushItem();
section = null;
entry = { version: versionMatch[1].trim(), date: versionMatch[2]?.trim() ?? null, sections: [] };
entries.push(entry);
continue;
flushItem()
section = null
entry = {
version: versionMatch[1].trim(),
date: versionMatch[2]?.trim() ?? null,
sections: [],
}
entries.push(entry)
continue
}
// Stop collecting once we leave the changelog body (e.g. link-reference defs at EOF).
if (!entry) continue;
if (!entry) continue
const sectionMatch = line.match(SECTION_HEADING);
const sectionMatch = line.match(SECTION_HEADING)
if (sectionMatch) {
flushItem();
section = { title: sectionMatch[1].trim(), items: [] };
entry.sections.push(section);
continue;
flushItem()
section = { title: sectionMatch[1].trim(), items: [] }
entry.sections.push(section)
continue
}
const bulletMatch = line.match(BULLET);
const bulletMatch = line.match(BULLET)
if (bulletMatch) {
flushItem();
buffer.push(bulletMatch[1]);
continue;
flushItem()
buffer.push(bulletMatch[1])
continue
}
if (line.trim() === "") {
if (line.trim() === '') {
// A blank line ends a (possibly wrapped) multi-line bullet.
flushItem();
continue;
flushItem()
continue
}
// Indented continuation of the current wrapped bullet.
if (buffer.length > 0) {
buffer.push(line.trim());
buffer.push(line.trim())
}
}
flushItem();
return entries.map((e) => ({ ...e, sections: e.sections.filter((s) => s.items.length > 0) }));
flushItem()
return entries.map((e) => ({ ...e, sections: e.sections.filter((s) => s.items.length > 0) }))
}
const ENTRIES = parseChangelog(changelogRaw);
const ENTRIES = parseChangelog(changelogRaw)
export function getChangelogForVersion(version: string | null | undefined): ChangelogEntry | null {
if (!version) return null;
if (!version) return null
// Strip leading "v" and any build suffix (e.g. "-ui", "-dev", "-beta.1") so
// dev/UI-lab builds still resolve to the correct changelog entry.
const normalized = version.replace(/^v/, "").replace(/-[a-z].*/i, "");
const normalized = version.replace(/^v/, '').replace(/-[a-z].*/i, '')
// Never surface the in-progress [Unreleased] section to users.
if (normalized.toLowerCase() === "unreleased") return null;
return ENTRIES.find((e) => e.version.replace(/^v/, "") === normalized) ?? null;
if (normalized.toLowerCase() === 'unreleased') return null
return ENTRIES.find((e) => e.version.replace(/^v/, '') === normalized) ?? null
}
+70
View File
@@ -0,0 +1,70 @@
import { useState } from 'react'
import { useGalleryStore } from '../store'
/**
* Album list plus a create-new-album form. The host decides what picking
* means — the bulk bar adds the current selection; a newly created album is
* picked immediately.
*/
export function AlbumPicker({ onPick }: { onPick: (albumId: number) => Promise<void> | void }) {
const albums = useGalleryStore((state) => state.albums)
const createAlbum = useGalleryStore((state) => state.createAlbum)
const [creating, setCreating] = useState(false)
const [newAlbumName, setNewAlbumName] = useState('')
const handleCreate = async () => {
const name = newAlbumName.trim()
if (!name || creating) return
setCreating(true)
try {
const album = await createAlbum(name)
setNewAlbumName('')
await onPick(album.id)
} finally {
setCreating(false)
}
}
return (
<>
<div className="max-h-48 overflow-y-auto">
{albums.length === 0 ? (
<p className="px-2 py-2 text-[11px] text-gray-600">No albums yet create one below.</p>
) : (
albums.map((album) => (
<button
key={album.id}
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs text-gray-300 transition-colors hover:bg-white/5 hover:text-white"
onClick={() => void onPick(album.id)}
>
<span className="truncate">{album.name}</span>
<span className="shrink-0 text-[10px] text-gray-600">{album.image_count}</span>
</button>
))
)}
</div>
<form
className="mt-1 flex gap-1 border-t border-white/[0.06] pt-2"
onSubmit={(event) => {
event.preventDefault()
void handleCreate()
}}
>
<input
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white placeholder-gray-600 focus:border-white/20 focus:outline-none"
placeholder="New album…"
value={newAlbumName}
onChange={(event) => setNewAlbumName(event.target.value)}
disabled={creating}
/>
<button
type="submit"
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50"
disabled={creating || !newAlbumName.trim()}
>
Add
</button>
</form>
</>
)
}
+108 -567
View File
@@ -1,614 +1,155 @@
import { useEffect, useMemo, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { useGalleryStore, WorkerKey } from "../store";
import { Tooltip } from "./Tooltip";
const WORKER_FOR_STAGE: Record<string, WorkerKey> = {
Thumbnails: "thumbnail",
Metadata: "metadata",
Embeddings: "embedding",
Tags: "tagging",
};
interface TaskStage {
label: string;
detail: string;
progress: number | null; // 0100, or null for indeterminate
failed: boolean;
}
interface Task {
id: number;
name: string;
stages: TaskStage[];
isActive: boolean;
hasFailedEmbeddings: boolean;
hasFailedTagging: boolean;
hasFailedCaptions: boolean;
pendingMediaWork: number;
embeddingProcessed: number;
embeddingTotal: number;
currentFile: string | null;
snapshot: string;
}
interface FailedWorkerItem {
image_id: number;
filename: string;
path: string;
error: string | null;
}
function FailedWorkerItemRow({ item }: { item: FailedWorkerItem }) {
return (
<div className="flex min-w-0 items-start gap-1.5">
<svg className="mt-px h-2.5 w-2.5 shrink-0 text-amber-500 light-theme:text-amber-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<div className="min-w-0 flex-1">
<p className="truncate text-[10px] font-medium text-amber-400/80 light-theme:text-amber-700">{item.filename}</p>
{item.error && (
<p className="truncate text-[9px] text-gray-600">{item.error}</p>
)}
</div>
<Tooltip label="Reveal in Explorer" anchorToCursor>
<button
className="shrink-0 text-gray-700 transition-colors hover:text-gray-300 light-theme:text-gray-600 light-theme:hover:text-gray-100"
onClick={() => void revealItemInDir(item.path)}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
</button>
</Tooltip>
</div>
);
}
import { useEffect, useMemo, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { useGalleryStore, type WorkerKey } from '../store'
import { BackgroundTaskSummary } from './backgroundTasks/BackgroundTaskSummary'
import { ExpandedTaskPanel } from './backgroundTasks/ExpandedTaskPanel'
import {
buildDuplicateScanTask,
buildFolderTasks,
taskHasTerminalFailure,
taskProgress,
} from './backgroundTasks/taskModel'
import type { BackgroundTask, FailedWorkerItem } from './backgroundTasks/types'
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 [failedEmbeddingItems, setFailedEmbeddingItems] = useState<Record<number, FailedWorkerItem[]>>({});
const [failedTaggingItems, setFailedTaggingItems] = useState<Record<number, FailedWorkerItem[]>>({});
const workerPaused = useGalleryStore((state) => state.workerPaused);
const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates);
const setWorkerPaused = useGalleryStore((state) => state.setWorkerPaused);
const 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 workerPaused = useGalleryStore((state) => state.workerPaused)
const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates)
const setWorkerPaused = useGalleryStore((state) => state.setWorkerPaused)
const [expanded, setExpanded] = useState(false)
const [dismissed, setDismissed] = useState<Record<number, string>>({})
const [failedEmbeddingItems, setFailedEmbeddingItems] = useState<
Record<number, FailedWorkerItem[]>
>({})
const [failedTaggingItems, setFailedTaggingItems] = useState<Record<number, FailedWorkerItem[]>>(
{}
)
useEffect(() => {
void loadWorkerStates();
}, [folders, loadWorkerStates]);
void loadWorkerStates()
}, [folders, loadWorkerStates])
// 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]),
Object.entries(mediaJobProgress).map(([id, progress]) => [
id,
progress?.embedding_failed ?? 0,
])
),
[mediaJobProgress],
);
[mediaJobProgress]
)
const failedTaggingCounts = useMemo(
() =>
Object.fromEntries(
Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.tagging_failed ?? 0]),
Object.entries(mediaJobProgress).map(([id, progress]) => [
id,
progress?.tagging_failed ?? 0,
])
),
[mediaJobProgress],
);
[mediaJobProgress]
)
useEffect(() => {
if (!expanded) return;
if (!expanded) return
for (const [folderId, count] of Object.entries(failedEmbeddingCounts)) {
if (count > 0) {
invoke<FailedWorkerItem[]>("get_failed_embedding_images", {
invoke<FailedWorkerItem[]>('get_failed_embedding_images', {
folderId: Number(folderId),
})
.then((items) => setFailedEmbeddingItems((prev) => ({ ...prev, [folderId]: items })))
.catch(() => undefined);
.catch(() => undefined)
}
}
for (const [folderId, count] of Object.entries(failedTaggingCounts)) {
if (count > 0) {
invoke<FailedWorkerItem[]>("get_failed_tagging_images", {
invoke<FailedWorkerItem[]>('get_failed_tagging_images', {
folderId: Number(folderId),
})
.then((items) => setFailedTaggingItems((prev) => ({ ...prev, [folderId]: items })))
.catch(() => undefined);
.catch(() => undefined)
}
}
}, [expanded, failedEmbeddingCounts, failedTaggingCounts]);
}, [expanded, failedEmbeddingCounts, failedTaggingCounts])
const isWorkerPaused = (folderId: number, worker: WorkerKey) => {
return workerPaused[folderId]?.[worker] ?? false;
};
const folderTasks = useMemo(
() =>
buildFolderTasks({
dismissed,
folders,
indexingProgress,
mediaJobProgress,
workerPaused,
}),
[dismissed, folders, indexingProgress, mediaJobProgress, workerPaused]
)
const duplicateScanTask = useMemo(
() => buildDuplicateScanTask(duplicateScanning, duplicateScanProgress),
[duplicateScanning, duplicateScanProgress]
)
const allTasks = duplicateScanTask ? [duplicateScanTask, ...folderTasks] : folderTasks
if (allTasks.length === 0) return null
const isWorkerPaused = (folderId: number, worker: WorkerKey) =>
workerPaused[folderId]?.[worker] ?? false
const toggleWorker = (folderId: number, worker: WorkerKey) => {
setWorkerPaused(folderId, worker, !isWorkerPaused(folderId, worker));
};
const dismissTask = (id: number, snapshot: string) => {
if (id < 0) return; // system tasks (duplicate scan) cannot be dismissed
setDismissed((prev) => ({ ...prev, [id]: snapshot }));
setExpanded(false);
};
const tasks = useMemo<Task[]>(() => {
return folders
.map((folder): Task | null => {
const index = indexingProgress[folder.id];
const jobs = mediaJobProgress[folder.id];
const thumbnailPending = jobs?.thumbnail_pending ?? 0;
const metadataPending = jobs?.metadata_pending ?? 0;
const embeddingPending = jobs?.embedding_pending ?? 0;
const embeddingReady = jobs?.embedding_ready ?? 0;
const embeddingFailed = jobs?.embedding_failed ?? 0;
const taggingPending = jobs?.tagging_pending ?? 0;
const taggingReady = jobs?.tagging_ready ?? 0;
const taggingFailed = jobs?.tagging_failed ?? 0;
const captionPending = jobs?.caption_pending ?? 0;
const captionReady = jobs?.caption_ready ?? 0;
const captionFailed = jobs?.caption_failed ?? 0;
// A folder is "actively processing" when something is genuinely moving:
// an in-progress scan, or pending work on a worker that isn't paused.
// Captions have no per-folder pause toggle, so any caption work counts.
const paused = workerPaused[folder.id];
const isActive =
(!!index && !index.done) ||
(thumbnailPending > 0 && !(paused?.thumbnail ?? false)) ||
(metadataPending > 0 && !(paused?.metadata ?? false)) ||
(embeddingPending > 0 && !(paused?.embedding ?? false)) ||
(taggingPending > 0 && !(paused?.tagging ?? false)) ||
captionPending > 0;
const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending + taggingPending + captionPending;
const embeddingProcessed = embeddingReady + embeddingFailed;
const embeddingTotal = embeddingProcessed + embeddingPending;
const taggingProcessed = taggingReady + taggingFailed;
const taggingTotal = taggingProcessed + taggingPending;
const captionProcessed = captionReady + captionFailed;
const captionTotal = captionProcessed + captionPending;
const hasFailedEmbeddings = embeddingFailed > 0;
const hasFailedTagging = taggingFailed > 0;
const hasFailedCaptions = captionFailed > 0;
if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings && !hasFailedTagging && !hasFailedCaptions) return null;
const stages: TaskStage[] = [];
if (index && !index.done) {
const pct = index.total > 0 ? (index.indexed / index.total) * 100 : 0;
stages.push({
label: "Scanning",
detail: `${index.indexed.toLocaleString()} / ${index.total.toLocaleString()}`,
progress: pct,
failed: false,
});
setWorkerPaused(folderId, worker, !isWorkerPaused(folderId, worker))
}
if (thumbnailPending > 0) {
stages.push({
label: "Thumbnails",
detail: thumbnailPending.toLocaleString(),
progress: null,
failed: false,
});
const dismissTask = (task: BackgroundTask) => {
if (task.id < 0) return
setDismissed((prev) => ({ ...prev, [task.id]: task.snapshot }))
setExpanded(false)
}
if (metadataPending > 0) {
stages.push({
label: "Metadata",
detail: metadataPending.toLocaleString(),
progress: null,
failed: false,
});
const retryTask = (task: BackgroundTask) => {
if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id)
if (task.hasFailedTagging) void queueTaggingJobs(task.id)
}
if (embeddingPending > 0) {
const pct = embeddingTotal > 0 ? (embeddingProcessed / embeddingTotal) * 100 : 0;
stages.push({
label: "Embeddings",
detail: `${embeddingProcessed.toLocaleString()} / ${embeddingTotal.toLocaleString()}`,
progress: pct,
failed: false,
});
}
if (taggingPending > 0) {
const pct = taggingTotal > 0 ? (taggingProcessed / taggingTotal) * 100 : 0;
stages.push({
label: "Tags",
detail: `${taggingProcessed.toLocaleString()} / ${taggingTotal.toLocaleString()}`,
progress: pct,
failed: false,
});
}
if (captionPending > 0) {
const pct = captionTotal > 0 ? (captionProcessed / captionTotal) * 100 : 0;
stages.push({
label: "Captions",
detail: `${captionProcessed.toLocaleString()} / ${captionTotal.toLocaleString()}`,
progress: pct,
failed: false,
});
}
if (hasFailedEmbeddings && pendingMediaWork === 0) {
stages.push({
label: "Failed",
detail: `${embeddingFailed.toLocaleString()} embeddings`,
progress: null,
failed: true,
});
}
if (hasFailedTagging && pendingMediaWork === 0) {
stages.push({
label: "Failed",
detail: `${taggingFailed.toLocaleString()} tags`,
progress: null,
failed: true,
});
}
if (hasFailedCaptions && pendingMediaWork === 0) {
stages.push({
label: "Failed",
detail: `${captionFailed.toLocaleString()} captions`,
progress: null,
failed: true,
});
}
const snapshot = `${pendingMediaWork}:${embeddingFailed}:${taggingFailed}:${captionFailed}`;
return {
id: folder.id,
name: folder.name,
stages,
isActive,
hasFailedEmbeddings,
hasFailedTagging,
hasFailedCaptions,
pendingMediaWork,
embeddingProcessed,
embeddingTotal,
currentFile: index && !index.done ? (index.current_file || null) : null,
snapshot,
};
})
.filter((t): t is Task => t !== null)
.filter((t) => dismissed[t.id] !== t.snapshot)
// Surface actively-processing folders first so a paused folder never holds
// the primary (collapsed) slot while another is genuinely working. Array
// sort is stable, so folders within each group keep their existing order.
.sort((a, b) => Number(b.isActive) - Number(a.isActive));
}, [folders, indexingProgress, mediaJobProgress, workerPaused, dismissed]);
// Synthetic task for duplicate scanning — negative id so dismiss/retry are suppressed
const duplicateScanTask: Task | null = duplicateScanning ? {
id: -1,
name: "Duplicate Scan",
stages: [{
label: duplicateScanProgress?.phase === "checking"
? "Checking"
: duplicateScanProgress?.phase === "confirming"
? "Confirming"
: "Hashing",
detail: duplicateScanProgress
? `${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ""}`
: "Starting…",
progress: duplicateScanProgress && duplicateScanProgress.total > 0
? (duplicateScanProgress.processed / duplicateScanProgress.total) * 100
: null,
failed: false,
}],
isActive: true,
hasFailedEmbeddings: false,
hasFailedTagging: false,
hasFailedCaptions: false,
pendingMediaWork: 1,
embeddingProcessed: 0,
embeddingTotal: 0,
currentFile: null,
snapshot: "",
} : null;
const allTasks = duplicateScanTask ? [duplicateScanTask, ...tasks] : tasks;
if (allTasks.length === 0) return null;
const primary = allTasks[0];
const extraCount = allTasks.length - 1;
const hasFailed = tasks.some((t) => (t.hasFailedEmbeddings || t.hasFailedTagging || t.hasFailedCaptions) && t.pendingMediaWork === 0);
// Best progress bar value: use embedding progress if available (most informative),
// otherwise tagging progress, otherwise fall back to scanning progress, otherwise indeterminate.
const embeddingStage = primary.stages.find((s) => s.label === "Embeddings");
const taggingStage = primary.stages.find((s) => s.label === "Tags");
const scanningStage = primary.stages.find((s) => s.label === "Scanning");
const duplicateStage = primary.id === -1 ? primary.stages[0] : null;
const barProgress = embeddingStage?.progress ?? taggingStage?.progress ?? scanningStage?.progress ?? duplicateStage?.progress ?? null;
const primary = allTasks[0]
const hasFailed = folderTasks.some(taskHasTerminalFailure)
const barProgress = taskProgress(primary)
return (
<div className="shrink-0 border-b border-white/[0.06]">
{/* Slim bar */}
<div
className={`group flex items-center gap-3 px-5 h-11 cursor-pointer select-none transition-colors ${
expanded ? "bg-white/[0.03]" : "hover:bg-white/[0.02]"
}`}
onClick={() => setExpanded((v) => !v)}
>
{/* Pulse dot */}
<div className="relative shrink-0">
<div className={`h-1.5 w-1.5 rounded-full ${hasFailed ? "bg-amber-400" : "bg-blue-400"}`} />
<div className={`absolute inset-0 h-1.5 w-1.5 rounded-full animate-ping opacity-60 ${hasFailed ? "bg-amber-400" : "bg-blue-400"}`} />
</div>
{/* Folder name */}
<span className="text-[13px] font-medium text-white/60 shrink-0">{primary.name}</span>
{/* Stage tags — all active stages visible simultaneously */}
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden">
{primary.stages.map((stage) => {
const workerKey = WORKER_FOR_STAGE[stage.label];
const isPaused = workerKey ? isWorkerPaused(primary.id, workerKey) : false;
return (
<span
key={stage.label}
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${
stage.failed
? "bg-amber-500/10 text-amber-400 light-theme:border light-theme:border-amber-500/40 light-theme:bg-amber-100 light-theme:text-amber-700"
: isPaused
? "bg-white/4 text-gray-600"
: "bg-white/5 text-gray-400"
}`}
>
<span>{stage.label}</span>
<span className={`tabular-nums ${stage.failed ? "text-amber-500 light-theme:text-amber-700" : isPaused ? "text-gray-700" : "text-gray-600"}`}>
{stage.detail}
</span>
{workerKey && (
<Tooltip label={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`} anchorToCursor>
<button
className="ml-0.5 opacity-0 group-hover:opacity-100 hover:text-white transition-opacity"
onClick={(e) => { e.stopPropagation(); toggleWorker(primary.id, workerKey); }}
>
{isPaused ? (
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
) : (
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
)}
</button>
</Tooltip>
)}
</span>
);
})}
</div>
{/* Progress bar — embedding or scanning progress, pulsing if indeterminate */}
<div className="w-24 h-px bg-white/8 rounded-full overflow-hidden shrink-0">
<div
className={`h-full rounded-full transition-all duration-500 ${
hasFailed
? "bg-amber-400/60"
: barProgress === null
? "bg-blue-500/40 animate-pulse w-full"
: "bg-blue-500"
}`}
style={barProgress !== null ? { width: `${barProgress}%` } : undefined}
<BackgroundTaskSummary
expanded={expanded}
extraCount={allTasks.length - 1}
hasFailed={hasFailed}
isWorkerPaused={isWorkerPaused}
onDismiss={dismissTask}
onLocate={showFailedTagging}
onRetry={retryTask}
onToggleExpanded={() => setExpanded((value) => !value)}
onToggleWorker={toggleWorker}
primary={primary}
progress={barProgress}
taskCount={allTasks.length}
/>
</div>
{/* Extra folders badge */}
{extraCount > 0 && (
<span className="rounded-full bg-white/8 px-2 py-0.5 text-[10px] text-gray-500 shrink-0">
+{extraCount}
</span>
)}
{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) */}
{allTasks.length > 1 && (
<svg
className={`h-3.5 w-3.5 text-gray-600 transition-transform duration-200 shrink-0 ${expanded ? "rotate-180" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)}
{/* Dismiss — hidden for system tasks like duplicate scan */}
{primary.id >= 0 && (
<Tooltip label="Dismiss" anchorToCursor>
<button
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
onClick={(e) => { e.stopPropagation(); dismissTask(primary.id, primary.snapshot); }}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Tooltip>
)}
</div>
{/* Expanded panel — one row per folder */}
{expanded && (
<div className="border-t border-white/[0.06] bg-white/[0.02] px-5 py-3 space-y-3">
{allTasks.map((task) => {
const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings");
const taskTaggingStage = task.stages.find((s) => s.label === "Tags");
const taskScanningStage = task.stages.find((s) => s.label === "Scanning");
const taskDuplicateStage = task.id === -1 ? task.stages[0] : null;
const taskBarProgress = taskEmbeddingStage?.progress ?? taskTaggingStage?.progress ?? taskScanningStage?.progress ?? taskDuplicateStage?.progress ?? null;
const taskHasFailed = (task.hasFailedEmbeddings || task.hasFailedTagging || task.hasFailedCaptions) && task.pendingMediaWork === 0;
return (
<div key={task.id}>
<div className="flex items-center gap-3">
<span className="text-[12px] text-white/50 w-28 truncate shrink-0">{task.name}</span>
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden">
{task.stages.map((stage) => {
const workerKey = WORKER_FOR_STAGE[stage.label];
const isPaused = workerKey ? isWorkerPaused(task.id, workerKey) : false;
return (
<span
key={stage.label}
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${
stage.failed
? "bg-amber-500/10 text-amber-400 light-theme:border light-theme:border-amber-500/40 light-theme:bg-amber-100 light-theme:text-amber-700"
: isPaused
? "bg-white/4 text-gray-600"
: "bg-white/5 text-gray-500"
}`}
>
{isPaused && (
<svg className="h-2 w-2 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
)}
<span>{stage.label}</span>
<span className={`tabular-nums ${stage.failed ? "text-amber-500 light-theme:text-amber-700" : "text-gray-600"}`}>
{stage.detail}
</span>
{workerKey && (
<Tooltip label={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`} anchorToCursor>
<button
className="ml-0.5 text-gray-600 hover:text-white transition-colors"
onClick={() => toggleWorker(task.id, workerKey)}
>
{isPaused ? (
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
) : (
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
)}
</button>
</Tooltip>
)}
</span>
);
})}
</div>
<div className="w-20 h-px bg-white/8 rounded-full overflow-hidden shrink-0">
<div
className={`h-full rounded-full transition-all duration-500 ${
taskHasFailed
? "bg-amber-400/60"
: taskBarProgress === null
? "bg-blue-500/40 animate-pulse w-full"
: "bg-blue-500"
}`}
style={taskBarProgress !== null ? { width: `${taskBarProgress}%` } : undefined}
{expanded ? (
<ExpandedTaskPanel
failedEmbeddingItems={failedEmbeddingItems}
failedTaggingItems={failedTaggingItems}
isWorkerPaused={isWorkerPaused}
onDismiss={dismissTask}
onLocate={showFailedTagging}
onRetry={retryTask}
onToggleWorker={toggleWorker}
tasks={allTasks}
/>
</div>
{taskHasFailed && (
<div className="flex shrink-0 items-center gap-1.5">
{task.hasFailedTagging ? (
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
onClick={() => showFailedTagging(task.id)}
>
Locate
</button>
) : null}
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
onClick={() => {
if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id);
if (task.hasFailedTagging) void queueTaggingJobs(task.id);
}}
>
Retry
</button>
</div>
)}
{task.id >= 0 && (
<Tooltip label="Dismiss" anchorToCursor>
<button
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
onClick={() => dismissTask(task.id, task.snapshot)}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Tooltip>
)}
</div>
{task.currentFile && (
<p className="text-[10px] text-gray-600 truncate mt-1 pl-[calc(7rem+0.75rem)]">
{task.currentFile}
</p>
)}
{/* 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">
{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>
)}
</div>
);
})}
</div>
)}
</div>
);
)
}
+84 -203
View File
@@ -1,89 +1,67 @@
import { useEffect, useRef, useState } from "react";
import { useGalleryStore } from "../store";
import { BulkTagPopover } from "./bulk/BulkTagPopover";
import { Tooltip } from "./Tooltip"
type Panel = "tag" | "rating" | "album" | "delete" | null;
import { useEffect, useRef, useState } from 'react'
import { useGalleryStore } from '../store'
import { BulkAlbumPopover } from './bulk/BulkAlbumPopover'
import { BulkDeleteConfirm } from './bulk/BulkDeleteConfirm'
import { BulkRatingPopover } from './bulk/BulkRatingPopover'
import { BulkSelectionSummary } from './bulk/BulkSelectionSummary'
import { BulkTagPopover } from './bulk/BulkTagPopover'
import { type BulkPanel } from './bulk/types'
import { useDismissable } from './menu'
import { Tooltip } from './Tooltip'
import { CloseIcon } from './icons'
export function BulkActionBar() {
const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size);
const selectedIds = useGalleryStore((state) => state.gallerySelectedIds);
const clearGallerySelection = useGalleryStore((state) => state.clearGallerySelection);
const selectAllGallery = useGalleryStore((state) => state.selectAllGallery);
const loadedCount = useGalleryStore((state) => state.loadedCount);
const totalImages = useGalleryStore((state) => state.totalImages);
const bulkSetFavorite = useGalleryStore((state) => state.bulkSetFavorite);
const bulkSetRating = useGalleryStore((state) => state.bulkSetRating);
const bulkDeleteSelected = useGalleryStore((state) => state.bulkDeleteSelected);
const activeView = useGalleryStore((state) => state.activeView);
const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId);
const albums = useGalleryStore((state) => state.albums);
const addToAlbum = useGalleryStore((state) => state.addToAlbum);
const removeFromAlbum = useGalleryStore((state) => state.removeFromAlbum);
const createAlbum = useGalleryStore((state) => state.createAlbum);
const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size)
const selectedIds = useGalleryStore((state) => state.gallerySelectedIds)
const clearGallerySelection = useGalleryStore((state) => state.clearGallerySelection)
const selectAllGallery = useGalleryStore((state) => state.selectAllGallery)
const loadedCount = useGalleryStore((state) => state.loadedCount)
const totalImages = useGalleryStore((state) => state.totalImages)
const bulkSetFavorite = useGalleryStore((state) => state.bulkSetFavorite)
const bulkSetRating = useGalleryStore((state) => state.bulkSetRating)
const bulkDeleteSelected = useGalleryStore((state) => state.bulkDeleteSelected)
const activeView = useGalleryStore((state) => state.activeView)
const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId)
const addToAlbum = useGalleryStore((state) => state.addToAlbum)
const removeFromAlbum = useGalleryStore((state) => state.removeFromAlbum)
const [panel, setPanel] = useState<Panel>(null);
const [deleting, setDeleting] = useState(false);
const [creatingAlbum, setCreatingAlbum] = useState(false);
const [newAlbumName, setNewAlbumName] = useState("");
const barRef = useRef<HTMLDivElement>(null);
const [panel, setPanel] = useState<BulkPanel>(null)
const [deleting, setDeleting] = useState(false)
const barRef = useRef<HTMLDivElement>(null)
// Close any open popover when clicking outside the bar.
useEffect(() => {
const onPointerDown = (event: PointerEvent) => {
if (barRef.current?.contains(event.target as Node)) return;
setPanel(null);
};
window.addEventListener("pointerdown", onPointerDown);
return () => window.removeEventListener("pointerdown", onPointerDown);
}, []);
// Close any open popover when clicking outside the bar or pressing Escape.
useDismissable(barRef, () => setPanel(null), panel !== null)
// Reset transient UI whenever the selection empties.
useEffect(() => {
if (selectedCount === 0) {
setPanel(null);
setNewAlbumName("");
}
}, [selectedCount]);
if (selectedCount === 0) setPanel(null)
}, [selectedCount])
if (selectedCount === 0) return null;
if (selectedCount === 0) return null
const ids = Array.from(selectedIds);
const inAlbumView = activeView === "album" && selectedAlbumId !== null;
const togglePanel = (next: Exclude<Panel, null>) => setPanel((current) => (current === next ? null : next));
const ids = Array.from(selectedIds)
const inAlbumView = activeView === 'album' && selectedAlbumId !== null
const togglePanel = (next: Exclude<BulkPanel, null>) =>
setPanel((current) => (current === next ? null : next))
const handleDelete = async () => {
setDeleting(true);
setDeleting(true)
try {
await bulkDeleteSelected();
await bulkDeleteSelected()
} finally {
setDeleting(false);
setPanel(null);
setDeleting(false)
setPanel(null)
}
}
};
const handlePickAlbum = async (albumId: number) => {
await addToAlbum(albumId, ids);
setPanel(null);
};
const handleCreateAlbum = async () => {
const name = newAlbumName.trim();
if (!name || creatingAlbum) return;
setCreatingAlbum(true);
try {
const album = await createAlbum(name);
await addToAlbum(album.id, ids);
setNewAlbumName("");
setPanel(null);
} finally {
setCreatingAlbum(false);
await addToAlbum(albumId, ids)
setPanel(null)
}
};
const btn = "rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors";
const btnIdle = `${btn} text-gray-300 hover:bg-white/10 hover:text-white`;
const btnActive = `${btn} bg-white/10 text-white`;
const btn = 'rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors'
const btnIdle = `${btn} text-gray-300 hover:bg-white/10 hover:text-white`
const btnActive = `${btn} bg-white/10 text-white`
return (
<div
@@ -91,67 +69,34 @@ export function BulkActionBar() {
className="pointer-events-auto absolute bottom-6 left-1/2 z-30 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-white/10 bg-gray-950/95 px-2 py-1.5 shadow-2xl shadow-black/50 backdrop-blur"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center gap-2 px-1.5">
<span className="text-xs font-medium text-white">{selectedCount} selected</span>
{loadedCount < totalImages || loadedCount > selectedCount ? (
<Tooltip label={loadedCount < totalImages ? "Selects loaded items only" : "Select all loaded"}>
<button
className="text-[11px] text-gray-500 transition-colors hover:text-gray-300"
onClick={selectAllGallery}
>
Select all{loadedCount < totalImages ? " loaded" : ""}
</button>
</Tooltip>
) : null}
</div>
<BulkSelectionSummary
loadedCount={loadedCount}
selectedCount={selectedCount}
totalImages={totalImages}
onSelectAll={selectAllGallery}
/>
<div className="h-5 w-px bg-white/10" />
<div className="relative">
<button className={panel === "tag" ? btnActive : btnIdle} onClick={() => togglePanel("tag")}>
<button
className={panel === 'tag' ? btnActive : btnIdle}
onClick={() => togglePanel('tag')}
>
Tag
</button>
{panel === "tag" ? <BulkTagPopover onClose={() => setPanel(null)} /> : null}
{panel === 'tag' ? <BulkTagPopover onClose={() => setPanel(null)} /> : null}
</div>
<div className="relative">
<button className={panel === "rating" ? btnActive : btnIdle} onClick={() => togglePanel("rating")}>
<button
className={panel === 'rating' ? btnActive : btnIdle}
onClick={() => togglePanel('rating')}
>
Rating
</button>
{panel === "rating" ? (
<div
data-bulk-popover
className="absolute bottom-full left-1/2 mb-2 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-white/10 bg-gray-950/98 p-2 shadow-2xl backdrop-blur"
>
{Array.from({ length: 5 }, (_, index) => {
const rating = index + 1;
return (
<Tooltip label={`Set ${rating} star${rating === 1 ? "" : "s"}`}>
<button
key={rating}
className="rounded-md p-1 text-white/25 transition-colors hover:bg-white/5 hover:text-amber-300"
onClick={async () => {
await bulkSetRating(rating);
setPanel(null);
}}
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</button>
</Tooltip>
);
})}
<button
className="ml-1 rounded-md border border-white/10 px-2 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/5 hover:text-white"
onClick={async () => {
await bulkSetRating(0);
setPanel(null);
}}
>
Clear
</button>
</div>
{panel === 'rating' ? (
<BulkRatingPopover onSetRating={bulkSetRating} onClose={() => setPanel(null)} />
) : null}
</div>
<Tooltip label="Mark as favorite" followCursor>
@@ -160,54 +105,13 @@ export function BulkActionBar() {
</button>
</Tooltip>
<div className="relative">
<button className={panel === "album" ? btnActive : btnIdle} onClick={() => togglePanel("album")}>
<button
className={panel === 'album' ? btnActive : btnIdle}
onClick={() => togglePanel('album')}
>
Add to album
</button>
{panel === "album" ? (
<div
data-bulk-popover
className="absolute bottom-full left-1/2 mb-2 w-60 -translate-x-1/2 rounded-xl border border-white/10 bg-gray-950/98 p-2 shadow-2xl backdrop-blur"
>
<div className="max-h-48 overflow-y-auto">
{albums.length === 0 ? (
<p className="px-2 py-2 text-[11px] text-gray-600">No albums yet create one below.</p>
) : (
albums.map((album) => (
<button
key={album.id}
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs text-gray-300 transition-colors hover:bg-white/5 hover:text-white"
onClick={() => void handlePickAlbum(album.id)}
>
<span className="truncate">{album.name}</span>
<span className="shrink-0 text-[10px] text-gray-600">{album.image_count}</span>
</button>
))
)}
</div>
<form
className="mt-1 flex gap-1 border-t border-white/[0.06] pt-2"
onSubmit={(event) => {
event.preventDefault();
void handleCreateAlbum();
}}
>
<input
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white placeholder-gray-600 focus:border-white/20 focus:outline-none"
placeholder="New album…"
value={newAlbumName}
onChange={(event) => setNewAlbumName(event.target.value)}
disabled={creatingAlbum}
/>
<button
type="submit"
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50"
disabled={creatingAlbum || !newAlbumName.trim()}
>
Add
</button>
</form>
</div>
) : null}
{panel === 'album' ? <BulkAlbumPopover onPick={handlePickAlbum} /> : null}
</div>
{inAlbumView ? (
@@ -224,45 +128,24 @@ export function BulkActionBar() {
<div className="relative">
<Tooltip label="Delete files from disk" followCursor>
<button
className={panel === "delete" ? `${btn} bg-red-500/15 text-red-300` : `${btn} text-gray-300 hover:bg-red-500/10 hover:text-red-300`}
onClick={() => togglePanel("delete")}
className={
panel === 'delete'
? `${btn} bg-red-500/15 text-red-300`
: `${btn} text-gray-300 hover:bg-red-500/10 hover:text-red-300`
}
onClick={() => togglePanel('delete')}
disabled={deleting}
>
{deleting ? "Deleting…" : "Delete"}
{deleting ? 'Deleting…' : 'Delete'}
</button>
</Tooltip>
{panel === "delete" ? (
<div
data-bulk-popover
className="absolute bottom-full left-1/2 mb-2 w-64 -translate-x-1/2 rounded-xl border border-red-500/30 bg-gray-950/98 p-3 shadow-2xl backdrop-blur"
>
<div className="mb-1 flex items-center gap-1.5 text-red-300">
<svg className="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<p className="text-xs font-semibold">Delete from disk</p>
</div>
<p className="mb-2.5 text-[11px] leading-relaxed text-gray-400">
Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer.
This removes the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone.
</p>
<div className="flex justify-end gap-1.5">
<button
className="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setPanel(null)}
>
Cancel
</button>
<button
className="rounded-md bg-red-500/20 px-2.5 py-1 text-[11px] font-medium text-red-300 transition-colors hover:bg-red-500/30 hover:text-red-200 disabled:opacity-50"
onClick={() => void handleDelete()}
disabled={deleting}
>
{deleting ? "Deleting…" : `Delete ${selectedCount} from disk`}
</button>
</div>
</div>
{panel === 'delete' ? (
<BulkDeleteConfirm
deleting={deleting}
selectedCount={selectedCount}
onCancel={() => setPanel(null)}
onDelete={handleDelete}
/>
) : null}
</div>
<Tooltip label="Clear selection" followCursor>
@@ -270,11 +153,9 @@ export function BulkActionBar() {
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={clearGallerySelection}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<CloseIcon className="h-4 w-4" />
</button>
</Tooltip>
</div>
);
)
}
+76 -56
View File
@@ -1,75 +1,82 @@
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../store";
import { Tooltip } from "./Tooltip";
import { useRef, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { useGalleryStore } from '../store'
import { useDismissable } from './menu'
import { Tooltip } from './Tooltip'
type Rgb = [number, number, number];
type Rgb = [number, number, number]
// Representative colors for the quick-pick swatches. Each is just an RGB the
// distance filter matches against — not a hard bucket.
const SWATCHES: { name: string; rgb: Rgb }[] = [
{ name: "Red", rgb: [226, 59, 59] },
{ name: "Orange", rgb: [232, 134, 46] },
{ name: "Yellow", rgb: [242, 207, 46] },
{ name: "Green", rgb: [76, 175, 80] },
{ name: "Teal", rgb: [31, 182, 166] },
{ name: "Blue", rgb: [59, 125, 216] },
{ name: "Purple", rgb: [139, 92, 246] },
{ name: "Pink", rgb: [236, 72, 153] },
{ name: "Brown", rgb: [139, 90, 43] },
{ name: "Black", rgb: [26, 26, 26] },
{ name: "White", rgb: [245, 245, 245] },
{ name: "Gray", rgb: [154, 160, 166] },
];
{ name: 'Red', rgb: [226, 59, 59] },
{ name: 'Orange', rgb: [232, 134, 46] },
{ name: 'Yellow', rgb: [242, 207, 46] },
{ name: 'Green', rgb: [76, 175, 80] },
{ name: 'Teal', rgb: [31, 182, 166] },
{ name: 'Blue', rgb: [59, 125, 216] },
{ name: 'Purple', rgb: [139, 92, 246] },
{ name: 'Pink', rgb: [236, 72, 153] },
{ name: 'Brown', rgb: [139, 90, 43] },
{ name: 'Black', rgb: [26, 26, 26] },
{ name: 'White', rgb: [245, 245, 245] },
{ name: 'Gray', rgb: [154, 160, 166] },
]
function rgbEquals(a: Rgb | null, b: Rgb): boolean {
return a !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
return a !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2]
}
function toHex([r, g, b]: Rgb): string {
return `#${[r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("")}`;
return `#${[r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('')}`
}
function fromHex(hex: string): Rgb {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
const n = parseInt(hex.slice(1), 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
export function ColorFilter() {
const colorFilter = useGalleryStore((state) => state.colorFilter);
const setColorFilter = useGalleryStore((state) => state.setColorFilter);
const colorBackfill = useGalleryStore((state) => state.colorBackfill);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const colorFilter = useGalleryStore((state) => state.colorFilter)
const setColorFilter = useGalleryStore((state) => state.setColorFilter)
const colorBackfill = useGalleryStore((state) => state.colorBackfill)
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const isActive = colorFilter !== null;
const isCustom = isActive && !SWATCHES.some((swatch) => rgbEquals(colorFilter, swatch.rgb));
const isActive = colorFilter !== null
const isCustom = isActive && !SWATCHES.some((swatch) => rgbEquals(colorFilter, swatch.rgb))
// Collapse the panel when clicking elsewhere.
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
};
window.addEventListener("pointerdown", onPointerDown);
return () => window.removeEventListener("pointerdown", onPointerDown);
}, [open]);
// Collapse the panel when clicking elsewhere or pressing Escape.
useDismissable(ref, () => setOpen(false), open)
return (
<div ref={ref} className="relative ml-1 flex shrink-0 items-center border-l border-white/6 pl-2">
<div
ref={ref}
className="relative ml-1 flex shrink-0 items-center border-l border-white/6 pl-2"
>
{/* Trigger — a single palette icon; shows the active color as a dot when a
filter is applied so the collapsed state still communicates it. */}
<Tooltip label={isActive ? "Color filter active" : "Filter by color"} delay={400} anchorToCursor>
<Tooltip
label={isActive ? 'Color filter active' : 'Filter by color'}
delay={400}
anchorToCursor
>
<button
className={`relative flex items-center gap-1.5 rounded-lg px-2 py-1.5 transition-colors ${
open || isActive ? "bg-white/10 text-white" : "text-gray-500 hover:bg-white/5 hover:text-gray-200"
open || isActive
? 'bg-white/10 text-white'
: 'text-gray-500 hover:bg-white/5 hover:text-gray-200'
}`}
onClick={() => setOpen((value) => !value)}
aria-label="Filter by color"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6}
d="M12 3a9 9 0 100 18c.83 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.01-.23-.26-.39-.61-.39-1 0-.83.67-1.5 1.5-1.5H16a5 5 0 005-5c0-4.42-4.03-8-9-8z" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.6}
d="M12 3a9 9 0 100 18c.83 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.01-.23-.26-.39-.61-.39-1 0-.83.67-1.5 1.5-1.5H16a5 5 0 005-5c0-4.42-4.03-8-9-8z"
/>
<circle cx="7.5" cy="11.5" r="1" fill="currentColor" stroke="none" />
<circle cx="11.5" cy="7.5" r="1" fill="currentColor" stroke="none" />
<circle cx="15.5" cy="9.5" r="1" fill="currentColor" stroke="none" />
@@ -92,42 +99,49 @@ export function ColorFilter() {
initial={{ opacity: 0, y: -4, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.98 }}
transition={{ duration: 0.14, ease: "easeOut" }}
className="absolute right-0 top-full z-30 mt-2 w-max rounded-xl border border-white/10 bg-gray-950/98 p-2.5 shadow-2xl backdrop-blur light-theme:border-gray-700/50"
transition={{ duration: 0.14, ease: 'easeOut' }}
className="light-theme:border-gray-700/50 absolute top-full right-0 z-30 mt-2 w-max rounded-xl border border-white/10 bg-gray-950/98 p-2.5 shadow-2xl backdrop-blur"
>
<div className="grid grid-cols-7 gap-1.5">
{SWATCHES.map((swatch) => {
const active = rgbEquals(colorFilter, swatch.rgb);
const active = rgbEquals(colorFilter, swatch.rgb)
return (
<Tooltip label={swatch.name} followCursor>
<button
key={swatch.name}
aria-label={`Filter by ${swatch.name}`}
className={`h-5 w-5 shrink-0 rounded-full border transition-transform ${
active ? "scale-110 border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110"
active
? 'scale-110 border-white/40 ring-2 ring-white/70'
: 'border-white/15 hover:scale-110'
}`}
style={{ backgroundColor: toHex(swatch.rgb) }}
onClick={() => setColorFilter(active ? null : swatch.rgb)}
/>
</Tooltip>
);
)
})}
<Tooltip label="Custom Colour" followCursor>
{/* Custom color picker — rainbow until a custom color is chosen. */}
<label
className={`relative h-5 w-5 shrink-0 cursor-pointer overflow-hidden rounded-full border ${
isCustom ? "border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110"
isCustom
? 'border-white/40 ring-2 ring-white/70'
: 'border-white/15 hover:scale-110'
}`}
style={
isCustom
? { backgroundColor: toHex(colorFilter as Rgb) }
: { background: "conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)" }
: {
background:
'conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)',
}
}
>
<input
type="color"
className="absolute inset-0 cursor-pointer opacity-0"
value={colorFilter ? toHex(colorFilter) : "#3b7dd8"}
value={colorFilter ? toHex(colorFilter) : '#3b7dd8'}
onChange={(event) => setColorFilter(fromHex(event.target.value))}
/>
</label>
@@ -135,14 +149,20 @@ export function ColorFilter() {
</div>
{isActive || (colorBackfill && colorBackfill.total > 0) ? (
<div className="mt-2 flex items-center justify-between gap-3 border-t border-white/6 pt-2 light-theme:border-gray-700/40">
<div className="light-theme:border-gray-700/40 mt-2 flex items-center justify-between gap-3 border-t border-white/6 pt-2">
{colorBackfill && colorBackfill.total > 0 ? (
<Tooltip label="Sampling colours from existing thumbnails — colour search fills in as this runs" anchorToCursor>
<Tooltip
label="Sampling colours from existing thumbnails — colour search fills in as this runs"
anchorToCursor
>
<span className="text-[10px] text-gray-600">
sampling {colorBackfill.processed.toLocaleString()}/{colorBackfill.total.toLocaleString()}
sampling {colorBackfill.processed.toLocaleString()}/
{colorBackfill.total.toLocaleString()}
</span>
</Tooltip>
) : <span />}
) : (
<span />
)}
{isActive ? (
<Tooltip label="Clear colour filter" anchorToCursor>
<button
@@ -159,5 +179,5 @@ export function ColorFilter() {
) : null}
</AnimatePresence>
</div>
);
)
}
+83 -73
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { FolderJobProgress, useGalleryStore } from "../store";
import { useEffect, useRef, useState } from 'react'
import { FolderJobProgress, useGalleryStore } from '../store'
// Dev-only screenshot/demo helper. Injects frozen UI states that are otherwise
// too transient (or unreachable) to capture: the background-worker pipeline,
@@ -25,7 +25,7 @@ function emptyProgress(folderId: number): FolderJobProgress {
tagging_pending: 0,
tagging_ready: 0,
tagging_failed: 0,
};
}
}
// A believable multi-folder "busy pipeline" — three folders at different stages.
@@ -36,56 +36,56 @@ const BUSY_PRESETS: Partial<FolderJobProgress>[] = [
{ thumbnail_pending: 11, embedding_pending: 50, tagging_pending: 50 },
// Late stage: embeddings done, tagging running.
{ embedding_ready: 40, tagging_ready: 18, tagging_pending: 22 },
];
]
const DEMO_UPDATE_VERSION = "0.2.0";
const DEMO_UPDATE_VERSION = '0.2.0'
export function DemoPanel() {
const folders = useGalleryStore((state) => state.folders);
const appVersion = useGalleryStore((state) => state.appVersion);
const [open, setOpen] = useState(false);
const downloadTimer = useRef<number | null>(null);
const folders = useGalleryStore((state) => state.folders)
const appVersion = useGalleryStore((state) => state.appVersion)
const [open, setOpen] = useState(false)
const downloadTimer = useRef<number | null>(null)
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === "d") {
event.preventDefault();
setOpen((value) => !value);
if (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 'd') {
event.preventDefault()
setOpen((value) => !value)
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const stopTimer = () => {
if (downloadTimer.current !== null) {
window.clearInterval(downloadTimer.current);
downloadTimer.current = null;
window.clearInterval(downloadTimer.current)
downloadTimer.current = null
}
}
};
// Stop any running download animation when the panel unmounts.
useEffect(() => stopTimer, []);
useEffect(() => stopTimer, [])
const injectBusy = () => {
const progress: Record<number, FolderJobProgress> = {};
const progress: Record<number, FolderJobProgress> = {}
folders.slice(0, BUSY_PRESETS.length).forEach((folder, index) => {
progress[folder.id] = { ...emptyProgress(folder.id), ...BUSY_PRESETS[index] };
});
useGalleryStore.setState({ mediaJobProgress: progress });
};
progress[folder.id] = { ...emptyProgress(folder.id), ...BUSY_PRESETS[index] }
})
useGalleryStore.setState({ mediaJobProgress: progress })
}
const injectSingleEmbedding = () => {
const folder = folders[0];
if (!folder) return;
const folder = folders[0]
if (!folder) return
useGalleryStore.setState({
mediaJobProgress: {
[folder.id]: { ...emptyProgress(folder.id), embedding_ready: 27, embedding_pending: 23 },
},
});
};
})
}
const clear = () => useGalleryStore.setState({ mediaJobProgress: {} });
const clear = () => useGalleryStore.setState({ mediaJobProgress: {} })
// --- Updater flow ---------------------------------------------------------
// These drive the real UpdateToast + Settings "About" row. The Install button
@@ -93,73 +93,73 @@ export function DemoPanel() {
// presentation only — see DemoPanel's header note for the real e2e path.
const updateAvailable = () => {
stopTimer();
stopTimer()
useGalleryStore.setState({
updateStatus: "available",
updateStatus: 'available',
updateVersion: DEMO_UPDATE_VERSION,
updateProgress: null,
updateError: null,
updateDismissed: false,
});
};
})
}
// Indeterminate pulse, then climb 0 → 100%, then flip to "installing".
const simulateDownload = () => {
stopTimer();
stopTimer()
useGalleryStore.setState({
updateStatus: "downloading",
updateStatus: 'downloading',
updateVersion: DEMO_UPDATE_VERSION,
updateProgress: null,
updateError: null,
updateDismissed: false,
});
let progress = 0;
})
let progress = 0
window.setTimeout(() => {
downloadTimer.current = window.setInterval(() => {
progress = Math.min(progress + 0.07, 1);
useGalleryStore.setState({ updateProgress: progress });
progress = Math.min(progress + 0.07, 1)
useGalleryStore.setState({ updateProgress: progress })
if (progress >= 1) {
stopTimer();
useGalleryStore.setState({ updateStatus: "installing" });
stopTimer()
useGalleryStore.setState({ updateStatus: 'installing' })
}
}, 200)
}, 700)
}
}, 200);
}, 700);
};
const updateInstalling = () => {
stopTimer();
stopTimer()
useGalleryStore.setState({
updateStatus: "installing",
updateStatus: 'installing',
updateVersion: DEMO_UPDATE_VERSION,
updateProgress: 1,
updateDismissed: false,
});
};
})
}
const updateError = () => {
stopTimer();
stopTimer()
useGalleryStore.setState({
updateStatus: "error",
updateError: "Could not reach the update server (connection timed out).",
updateStatus: 'error',
updateError: 'Could not reach the update server (connection timed out).',
updateDismissed: false,
});
};
})
}
const updateUpToDate = () => {
stopTimer();
useGalleryStore.setState({ updateStatus: "upToDate", updateVersion: null, updateError: null });
};
stopTimer()
useGalleryStore.setState({ updateStatus: 'upToDate', updateVersion: null, updateError: null })
}
const resetUpdate = () => {
stopTimer();
stopTimer()
useGalleryStore.setState({
updateStatus: "idle",
updateStatus: 'idle',
updateVersion: null,
updateProgress: null,
updateError: null,
updateDismissed: false,
});
};
})
}
// --- What's New flow ------------------------------------------------------
// Drives the post-update greeting without needing a real version change. The
@@ -167,26 +167,32 @@ export function DemoPanel() {
// app version, so the current release's notes are what render.
const showWhatsNewToast = () =>
useGalleryStore.setState({ whatsNewToast: appVersion ?? DEMO_UPDATE_VERSION, whatsNewOpen: false });
useGalleryStore.setState({
whatsNewToast: appVersion ?? DEMO_UPDATE_VERSION,
whatsNewOpen: false,
})
const openWhatsNewModal = () => useGalleryStore.setState({ whatsNewOpen: true, whatsNewToast: null });
const openWhatsNewModal = () =>
useGalleryStore.setState({ whatsNewOpen: true, whatsNewToast: null })
const resetWhatsNew = () => useGalleryStore.setState({ whatsNewOpen: false, whatsNewToast: null });
const resetWhatsNew = () => useGalleryStore.setState({ whatsNewOpen: false, whatsNewToast: null })
if (!open) return null;
if (!open) return null
const injectBtn =
"rounded-md border border-amber-400/30 bg-amber-500/15 px-2 py-1.5 text-left hover:bg-amber-500/25";
'rounded-md border border-amber-400/30 bg-amber-500/15 px-2 py-1.5 text-left hover:bg-amber-500/25'
const neutralBtn =
"rounded-md border border-white/10 bg-white/[0.06] px-2 py-1.5 text-left text-gray-300 hover:bg-white/10";
'rounded-md border border-white/10 bg-white/[0.06] px-2 py-1.5 text-left text-gray-300 hover:bg-white/10'
return (
<div className="fixed bottom-4 left-4 z-[100] max-h-[calc(100vh-2rem)] w-56 overflow-y-auto rounded-lg border border-amber-400/40 bg-amber-950/90 p-3 text-xs text-amber-100 shadow-xl backdrop-blur">
<div className="mb-2 flex items-center justify-between">
<span className="font-semibold uppercase tracking-wide">Demo · Ctrl+Shift+D</span>
<span className="font-semibold tracking-wide uppercase">Demo · Ctrl+Shift+D</span>
</div>
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-amber-200/60">Pipeline</p>
<p className="mb-1.5 text-[11px] font-semibold tracking-wide text-amber-200/60 uppercase">
Pipeline
</p>
<p className="mb-2 text-[11px] leading-snug text-amber-200/70">
Inject a frozen worker-bar state, hide this panel, then screenshot.
</p>
@@ -204,7 +210,9 @@ export function DemoPanel() {
<div className="my-3 h-px bg-amber-400/20" />
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-amber-200/60">Update flow</p>
<p className="mb-1.5 text-[11px] font-semibold tracking-wide text-amber-200/60 uppercase">
Update flow
</p>
<p className="mb-2 text-[11px] leading-snug text-amber-200/70">
Drives the real toast (bottom-right) + Settings About row. Install is inert here.
</p>
@@ -231,9 +239,11 @@ export function DemoPanel() {
<div className="my-3 h-px bg-amber-400/20" />
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-amber-200/60">What's new</p>
<p className="mb-1.5 text-[11px] font-semibold tracking-wide text-amber-200/60 uppercase">
What's new
</p>
<p className="mb-2 text-[11px] leading-snug text-amber-200/70">
Post-update greeting for v{appVersion ?? "—"}, sourced from the bundled changelog.
Post-update greeting for v{appVersion ?? ''}, sourced from the bundled changelog.
</p>
<div className="flex flex-col gap-1.5">
<button className={injectBtn} onClick={showWhatsNewToast}>
@@ -247,5 +257,5 @@ export function DemoPanel() {
</button>
</div>
</div>
);
)
}
+75 -311
View File
@@ -1,361 +1,125 @@
import { useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { DuplicateGroup, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { mediaSrc } from "../lib/mediaSrc";
import { Tooltip } from "./Tooltip";
function formatBytes(bytes: number): string {
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${bytes} B`;
}
function DuplicateGroupCard({ group }: { group: DuplicateGroup }) {
const selectedIds = useGalleryStore((state) => state.duplicateSelectedIds);
const toggleDuplicateSelected = useGalleryStore((state) => state.toggleDuplicateSelected);
const selectAllDuplicates = useGalleryStore((state) => state.selectAllDuplicates);
const groupSelectedCount = group.images.filter((img) => selectedIds.has(img.id)).length;
const noneSelected = groupSelectedCount === 0;
// "Keep all but the first" — a common quick action
const handleKeepFirst = () => {
const toDelete = group.images.slice(1).map((img) => img.id);
// Clear any selection for this group first, then add the ones to delete
for (const img of group.images) {
if (selectedIds.has(img.id)) toggleDuplicateSelected(img.id);
}
selectAllDuplicates(toDelete);
};
return (
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.02] p-4">
{/* Group header */}
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-400">
{group.images.length} copies
</span>
<span className="text-[11px] text-white/30">{formatBytes(group.file_size)} each</span>
<span className="text-[11px] text-white/20">
{formatBytes(group.file_size * (group.images.length - 1))} wasted
</span>
</div>
<div className="flex items-center gap-2">
{noneSelected ? (
<button
className="text-[11px] text-white/35 transition-colors hover:text-white/70"
onClick={handleKeepFirst}
>
Keep first
</button>
) : (
<button
className="text-[11px] text-white/35 transition-colors hover:text-white/70"
onClick={() => {
for (const img of group.images) {
if (selectedIds.has(img.id)) toggleDuplicateSelected(img.id);
}
}}
>
Deselect all
</button>
)}
</div>
</div>
{/* Image grid */}
<div className="flex flex-wrap gap-3">
{group.images.map((image) => {
const isSelected = selectedIds.has(image.id);
const src = mediaSrc(image.thumbnail_path);
return (
<Tooltip label={image.path} anchorToCursor>
<button
key={image.id}
className={`media-dark-surface group relative overflow-hidden rounded-xl border transition-all ${
isSelected
? "border-red-400/50 ring-1 ring-red-400/30"
: "border-white/8 hover:border-white/20"
}`}
style={{ width: 140, height: 105 }}
onClick={() => toggleDuplicateSelected(image.id)}
>
{src ? (
<img src={src} alt="" className="h-full w-full object-cover" draggable={false} />
) : (
<div className="h-full w-full bg-white/[0.03]" />
)}
{/* Delete overlay */}
{isSelected ? (
<div className="absolute inset-0 flex items-center justify-center bg-red-950/60">
<svg className="h-6 w-6 text-red-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</div>
) : null}
{/* Path tooltip on hover */}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-2 pb-1.5 pt-4 opacity-0 transition-opacity group-hover:opacity-100">
<p className="truncate text-[9px] text-white/60">{image.path.split(/[\\/]/).slice(-2).join("/")}</p>
</div>
</button>
</Tooltip>
);
})}
</div>
</div>
);
}
function formatRelativeTime(unixSecs: number): string {
const diff = Math.floor(Date.now() / 1000) - unixSecs;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
import { useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useGalleryStore } from '../store'
import {
DuplicateScanEmptyState,
DuplicateScanIntroState,
DuplicateScanLoadingState,
} from './duplicateFinder/DuplicateFinderEmptyStates'
import { DuplicateFinderHeader } from './duplicateFinder/DuplicateFinderHeader'
import { DuplicateGroupCard } from './duplicateFinder/DuplicateGroupCard'
import { duplicateProgressLabel } from './duplicateFinder/format'
export function DuplicateFinder() {
const duplicateGroups = useGalleryStore((state) => state.duplicateGroups);
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
const duplicateScanError = useGalleryStore((state) => state.duplicateScanError);
const duplicateScanWarning = useGalleryStore((state) => state.duplicateScanWarning);
const duplicateSelectedIds = useGalleryStore((state) => state.duplicateSelectedIds);
const duplicateLastScanned = useGalleryStore((state) => state.duplicateLastScanned);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const scanDuplicates = useGalleryStore((state) => state.scanDuplicates);
const clearDuplicateSelection = useGalleryStore((state) => state.clearDuplicateSelection);
const selectKeepFirstAllGroups = useGalleryStore((state) => state.selectKeepFirstAllGroups);
const deleteSelectedDuplicates = useGalleryStore((state) => state.deleteSelectedDuplicates);
const duplicateGroups = useGalleryStore((state) => state.duplicateGroups)
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning)
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress)
const duplicateScanError = useGalleryStore((state) => state.duplicateScanError)
const duplicateScanWarning = useGalleryStore((state) => state.duplicateScanWarning)
const duplicateSelectedIds = useGalleryStore((state) => state.duplicateSelectedIds)
const duplicateLastScanned = useGalleryStore((state) => state.duplicateLastScanned)
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId)
const scanDuplicates = useGalleryStore((state) => state.scanDuplicates)
const clearDuplicateSelection = useGalleryStore((state) => state.clearDuplicateSelection)
const selectKeepFirstAllGroups = useGalleryStore((state) => state.selectKeepFirstAllGroups)
const deleteSelectedDuplicates = useGalleryStore((state) => state.deleteSelectedDuplicates)
const [deleting, setDeleting] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [deleteResult, setDeleteResult] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const [deleteResult, setDeleteResult] = useState<string | null>(null)
// Virtualize the group list so a large result set (e.g. thousands of pairs)
// only mounts the on-screen cards. Group cards vary in height (number of
// copies wraps across rows), so heights are measured dynamically.
const scrollRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: duplicateGroups.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 220,
overscan: 4,
});
const selectedCount = duplicateSelectedIds.size;
const hasResults = duplicateGroups.length > 0;
const hasScanned = hasResults || duplicateLastScanned !== null || (!duplicateScanning && duplicateScanProgress !== null);
const totalWasted = duplicateGroups.reduce(
(sum, g) => sum + g.file_size * (g.images.length - 1),
0,
);
const totalDuplicateImages = duplicateGroups.reduce((sum, g) => sum + g.images.length - 1, 0);
})
const selectedCount = duplicateSelectedIds.size
const hasResults = duplicateGroups.length > 0
const hasScanned =
hasResults ||
duplicateLastScanned !== null ||
(!duplicateScanning && duplicateScanProgress !== null)
const handleDelete = async () => {
setDeleting(true);
setConfirmingDelete(false);
setDeleteResult(null);
setDeleting(true)
setConfirmingDelete(false)
setDeleteResult(null)
try {
const deleted = await deleteSelectedDuplicates();
setDeleteResult(`Deleted ${deleted} file${deleted === 1 ? "" : "s"}.`);
const deleted = await deleteSelectedDuplicates()
setDeleteResult(`Deleted ${deleted} file${deleted === 1 ? '' : 's'}.`)
} catch (e) {
setDeleteResult(String(e));
setDeleteResult(String(e))
} finally {
setDeleting(false);
setDeleting(false)
}
}
};
const progressPercent =
duplicateScanProgress && duplicateScanProgress.total > 0
? Math.round((duplicateScanProgress.processed / duplicateScanProgress.total) * 100)
: 0;
const progressLabel = duplicateScanProgress
? duplicateScanProgress.phase === "checking"
? "Checking file sizes"
: duplicateScanProgress.phase === "hashing"
? "Hashing duplicate candidates"
: "Confirming exact matches"
: null;
const progressLabel = duplicateProgressLabel(duplicateScanProgress)
return (
<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">
<div>
<h2 className="text-[15px] font-semibold text-white">Duplicate Finder</h2>
<p className="mt-0.5 text-[11px] text-white/30">
{duplicateScanning
? duplicateScanProgress
? `${progressLabel}${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ""}`
: "Starting scan…"
: hasResults
? `${duplicateGroups.length} group${duplicateGroups.length === 1 ? "" : "s"} · ${formatBytes(totalWasted)} reclaimable`
: duplicateLastScanned !== null
? "No duplicates found"
: "Scan your library for identical files"}
</p>
{!duplicateScanning && duplicateLastScanned !== null && (
<p className="mt-0.5 text-[10px] text-white/20">
Last scanned {formatRelativeTime(duplicateLastScanned)}
</p>
)}
</div>
<div className="flex items-center gap-2">
<FolderScopeDropdown />
{/* Batch select — only shown when there are groups and nothing is selected yet */}
{hasResults && selectedCount === 0 && !deleting && (
<Tooltip label={`Mark ${totalDuplicateImages} duplicate${totalDuplicateImages === 1 ? "" : "s"} for deletion across all groups (keeps first in each)`} anchorToCursor>
<button
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white 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}
>
Select all duplicates
</button>
</Tooltip>
)}
{selectedCount > 0 ? (
<>
<span className="text-[11px] text-white/40">{selectedCount} marked for deletion</span>
<button
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={clearDuplicateSelection}
disabled={deleting}
>
Deselect all
</button>
<div className="relative">
<button
className="rounded-lg border border-red-400/25 bg-red-500/10 px-3 py-1.5 text-xs text-red-300 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => setConfirmingDelete((v) => !v)}
disabled={deleting}
>
{deleting ? "Deleting…" : `Delete ${selectedCount} file${selectedCount === 1 ? "" : "s"}`}
</button>
{confirmingDelete && !deleting ? (
<>
{/* Click-away backdrop */}
<div className="fixed inset-0 z-40" onClick={() => setConfirmingDelete(false)} />
<div className="absolute right-0 top-full z-50 mt-2 w-72 rounded-xl border border-red-500/30 bg-gray-950/98 p-3 text-left shadow-2xl backdrop-blur">
<div className="mb-1 flex items-center gap-1.5 text-red-300">
<svg className="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<p className="text-xs font-semibold">Delete from disk</p>
</div>
<p className="mb-2.5 text-[11px] leading-relaxed text-gray-400">
Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer.
This removes the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone.
</p>
<div className="flex justify-end gap-1.5">
<button
className="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setConfirmingDelete(false)}
>
Cancel
</button>
<button
className="rounded-md bg-red-500/20 px-2.5 py-1 text-[11px] font-medium text-red-300 transition-colors hover:bg-red-500/30 hover:text-red-200"
onClick={handleDelete}
>
Delete {selectedCount} from disk
</button>
</div>
</div>
</>
) : null}
</div>
</>
) : null}
<button
className="rounded-lg border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40 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}
>
{duplicateScanning ? "Scanning…" : hasScanned ? "Rescan" : "Scan for duplicates"}
</button>
</div>
</div>
{/* Progress bar */}
{duplicateScanning && duplicateScanProgress ? (
<div className="mt-3 h-px overflow-hidden rounded-full bg-white/[0.07]">
<div
className="h-full rounded-full bg-blue-500/60 transition-[width] duration-200"
style={{ width: `${progressPercent}%` }}
<DuplicateFinderHeader
confirmingDelete={confirmingDelete}
deleteResult={deleteResult}
deleting={deleting}
duplicateGroups={duplicateGroups}
duplicateLastScanned={duplicateLastScanned}
duplicateScanError={duplicateScanError}
duplicateScanning={duplicateScanning}
duplicateScanProgress={duplicateScanProgress}
duplicateScanWarning={duplicateScanWarning}
hasResults={hasResults}
hasScanned={hasScanned}
onClearSelection={clearDuplicateSelection}
onConfirmDelete={() => setConfirmingDelete(false)}
onDelete={handleDelete}
onScan={() => {
setDeleteResult(null)
void scanDuplicates(selectedFolderId)
}}
onSelectKeepFirstAll={selectKeepFirstAllGroups}
selectedCount={selectedCount}
setConfirmingDelete={setConfirmingDelete}
/>
</div>
) : null}
{duplicateScanError ? (
<p className="mt-2 text-[11px] text-red-400/80">{duplicateScanError}</p>
) : null}
{duplicateScanWarning ? (
<p className="mt-2 text-[11px] text-amber-300/70">{duplicateScanWarning}</p>
) : null}
{deleteResult ? (
<p className="mt-2 text-[11px] text-white/40">{deleteResult}</p>
) : null}
</div>
{/* Body */}
{duplicateScanning && !hasResults ? (
<div className="flex flex-1 items-center justify-center gap-3 text-white/25">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/15 border-t-white/50" />
<span className="text-sm">{progressLabel ? `${progressLabel}` : "Preparing scan…"}</span>
</div>
<DuplicateScanLoadingState progressLabel={progressLabel} />
) : !hasScanned ? (
<div className="flex flex-1 items-center justify-center px-8">
<div className="max-w-sm text-center">
<svg className="mx-auto mb-4 h-10 w-10 text-white/10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<p className="text-sm text-white/30">
Finds files with identical content regardless of filename or location.
Click <strong className="text-white/50">Scan for duplicates</strong> to begin.
</p>
<p className="mt-2 text-xs text-white/20">
Large libraries may take a minute files are hashed from disk.
</p>
</div>
</div>
<DuplicateScanIntroState />
) : duplicateGroups.length === 0 ? (
<div className="flex flex-1 items-center justify-center">
<p className="text-sm text-white/25">No duplicate files found.</p>
</div>
<DuplicateScanEmptyState />
) : (
<div ref={scrollRef} className="overflow-y-auto px-6 py-5">
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const group = duplicateGroups[virtualItem.index];
if (!group) return null;
const group = duplicateGroups[virtualItem.index]
if (!group) return null
return (
<div
key={group.file_hash}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
position: 'absolute',
top: 0,
left: 0,
width: "100%",
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
paddingBottom: 16,
}}
>
<DuplicateGroupCard group={group} />
</div>
);
)
})}
</div>
</div>
)}
</div>
);
)
}
File diff suppressed because it is too large Load Diff
+92 -454
View File
@@ -1,423 +1,48 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { DirEntry, DirListing, FolderAddResult, useGalleryStore } from "../store";
import { Tooltip } from "./Tooltip";
function normalizePath(path: string): string {
return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
}
function cleanAddressInput(path: string): string {
const trimmed = path.trim();
if (trimmed.length >= 2) {
const first = trimmed[0];
const last = trimmed[trimmed.length - 1];
if ((first === "\"" && last === "\"") || (first === "'" && last === "'")) {
return trimmed.slice(1, -1).trim();
}
}
return trimmed;
}
function friendlyDirectoryError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (/cannot find the path|os error 3|not found|no such file/i.test(message)) {
return "Folder not found. Check the path and try again.";
}
return message;
}
function folderName(path: string): string {
const trimmed = path.replace(/[\\/]+$/, "");
const parts = trimmed.split(/[\\/]+/).filter(Boolean);
return parts.length > 0 ? parts[parts.length - 1] : path;
}
function buildBreadcrumbs(path: string | null): { label: string; path: string | null }[] {
if (!path) return [{ label: "This PC / Home", path: null }];
const separator = path.includes("\\") ? "\\" : "/";
const normalized = path.replace(/[\\/]+$/, "");
const windowsDrive = normalized.match(/^[A-Za-z]:/);
if (windowsDrive) {
const drive = windowsDrive[0] + "\\";
const rest = normalized.slice(2).split(/[\\/]+/).filter(Boolean);
let current = drive;
return [
{ label: "This PC", path: null },
{ label: drive, path: drive },
...rest.map((part) => {
current = current.endsWith("\\") ? `${current}${part}` : `${current}\\${part}`;
return { label: part, path: current };
}),
];
}
const parts = normalized.split(/[\\/]+/).filter(Boolean);
let current = separator === "/" ? "" : "";
return [
{ label: "/", path: null },
...parts.map((part) => {
current = `${current}/${part}`;
return { label: part, path: current };
}),
];
}
function StatusLine({ results }: { results: FolderAddResult[] | null }) {
if (!results) return null;
const added = results.filter((result) => result.status === "added").length;
const skipped = results.filter((result) => result.status === "skipped").length;
const failed = results.filter((result) => result.status === "error").length;
return (
<p className="text-xs text-gray-500 light-theme:text-gray-600">
Added {added}, skipped {skipped}, failed {failed}.
</p>
);
}
function FolderRow({
entry,
selected,
alreadyAdded,
onToggle,
onNavigate,
}: {
entry: DirEntry;
selected: boolean;
alreadyAdded: boolean;
onToggle: () => void;
onNavigate: () => void;
}) {
return (
<div
className={`group flex h-11 items-center gap-3 rounded-md border px-3 transition-colors ${
alreadyAdded
? "border-transparent bg-white/[0.025] text-gray-600 light-theme:bg-gray-900 light-theme:text-gray-500"
: selected
? "border-white/15 bg-white/[0.085] text-white shadow-[inset_0_0_0_1px_rgb(255_255_255_/_0.035)] light-theme:border-gray-700/45 light-theme:bg-gray-800/55 light-theme:text-white"
: "border-transparent text-gray-300 hover:bg-white/[0.055] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
>
<button
type="button"
className={`grid h-5 w-5 shrink-0 place-items-center rounded border transition-colors ${
selected
? "border-white/30 bg-gray-200 text-gray-950 light-theme:border-gray-700/55 light-theme:bg-gray-700 light-theme:text-white"
: "border-white/15 bg-white/[0.035] text-transparent hover:border-white/30 light-theme:border-gray-700/50 light-theme:bg-gray-950"
} ${alreadyAdded ? "cursor-not-allowed opacity-40" : ""}`}
onClick={onToggle}
disabled={alreadyAdded}
aria-label={selected ? `Remove ${entry.name} from folders to add` : `Choose ${entry.name}`}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</button>
<Tooltip label={entry.path} anchorToCursor className="min-w-0 flex-1">
<button
type="button"
className="flex w-full min-w-0 items-center gap-2 text-left"
onClick={onNavigate}
>
<svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" />
</svg>
<span className="truncate text-sm">{entry.name}</span>
</button>
</Tooltip>
{alreadyAdded ? (
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-500 light-theme:border-gray-700/40 light-theme:bg-gray-950">
Added
</span>
) : null}
<Tooltip label={entry.has_children ? "Open folder" : "No subfolders"} anchorToCursor>
<button
type="button"
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:hover:bg-gray-900 light-theme:hover:text-white"
onClick={onNavigate}
>
<svg className={`h-4 w-4 ${entry.has_children ? "" : "opacity-45"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</Tooltip>
</div>
);
}
function StagedFoldersPanel({
stagedPaths,
onRemove,
onClear,
}: {
stagedPaths: string[];
onRemove: (path: string) => void;
onClear: () => void;
}) {
return (
<aside className="flex min-h-0 w-full flex-col border-t border-white/[0.07] bg-white/[0.018] light-theme:border-gray-300/70 light-theme:bg-gray-900/35 lg:w-80 lg:border-l lg:border-t-0">
<div className="flex items-center justify-between gap-3 border-b border-white/[0.07] px-5 py-4 light-theme:border-gray-300/70">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">
Folders to add ({stagedPaths.length})
</p>
{stagedPaths.length > 0 ? (
<button
type="button"
className="rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={onClear}
>
Clear all
</button>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{stagedPaths.length === 0 ? (
<div className="flex h-full min-h-40 flex-col items-center justify-center rounded-md border border-dashed border-white/[0.08] px-5 text-center light-theme:border-gray-700/35">
<p className="text-sm text-gray-500">No folders selected.</p>
<p className="mt-1 max-w-52 text-xs leading-relaxed text-gray-600 light-theme:text-gray-500">
Choose folders on the left and they will collect here.
</p>
</div>
) : (
<div className="space-y-2">
{stagedPaths.map((path) => (
<Tooltip key={path} label={path} anchorToCursor block>
<div className="group flex min-h-12 items-center gap-2 rounded-md border border-white/[0.07] bg-white/[0.045] px-3 py-2 text-gray-200 transition-colors hover:border-white/15 hover:bg-white/[0.07] light-theme:border-gray-700/40 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800">
<svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" />
</svg>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{folderName(path)}</p>
<p className="mt-0.5 truncate text-[11px] text-gray-600 light-theme:text-gray-500">{path}</p>
</div>
<Tooltip label="Remove from folders to add" anchorToCursor>
<button
type="button"
className="rounded-md p-1 text-gray-500 opacity-80 transition-colors hover:bg-white/[0.08] hover:text-white group-hover:opacity-100 light-theme:hover:bg-gray-700"
onClick={() => onRemove(path)}
aria-label={`Remove ${path} from folders to add`}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Tooltip>
</div>
</Tooltip>
))}
</div>
)}
</div>
</aside>
);
}
import { useVirtualizer } from '@tanstack/react-virtual'
import { Tooltip } from './Tooltip'
import { CloseIcon } from './icons'
import { FolderRow } from './folderPicker/FolderRow'
import { StagedFoldersPanel } from './folderPicker/StagedFoldersPanel'
import { StatusLine } from './folderPicker/StatusLine'
import { normalizePath } from './folderPicker/pathUtils'
import { useFolderPicker } from './folderPicker/useFolderPicker'
export function FolderPickerModal() {
const folderPickerOpen = useGalleryStore((state) => state.folderPickerOpen);
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen);
const folders = useGalleryStore((state) => state.folders);
const listDirectories = useGalleryStore((state) => state.listDirectories);
const addFolders = useGalleryStore((state) => state.addFolders);
const [listing, setListing] = useState<DirListing | null>(null);
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [addressDraft, setAddressDraft] = useState("");
const [addressEditing, setAddressEditing] = useState(false);
const [stagedPaths, setStagedPaths] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
const [results, setResults] = useState<FolderAddResult[] | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const addressInputRef = useRef<HTMLInputElement>(null);
const libraryPaths = useMemo(() => new Set(folders.map((folder) => normalizePath(folder.path))), [folders]);
const stagedSet = useMemo(() => new Set(stagedPaths.map(normalizePath)), [stagedPaths]);
const breadcrumbs = useMemo(() => buildBreadcrumbs(listing?.current ?? null), [listing?.current]);
const folderPicker = useFolderPicker()
const virtualizer = useVirtualizer({
count: listing?.entries.length ?? 0,
getScrollElement: () => scrollRef.current,
count: folderPicker.entries.length,
getScrollElement: () => folderPicker.scrollRef.current,
estimateSize: () => 48,
overscan: 8,
});
useEffect(() => {
if (!folderPickerOpen) return;
let cancelled = false;
setLoading(true);
setError(null);
void listDirectories(currentPath)
.then((nextListing) => {
if (cancelled) return;
setListing(nextListing);
setAddressDraft(nextListing.current ?? "");
setAddressEditing(false);
scrollRef.current?.scrollTo({ top: 0, left: 0 });
})
.catch((loadError) => {
if (cancelled) return;
setListing({ current: currentPath, parent: null, entries: [] });
setError(friendlyDirectoryError(loadError));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [currentPath, folderPickerOpen, listDirectories]);
useEffect(() => {
if (!folderPickerOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
if (addressEditing) {
setAddressDraft(listing?.current ?? "");
setAddressEditing(false);
return;
}
setFolderPickerOpen(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [addressEditing, folderPickerOpen, listing?.current, setFolderPickerOpen]);
useEffect(() => {
if (!addressEditing) return;
requestAnimationFrame(() => {
addressInputRef.current?.focus();
addressInputRef.current?.select();
});
}, [addressEditing]);
useEffect(() => {
if (folderPickerOpen) return;
setCurrentPath(null);
setAddressDraft("");
setAddressEditing(false);
setListing(null);
setStagedPaths([]);
setError(null);
setResults(null);
setAdding(false);
}, [folderPickerOpen]);
if (!folderPickerOpen) return null;
const entries = listing?.entries ?? [];
const addressPath = cleanAddressInput(addressEditing ? addressDraft : (listing?.current ?? ""));
const normalizedAddressPath = addressPath ? normalizePath(addressPath) : "";
const addressAlreadyAdded = normalizedAddressPath ? libraryPaths.has(normalizedAddressPath) : false;
const addressAlreadyStaged = normalizedAddressPath ? stagedSet.has(normalizedAddressPath) : false;
const togglePath = (path: string) => {
const normalized = normalizePath(path);
if (libraryPaths.has(normalized)) return;
setResults(null);
setStagedPaths((current) => {
const exists = current.some((staged) => normalizePath(staged) === normalized);
return exists ? current.filter((staged) => normalizePath(staged) !== normalized) : [...current, path];
});
};
const stagePath = (path: string) => {
const cleaned = cleanAddressInput(path);
if (!cleaned) {
setError("Enter a folder path first.");
return;
}
const normalized = normalizePath(cleaned);
if (libraryPaths.has(normalized)) {
setError("That folder is already in your library.");
return;
}
if (stagedSet.has(normalized)) {
setError("That folder is already selected.");
return;
}
setError(null);
setResults(null);
setStagedPaths((current) => [...current, cleaned]);
};
const navigateToAddress = () => {
const cleaned = cleanAddressInput(addressDraft);
setResults(null);
setError(null);
setCurrentPath(cleaned || null);
};
const beginAddressEdit = () => {
setAddressDraft(listing?.current ?? "");
setAddressEditing(true);
};
const removeStagedPath = (path: string) => {
const normalized = normalizePath(path);
setResults(null);
setStagedPaths((current) => current.filter((staged) => normalizePath(staged) !== normalized));
};
const clearStagedPaths = () => {
setResults(null);
setStagedPaths([]);
};
const confirmAdd = async () => {
if (stagedPaths.length === 0 || adding) return;
setAdding(true);
setError(null);
try {
const addResults = await addFolders(stagedPaths);
const failed = addResults.filter((result) => result.status === "error");
setResults(addResults);
if (failed.length > 0) {
setStagedPaths(stagedPaths.filter((_, i) => addResults[i]?.status === "error"));
setError(failed.map((failure) => failure.data).join("; "));
return;
}
setFolderPickerOpen(false);
} catch (addError) {
setError(addError instanceof Error ? addError.message : String(addError));
} finally {
setAdding(false);
}
};
if (!folderPicker.folderPickerOpen) return null
return (
<div
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm"
onClick={() => setFolderPickerOpen(false)}
onClick={() => folderPicker.setFolderPickerOpen(false)}
>
<div
className="relative flex h-[min(82vh,760px)] w-[min(90vw,1180px)] flex-col overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60 light-theme:border-gray-300/70"
className="light-theme:border-gray-300/70 relative flex h-[min(82vh,760px)] w-[min(90vw,1180px)] flex-col overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60"
onClick={(event) => event.stopPropagation()}
>
<header className="border-b border-white/[0.07] px-5 py-4 light-theme:border-gray-200">
<header className="light-theme:border-gray-200 border-b border-white/[0.07] px-5 py-4">
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<p className="text-base font-semibold text-white">Add media folders</p>
<p className="mt-1 text-xs text-gray-500 light-theme:text-gray-600">Choose folders from any location, then add them together.</p>
<p className="light-theme:text-gray-600 mt-1 text-xs text-gray-500">
Choose folders from any location, then add them together.
</p>
</div>
<Tooltip label="Close folder picker" anchorToCursor>
<button
type="button"
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:hover:bg-gray-900 light-theme:hover:text-white"
onClick={() => setFolderPickerOpen(false)}
className="light-theme:hover:bg-gray-900 light-theme:hover:text-white rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={() => folderPicker.setFolderPickerOpen(false)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<CloseIcon className="h-4 w-4" />
</button>
</Tooltip>
</div>
@@ -428,56 +53,58 @@ export function FolderPickerModal() {
<div className="mb-4 flex items-center gap-2">
<button
type="button"
className="rounded-md border border-white/10 bg-white/[0.035] px-2.5 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
onClick={() => setCurrentPath(listing?.parent ?? null)}
disabled={!listing?.current}
className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 rounded-md border border-white/10 bg-white/[0.035] px-2.5 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => folderPicker.setCurrentPath(folderPicker.listing?.parent ?? null)}
disabled={!folderPicker.listing?.current}
>
Up
</button>
{addressEditing ? (
{folderPicker.addressEditing ? (
<form
className="flex min-w-0 flex-1 items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
navigateToAddress();
event.preventDefault()
folderPicker.navigateToAddress()
}}
>
<label className="sr-only" htmlFor="folder-picker-address">Folder path</label>
<label className="sr-only" htmlFor="folder-picker-address">
Folder path
</label>
<input
ref={addressInputRef}
ref={folderPicker.addressInputRef}
id="folder-picker-address"
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/[0.035] px-3 py-1.5 font-mono text-xs text-gray-200 placeholder-gray-600 outline-none transition-colors focus:border-white/25 focus:bg-white/[0.055] light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:placeholder-gray-500 light-theme:focus:bg-gray-800"
value={addressDraft}
onChange={(event) => {
setAddressDraft(event.target.value);
setResults(null);
}}
className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:placeholder-gray-500 light-theme:focus:bg-gray-800 min-w-0 flex-1 rounded-md border border-white/10 bg-white/[0.035] px-3 py-1.5 font-mono text-xs text-gray-200 placeholder-gray-600 transition-colors outline-none focus:border-white/25 focus:bg-white/[0.055]"
value={folderPicker.addressDraft}
onChange={(event) => folderPicker.updateAddressDraft(event.target.value)}
placeholder="Paste or type a folder path"
spellCheck={false}
/>
<button
type="submit"
className="rounded-md border border-white/10 bg-white/[0.035] px-2.5 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
disabled={loading}
className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 rounded-md border border-white/10 bg-white/[0.035] px-2.5 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
disabled={folderPicker.loading}
>
Go
</button>
</form>
) : (
<div
className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden rounded-md border border-white/10 bg-white/[0.025] px-2 py-1.5 light-theme:border-gray-300/70 light-theme:bg-gray-900"
>
<div className="light-theme:border-gray-300/70 light-theme:bg-gray-900 flex min-w-0 flex-1 items-center gap-1 overflow-hidden rounded-md border border-white/10 bg-white/[0.025] px-2 py-1.5">
<nav className="flex min-w-0 items-center gap-1 overflow-hidden">
{breadcrumbs.map((crumb, index) => (
<span key={`${crumb.path ?? "root"}-${index}`} className="flex min-w-0 items-center gap-1">
{index > 0 ? <span className="text-gray-700 light-theme:text-gray-400">/</span> : null}
<Tooltip label={crumb.path ?? "Roots"} anchorToCursor>
{folderPicker.breadcrumbs.map((crumb, index) => (
<span
key={`${crumb.path ?? 'root'}-${index}`}
className="flex min-w-0 items-center gap-1"
>
{index > 0 ? (
<span className="light-theme:text-gray-400 text-gray-700">/</span>
) : null}
<Tooltip label={crumb.path ?? 'Roots'} anchorToCursor>
<button
type="button"
className="max-w-40 truncate rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white"
className="light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white max-w-40 truncate rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={(event) => {
event.stopPropagation();
setCurrentPath(crumb.path);
event.stopPropagation()
folderPicker.setCurrentPath(crumb.path)
}}
>
{crumb.label}
@@ -488,8 +115,8 @@ export function FolderPickerModal() {
</nav>
<button
type="button"
className="min-w-10 flex-1 self-stretch cursor-text rounded px-1"
onClick={beginAddressEdit}
className="min-w-10 flex-1 cursor-text self-stretch rounded px-1"
onClick={folderPicker.beginAddressEdit}
aria-label="Edit folder path"
/>
</div>
@@ -497,36 +124,47 @@ export function FolderPickerModal() {
<button
type="button"
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-2.5 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => stagePath(addressPath)}
disabled={!addressPath || addressAlreadyAdded || addressAlreadyStaged}
onClick={() => folderPicker.stagePath(folderPicker.addressPath)}
disabled={
!folderPicker.addressPath ||
folderPicker.addressAlreadyAdded ||
folderPicker.addressAlreadyStaged
}
>
Select
</button>
</div>
{error ? (
<div className="mb-3 rounded-md border border-amber-400/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-200 light-theme:border-amber-600/40 light-theme:bg-amber-100 light-theme:text-amber-800">
{error}
{folderPicker.error ? (
<div className="light-theme:border-amber-600/40 light-theme:bg-amber-100 light-theme:text-amber-800 mb-3 rounded-md border border-amber-400/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
{folderPicker.error}
</div>
) : null}
<div ref={scrollRef} className="min-h-0 flex-1 overflow-auto rounded-md border border-white/[0.07] bg-white/[0.018] p-2 light-theme:border-gray-300/70 light-theme:bg-gray-900/50">
{loading ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">Loading folders...</div>
) : entries.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">No folders found here.</div>
<div
ref={folderPicker.scrollRef}
className="light-theme:border-gray-300/70 light-theme:bg-gray-900/50 min-h-0 flex-1 overflow-auto rounded-md border border-white/[0.07] bg-white/[0.018] p-2"
>
{folderPicker.loading ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Loading folders...
</div>
) : folderPicker.entries.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
No folders found here.
</div>
) : (
<div
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const entry = entries[virtualItem.index];
const normalized = normalizePath(entry.path);
const entry = folderPicker.entries[virtualItem.index]
const normalized = normalizePath(entry.path)
return (
<div
key={virtualItem.key}
className="absolute left-0 top-0 w-full px-0.5"
className="absolute top-0 left-0 w-full px-0.5"
style={{
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
@@ -534,13 +172,13 @@ export function FolderPickerModal() {
>
<FolderRow
entry={entry}
selected={stagedSet.has(normalized)}
alreadyAdded={libraryPaths.has(normalized)}
onToggle={() => togglePath(entry.path)}
onNavigate={() => setCurrentPath(entry.path)}
selected={folderPicker.stagedSet.has(normalized)}
alreadyAdded={folderPicker.libraryPaths.has(normalized)}
onToggle={() => folderPicker.togglePath(entry.path)}
onNavigate={() => folderPicker.setCurrentPath(entry.path)}
/>
</div>
);
)
})}
</div>
)}
@@ -548,38 +186,38 @@ export function FolderPickerModal() {
</main>
<StagedFoldersPanel
stagedPaths={stagedPaths}
onRemove={removeStagedPath}
onClear={clearStagedPaths}
stagedPaths={folderPicker.stagedPaths}
onRemove={folderPicker.removeStagedPath}
onClear={folderPicker.clearStagedPaths}
/>
</div>
<footer className="border-t border-white/[0.07] px-5 py-4 light-theme:border-gray-200">
<footer className="light-theme:border-gray-200 border-t border-white/[0.07] px-5 py-4">
<div className="flex items-end justify-between gap-4">
<div className="min-w-0 flex-1">
<StatusLine results={results} />
<StatusLine results={folderPicker.results} />
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
className="rounded-md border border-white/10 bg-white/[0.035] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
onClick={() => setFolderPickerOpen(false)}
className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 rounded-md border border-white/10 bg-white/[0.035] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white"
onClick={() => folderPicker.setFolderPickerOpen(false)}
>
Cancel
</button>
<button
type="button"
className="rounded-md border border-white/15 bg-white/[0.08] px-3 py-1.5 text-xs text-white transition-colors hover:bg-white/[0.12] disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
onClick={() => void confirmAdd()}
disabled={stagedPaths.length === 0 || adding}
className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 rounded-md border border-white/15 bg-white/[0.08] px-3 py-1.5 text-xs text-white transition-colors hover:bg-white/[0.12] disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => void folderPicker.confirmAdd()}
disabled={folderPicker.stagedPaths.length === 0 || folderPicker.adding}
>
{adding ? "Adding..." : `Add ${stagedPaths.length}`}
{folderPicker.adding ? 'Adding...' : `Add ${folderPicker.stagedPaths.length}`}
</button>
</div>
</div>
</footer>
</div>
</div>
);
)
}
+41 -86
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useGalleryStore } from "../store";
import { Tooltip } from "./Tooltip";
import { useMemo } from 'react'
import { useGalleryStore } from '../store'
import { Dropdown, DropdownOption } from './menu'
/**
* In-view folder scope picker for feature views (Timeline / Explore /
@@ -8,93 +8,48 @@ import { Tooltip } from "./Tooltip";
* current view active — unlike sidebar folder clicks, which jump to Gallery.
*/
export function FolderScopeDropdown() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const folders = useGalleryStore((state) => state.folders)
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId)
const setViewFolderScope = useGalleryStore((state) => state.setViewFolderScope)
const folders = useGalleryStore((state) => state.folders);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const setViewFolderScope = useGalleryStore((state) => state.setViewFolderScope);
useEffect(() => {
const close = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
window.addEventListener("pointerdown", close);
return () => window.removeEventListener("pointerdown", close);
}, []);
const currentLabel =
selectedFolderId === null
? "All Media"
: folders.find((folder) => folder.id === selectedFolderId)?.name ?? "All Media";
const select = (folderId: number | null) => {
setViewFolderScope(folderId);
setOpen(false);
};
const options = useMemo<DropdownOption<number | null>[]>(
() => [
{ value: null, label: 'All Media' },
...folders.map((folder) => ({
value: folder.id,
label: folder.name,
hint: <span className="tabular-nums">{folder.image_count.toLocaleString()}</span>,
})),
],
[folders]
)
return (
<div ref={ref} className="feature-scope-dropdown relative">
<Tooltip label="Change folder scope" anchorToCursor>
<button
onClick={() => setOpen((v) => !v)}
className={`feature-scope-trigger flex max-w-56 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${
open
? "border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white"
: "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:border-gray-700/40 light-theme:text-gray-600 light-theme:hover:border-gray-700 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
>
<svg className="h-3.5 w-3.5 shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="truncate">{currentLabel}</span>
<Dropdown
value={selectedFolderId}
options={options}
onChange={setViewFolderScope}
ariaLabel="Folder scope"
trigger="ghost"
size="md"
triggerTooltip="Change folder scope"
triggerClassName="max-w-56"
panelClassName="min-w-52 max-h-80 overflow-y-auto"
triggerIcon={
<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"
className="h-3.5 w-3.5 shrink-0 text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
</button>
</Tooltip>
{open ? (
<div className="feature-scope-menu absolute right-0 top-full z-30 mt-1.5 max-h-80 min-w-52 overflow-y-auto rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur light-theme:border-gray-700/50">
<button
className={`feature-scope-option flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
selectedFolderId === null ? "bg-white/6 text-white light-theme:bg-gray-900 light-theme:text-white" : "text-gray-400 hover:bg-white/5 hover:text-white light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => select(null)}
>
All Media
{selectedFolderId === null ? (
<svg className="h-3.5 w-3.5 shrink-0 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : null}
</button>
{folders.map((folder) => {
const active = selectedFolderId === folder.id;
return (
<button
key={folder.id}
className={`feature-scope-option flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
active ? "bg-white/6 text-white light-theme:bg-gray-900 light-theme:text-white" : "text-gray-400 hover:bg-white/5 hover:text-white light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => select(folder.id)}
>
<span className="min-w-0 truncate">{folder.name}</span>
<span className="flex shrink-0 items-center gap-2">
<span className="text-[11px] tabular-nums text-gray-600">{folder.image_count.toLocaleString()}</span>
{active ? (
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : null}
</span>
</button>
);
})}
</div>
) : null}
</div>
);
}
/>
)
}
+80 -452
View File
@@ -1,366 +1,54 @@
import { useEffect, useLayoutEffect, useRef, useCallback, useMemo, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store";
import { BulkActionBar } from "./BulkActionBar";
import { Tooltip } from "./Tooltip";
import { mediaSrc } from "../lib/mediaSrc";
import { useEffect, useLayoutEffect, useRef, useCallback, useMemo, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from '../store'
import { BulkActionBar } from './BulkActionBar'
import { ImageContextMenu } from './ImageContextMenu'
import { GalleryEmptyState, GalleryLoadingState } from './gallery/GalleryEmptyState'
import { ImageTile } from './gallery/ImageTile'
const GAP = 6;
function formatDuration(durationMs: number | null): string | null {
if (!durationMs || durationMs <= 0) return null;
const totalSeconds = Math.floor(durationMs / 1000);
const seconds = totalSeconds % 60;
const minutes = Math.floor(totalSeconds / 60) % 60;
const hours = Math.floor(totalSeconds / 3600);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
export function ContextMenu({
x,
y,
image,
onClose,
}: {
x: number;
y: number;
image: ImageRecord;
onClose: () => void;
}) {
const openImage = useGalleryStore((state) => state.openImage);
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
const findSimilar = useGalleryStore((state) => state.findSimilar);
const canFindSimilar = image.embedding_status === "ready";
return (
<div
data-gallery-context-menu
className="fixed z-40 min-w-52 rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur"
style={{ left: x, top: y }}
onClick={(event) => event.stopPropagation()}
>
<button
className="w-full rounded-lg px-3 py-2 text-left text-sm text-gray-200 hover:bg-white/5 hover:text-white transition-colors"
onClick={() => { openImage(image); onClose(); }}
>
Open Preview
</button>
<button
className="w-full rounded-lg px-3 py-2 text-left text-sm text-gray-200 hover:bg-white/5 hover:text-white transition-colors"
onClick={async () => { await updateImageDetails(image.id, { favorite: !image.favorite }); onClose(); }}
>
{image.favorite ? "Remove Favorite" : "Add to Favorites"}
</button>
<button
className={`w-full rounded-lg px-3 py-2 text-left text-sm transition-colors ${
canFindSimilar
? "text-gray-200 hover:bg-white/5 hover:text-white"
: "text-gray-600 cursor-not-allowed"
}`}
onClick={() => {
if (!canFindSimilar) return;
findSimilar(image.id, image.folder_id);
onClose();
}}
disabled={!canFindSimilar}
>
{canFindSimilar ? "Find Similar" : "Embeddings not ready"}
</button>
<div className="my-1 h-px bg-white/[0.06]" />
<div className="px-3 py-1 text-[10px] uppercase tracking-[0.18em] text-gray-600">Rating</div>
<div className="flex items-center gap-0.5 px-2 pb-1.5">
{Array.from({ length: 5 }, (_, index) => {
const rating = index + 1;
return (
<Tooltip key={rating} label={`Set ${rating} star rating`} followCursor>
<button
className="rounded-md p-1 transition-colors hover:bg-white/5"
onClick={async () => { await updateImageDetails(image.id, { rating }); onClose(); }}
>
<svg
className={`h-4 w-4 ${rating <= image.rating ? "text-amber-300" : "text-white/20 hover:text-white/40"}`}
fill="currentColor" viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</button>
</Tooltip>
);
})}
{image.rating > 0 ? (
<Tooltip label="Remove rating" followCursor>
<button
className="ml-1 rounded-md p-1 text-gray-600 hover:bg-white/5 hover:text-gray-300 transition-colors"
onClick={async () => { await updateImageDetails(image.id, { rating: 0 }); onClose(); }}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Tooltip>
) : null}
</div>
</div>
);
}
export function ImageTile({
image,
onClick,
onContextMenu,
}: {
image: ImageRecord;
onClick: () => void;
onContextMenu: (event: React.MouseEvent<HTMLDivElement>) => void;
}) {
const [loaded, setLoaded] = useState(false);
const [errored, setErrored] = useState(false);
const findSimilar = useGalleryStore((state) => state.findSimilar);
const selected = useGalleryStore((state) => state.gallerySelectedIds.has(image.id));
const selectionActive = useGalleryStore((state) => state.gallerySelectedIds.size > 0);
const toggleGallerySelected = useGalleryStore((state) => state.toggleGallerySelected);
const canFindSimilar = image.embedding_status === "ready";
const src = mediaSrc(image.thumbnail_path);
return (
<div
className={`media-dark-surface group relative overflow-hidden rounded-xl bg-white/[0.04] text-left focus:outline-none transition-shadow ${
selected ? "ring-2 ring-inset ring-blue-400/80" : ""
}`}
style={{ width: "100%", aspectRatio: "1 / 1" }}
onContextMenu={onContextMenu}
>
{/* Full-tile click target — opens, or toggles selection while selecting.
A real button (over the non-interactive tile div) keeps it keyboard-
accessible without nesting buttons. */}
<button
type="button"
className="absolute inset-0 z-10 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
aria-label={`Open ${image.filename}`}
onClick={(event) => {
event.stopPropagation();
if (selectionActive) toggleGallerySelected(image.id);
else onClick();
}}
onDoubleClick={(event) => {
event.stopPropagation();
onClick();
}}
/>
{/* Selection corner — a top-left zone that reveals the checkbox only when
hovered (not the whole tile) and toggles selection on click. The
checkbox stays visible once the item is selected. */}
<button
type="button"
role="checkbox"
aria-checked={selected}
aria-label={selected ? "Deselect" : "Select"}
className="group/cb absolute top-0 left-0 z-20 h-11 w-11 cursor-pointer"
onClick={(event) => {
event.stopPropagation();
toggleGallerySelected(image.id);
}}
>
<div
className={`absolute top-2 left-2 flex h-5 w-5 items-center justify-center rounded-full border transition-all duration-150 ${
selected
? "border-blue-400 bg-blue-500 text-white opacity-100"
: "border-white/70 bg-black/40 text-transparent opacity-0 backdrop-blur-sm group-hover/cb:opacity-100"
}`}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
</button>
{/* Image / placeholder */}
{src && !errored ? (
<>
{!loaded && <div className="absolute inset-0 animate-pulse bg-white/[0.04]" />}
<img
src={src}
alt={image.filename}
className={`h-full w-full object-cover transition-all duration-300 ${
loaded ? "opacity-100 scale-100" : "opacity-0 scale-[1.02]"
} group-hover:scale-[1.03]`}
loading="lazy"
onLoad={() => setLoaded(true)}
onError={() => setErrored(true)}
/>
</>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-white/[0.03] text-white/20">
{image.media_kind === "video" ? (
<svg className="h-7 w-7" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
) : (
<svg className="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
)}
</div>
)}
{/* Video play icon — subtle at rest, visible on hover */}
{image.media_kind === "video" && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="rounded-full bg-black/40 p-3 text-white backdrop-blur-sm opacity-50 group-hover:opacity-90 transition-opacity duration-200">
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
)}
{/* Persistent badges — only shown when meaningful */}
<div className="absolute top-2 right-2 flex flex-col items-end gap-1 pointer-events-none">
{image.embedding_status === "failed" && (
<Tooltip label={image.embedding_error ?? "Embedding failed"} followCursor className="pointer-events-auto">
<div className="flex items-center gap-1 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 backdrop-blur-sm">
<svg className="h-2.5 w-2.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
</div>
</Tooltip>
)}
{image.favorite && (
<div className="rounded-full bg-black/50 p-1 text-rose-400 backdrop-blur-sm">
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" />
</svg>
</div>
)}
{image.rating > 0 && (
<div className="flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-1 text-amber-300 backdrop-blur-sm">
{Array.from({ length: image.rating }, (_, index) => (
<svg key={index} className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
)}
{image.media_kind === "video" && image.duration_ms && (
<div className="rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80 backdrop-blur-sm">
{formatDuration(image.duration_ms)}
</div>
)}
</div>
{/* Hover overlay — slides up from bottom */}
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" />
{/* Hover info — appears with overlay */}
<div className="absolute bottom-0 left-0 right-0 z-20 p-2.5 translate-y-1 group-hover:translate-y-0 opacity-0 group-hover:opacity-100 transition-all duration-200">
<TruncatedFilename filename={image.filename} />
<div className="mt-1.5 flex items-center justify-between gap-2">
{image.rating > 0 ? (
<div className="flex items-center gap-0.5">
{Array.from({ length: image.rating }, (_, i) => (
<svg key={i} className="h-2.5 w-2.5 text-amber-300" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
) : (
<span />
)}
<button
className={`relative z-20 rounded-md px-2 py-0.5 text-[10px] transition-colors pointer-events-auto backdrop-blur-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80 ${
canFindSimilar
? "bg-white/10 text-white/80 hover:bg-white/20 hover:text-white"
: "bg-white/5 text-white/30 cursor-not-allowed"
}`}
onClick={(event) => {
event.stopPropagation();
if (!canFindSimilar) return;
findSimilar(image.id, image.folder_id);
}}
disabled={!canFindSimilar}
>
Similar
</button>
</div>
</div>
</div>
);
}
function TruncatedFilename({ filename }: { filename: string }) {
const textRef = useRef<HTMLParagraphElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
useLayoutEffect(() => {
const text = textRef.current;
if (!text) return;
const update = () => {
setIsTruncated(text.scrollWidth > text.clientWidth);
};
update();
const observer = new ResizeObserver(update);
observer.observe(text);
return () => observer.disconnect();
}, [filename]);
const label = (
<p ref={textRef} className="truncate text-[12px] font-medium leading-tight text-white">
{filename}
</p>
);
return (
<Tooltip label={filename} delay={500} block followCursor disabled={!isTruncated}>
{label}
</Tooltip>
);
}
const GAP = 6
export function Gallery() {
const images = useGalleryStore((state) => state.images);
const loadMoreImages = useGalleryStore((state) => state.loadMoreImages);
const openImage = useGalleryStore((state) => state.openImage);
const totalImages = useGalleryStore((state) => state.totalImages);
const loadingImages = useGalleryStore((state) => state.loadingImages);
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
const search = useGalleryStore((state) => state.search);
const collectionTitle = useGalleryStore((state) => state.collectionTitle);
const imageLoadError = useGalleryStore((state) => state.imageLoadError);
const galleryScrollResetKey = useGalleryStore((state) => state.galleryScrollResetKey);
const isSimilarResults = collectionTitle === "Similar Images";
const parsedSearch = parseSearchValue(search);
const images = useGalleryStore((state) => state.images)
const loadMoreImages = useGalleryStore((state) => state.loadMoreImages)
const openImage = useGalleryStore((state) => state.openImage)
const totalImages = useGalleryStore((state) => state.totalImages)
const loadingImages = useGalleryStore((state) => state.loadingImages)
const zoomPreset = useGalleryStore((state) => state.zoomPreset)
const search = useGalleryStore((state) => state.search)
const collectionTitle = useGalleryStore((state) => state.collectionTitle)
const imageLoadError = useGalleryStore((state) => state.imageLoadError)
const galleryScrollResetKey = useGalleryStore((state) => state.galleryScrollResetKey)
const isSimilarResults = collectionTitle === 'Similar Images'
const parsedSearch = parseSearchValue(search)
const parentRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null);
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 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();
}, []);
setContainerWidth(entries[0].contentRect.width)
})
ro.observe(el)
return () => ro.disconnect()
}, [])
const tileSize = tileSizeForZoom(zoomPreset);
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);
[containerWidth, tileSize]
)
const rowCount = Math.ceil(images.length / cols)
const estimateSize = useCallback(() => tileSize + GAP, [tileSize]);
const estimateSize = useCallback(() => tileSize + GAP, [tileSize])
const virtualizer = useVirtualizer({
count: rowCount,
@@ -368,127 +56,67 @@ export function Gallery() {
estimateSize,
overscan: 3,
paddingStart: GAP,
});
})
useEffect(() => {
virtualizer.measure();
}, [cols, virtualizer]);
virtualizer.measure()
}, [cols, virtualizer])
useEffect(() => {
parentRef.current?.scrollTo({ top: 0, left: 0 });
}, [galleryScrollResetKey]);
parentRef.current?.scrollTo({ top: 0, left: 0 })
}, [galleryScrollResetKey])
const handleScroll = useCallback(() => {
const el = parentRef.current;
if (!el) return;
if (el.scrollTop < 24) return;
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600;
const 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();
void loadMoreImages()
}
}, [images.length, loadMoreImages, loadingImages, totalImages]);
}, [images.length, loadMoreImages, loadingImages, totalImages])
useEffect(() => {
const el = parentRef.current;
if (!el) return;
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
useEffect(() => {
const close = (event: PointerEvent) => {
if ((event.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return;
setContextMenu(null);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setContextMenu(null);
};
window.addEventListener("pointerdown", close);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("pointerdown", close);
window.removeEventListener("keydown", handleKeyDown);
};
}, []);
const el = parentRef.current
if (!el) return
el.addEventListener('scroll', handleScroll, { passive: true })
return () => el.removeEventListener('scroll', handleScroll)
}, [handleScroll])
return (
<div className="relative flex-1 min-h-0">
<div ref={parentRef} className="absolute inset-0 overflow-y-auto overflow-x-hidden bg-gray-950">
<div className="relative min-h-0 flex-1">
<div
ref={parentRef}
className="absolute inset-0 overflow-x-hidden overflow-y-auto 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">
<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">
{isSimilarResults
? "Finding similar images"
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
? `Searching for matches to "${parsedSearch.query}"`
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
? `Searching tags for "${parsedSearch.query}"`
: "Loading media"}
</p>
<p className="text-xs text-white/20 mt-1">
{isSimilarResults
? "Comparing visual embeddings"
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
? "Semantic search can take a little longer than filename search"
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
? "Matching against AI and user tags"
: "Fetching results"}
</p>
</div>
</div>
<GalleryLoadingState isSimilarResults={isSimilarResults} parsedSearch={parsedSearch} />
) : 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">
<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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-sm text-white/30 font-medium">
{imageLoadError
? "Could not load results"
: isSimilarResults
? "No similar images found"
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
? "No semantic matches found"
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
? "No tag matches found"
: "No media found"}
</p>
<p className="text-xs text-white/15 mt-1">
{imageLoadError
? imageLoadError
: isSimilarResults
? "This item may be visually isolated, or more embeddings may need to finish processing"
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
? "Try a broader phrase, or wait for more embeddings to finish processing"
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
? "Try a shorter tag, or wait for more tagging jobs to finish"
: "Try adjusting your filters or add a new folder"}
</p>
</div>
</div>
<GalleryEmptyState
imageLoadError={imageLoadError}
isSimilarResults={isSimilarResults}
parsedSearch={parsedSearch}
/>
) : (
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const startIndex = virtualRow.index * cols;
const rowImages = images.slice(startIndex, startIndex + cols);
const startIndex = virtualRow.index * cols
const rowImages = images.slice(startIndex, startIndex + cols)
return (
<div
key={virtualRow.key}
style={{
position: "absolute",
position: 'absolute',
top: virtualRow.start,
width: "100%",
width: '100%',
height: virtualRow.size,
display: "grid",
display: 'grid',
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
gap: GAP,
paddingLeft: GAP,
paddingRight: GAP,
paddingBottom: GAP,
boxSizing: "border-box",
boxSizing: 'border-box',
}}
>
{rowImages.map((image) => (
@@ -497,25 +125,25 @@ export function Gallery() {
image={image}
onClick={() => openImage(image)}
onContextMenu={(event) => {
event.preventDefault();
setContextMenu({ x: event.clientX, y: event.clientY, image });
event.preventDefault()
setContextMenu({ x: event.clientX, y: event.clientY, image })
}}
/>
))}
</div>
);
)
})}
</div>
)}
{images.length > 0 && loadingImages ? (
<div className="flex justify-center py-8">
<div className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/20 border-t-white/60" />
</div>
) : null}
{contextMenu ? (
<ContextMenu
<ImageContextMenu
x={contextMenu.x}
y={contextMenu.y}
image={contextMenu.image}
@@ -528,5 +156,5 @@ export function Gallery() {
container so it stays put while the grid scrolls. */}
<BulkActionBar />
</div>
);
)
}
+88
View File
@@ -0,0 +1,88 @@
import { ImageRecord, useGalleryStore } from '../store'
import { ContextMenu, MenuItem, MenuLabel, MenuSeparator, SubMenu } from './menu'
import { Tooltip } from './Tooltip'
import { CloseIcon, StarIcon } from './icons'
/** Right-click menu for an image tile. Shared by the Gallery grid and the Timeline. */
export function ImageContextMenu({
x,
y,
image,
onClose,
}: {
x: number
y: number
image: ImageRecord
onClose: () => void
}) {
const openImage = useGalleryStore((state) => state.openImage)
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails)
const findSimilar = useGalleryStore((state) => state.findSimilar)
const albums = useGalleryStore((state) => state.albums)
const addToAlbum = useGalleryStore((state) => state.addToAlbum)
const canFindSimilar = image.embedding_status === 'ready'
return (
<ContextMenu x={x} y={y} onClose={onClose}>
<MenuItem label="Open Preview" onSelect={() => openImage(image)} />
<MenuItem
label={image.favorite ? 'Remove Favorite' : 'Add to Favorites'}
onSelect={() => void updateImageDetails(image.id, { favorite: !image.favorite })}
/>
<MenuItem
label={canFindSimilar ? 'Find Similar' : 'Embeddings not ready'}
disabled={!canFindSimilar}
onSelect={() => findSimilar(image.id, image.folder_id)}
/>
<SubMenu label="Add to Album" panelClassName="max-h-64 overflow-y-auto">
{albums.length === 0 ? (
<MenuItem label="No albums yet" disabled />
) : (
albums.map((album) => (
<MenuItem
key={album.id}
label={album.name}
hint={album.image_count.toLocaleString()}
onSelect={() => void addToAlbum(album.id, [image.id])}
/>
))
)}
</SubMenu>
<MenuSeparator />
<MenuLabel>Rating</MenuLabel>
<div className="flex items-center gap-0.5 px-2 pb-1.5">
{Array.from({ length: 5 }, (_, index) => {
const rating = index + 1
return (
<Tooltip key={rating} label={`Set ${rating} star rating`} followCursor>
<button
className="rounded-md p-1 transition-colors hover:bg-white/5"
onClick={async () => {
await updateImageDetails(image.id, { rating })
onClose()
}}
>
<StarIcon
className={`h-4 w-4 ${rating <= image.rating ? 'text-amber-300' : 'text-white/20 hover:text-white/40'}`}
/>
</button>
</Tooltip>
)
})}
{image.rating > 0 ? (
<Tooltip label="Remove rating" followCursor>
<button
className="ml-1 rounded-md p-1 text-gray-600 transition-colors hover:bg-white/5 hover:text-gray-300"
onClick={async () => {
await updateImageDetails(image.id, { rating: 0 })
onClose()
}}
>
<CloseIcon className="h-3 w-3" />
</button>
</Tooltip>
) : null}
</div>
</ContextMenu>
)
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Compact Confirm/Cancel pair for destructive row actions (remove folder,
* delete album). Swap it in where the hover actions normally sit.
*/
export function InlineConfirm({
onConfirm,
onCancel,
}: {
onConfirm: () => void
onCancel: () => void
}) {
return (
<div className="flex shrink-0 items-center gap-1" onClick={(event) => event.stopPropagation()}>
<button
className="rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] text-red-400 transition-colors hover:bg-red-500/30 hover:text-red-300"
onClick={onConfirm}
>
Confirm
</button>
<button
className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-gray-500 transition-colors hover:bg-white/10 hover:text-gray-300"
onClick={onCancel}
>
Cancel
</button>
</div>
)
}
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useRef, useState } from 'react'
/**
* In-place rename input for sidebar rows (folders, albums). Mount it in
* place of the row label while renaming: commits on Enter or blur (only when
* the trimmed name is non-empty and actually changed), cancels on Escape.
*/
export function InlineRename({
name,
onRename,
onClose,
}: {
name: string
onRename: (next: string) => Promise<void> | void
onClose: () => void
}) {
const [value, setValue] = useState(name)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, [])
const commit = async () => {
const trimmed = value.trim()
if (trimmed && trimmed !== name) {
await onRename(trimmed)
}
onClose()
}
return (
<input
ref={inputRef}
className="w-full rounded bg-white/10 px-1 py-0 text-[13px] leading-tight font-medium text-white ring-1 ring-blue-500/60 outline-none"
value={value}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void commit()
}
if (event.key === 'Escape') onClose()
}}
onBlur={() => void commit()}
onClick={(event) => event.stopPropagation()}
/>
)
}
+191 -1395
View File
File diff suppressed because it is too large Load Diff
-184
View File
@@ -1,184 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { MediaFilter, ZoomPreset, useGalleryStore } from "../store";
type MenuKey = "library" | "view" | "filter";
function MenuButton({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
active ? "bg-white/10 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white"
}`}
onClick={onClick}
>
{label}
</button>
);
}
function MenuPanel({ children }: { children: React.ReactNode }) {
return (
<div className="absolute left-0 top-full z-30 mt-2 min-w-56 rounded-xl border border-white/10 bg-gray-950/95 p-2 shadow-2xl backdrop-blur">
{children}
</div>
);
}
function MenuItem({
label,
hint,
active = false,
onClick,
}: {
label: string;
hint?: string;
active?: boolean;
onClick: () => void;
}) {
return (
<button
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
active ? "bg-blue-500/15 text-white" : "text-gray-300 hover:bg-white/5 hover:text-white"
}`}
onClick={onClick}
>
<span>{label}</span>
{hint ? <span className="text-xs text-gray-500">{hint}</span> : null}
</button>
);
}
const ZOOM_OPTIONS: { value: ZoomPreset; label: string }[] = [
{ value: "compact", label: "Compact Grid" },
{ value: "comfortable", label: "Comfortable Grid" },
{ value: "detail", label: "Detail Grid" },
];
const FILTER_OPTIONS: { value: MediaFilter; label: string }[] = [
{ value: "all", label: "All Media" },
{ value: "image", label: "Images" },
{ value: "video", label: "Videos" },
];
export function MenuBar() {
const [openMenu, setOpenMenu] = useState<MenuKey | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen);
const reindexFolder = useGalleryStore((state) => state.reindexFolder);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
const mediaFilter = useGalleryStore((state) => state.mediaFilter);
const setMediaFilter = useGalleryStore((state) => state.setMediaFilter);
const favoritesOnly = useGalleryStore((state) => state.favoritesOnly);
const setFavoritesOnly = useGalleryStore((state) => state.setFavoritesOnly);
useEffect(() => {
const handlePointerDown = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) {
setOpenMenu(null);
}
};
window.addEventListener("pointerdown", handlePointerDown);
return () => window.removeEventListener("pointerdown", handlePointerDown);
}, []);
const handleAddFolder = () => {
setFolderPickerOpen(true);
setOpenMenu(null);
};
const handleReindex = async () => {
if (selectedFolderId !== null) {
await reindexFolder(selectedFolderId);
}
setOpenMenu(null);
};
return (
<div ref={rootRef} className="relative z-20 flex items-center gap-1 border-b border-white/5 bg-gray-950/90 px-4 py-2 backdrop-blur">
<div className="relative">
<MenuButton
label="Library"
active={openMenu === "library"}
onClick={() => setOpenMenu((current) => (current === "library" ? null : "library"))}
/>
{openMenu === "library" ? (
<MenuPanel>
<MenuItem label="Add Folder" hint="Ctrl+O soon" onClick={handleAddFolder} />
<MenuItem
label="Re-index Current Folder"
hint={selectedFolderId === null ? "Select folder" : undefined}
onClick={handleReindex}
active={selectedFolderId !== null}
/>
</MenuPanel>
) : null}
</div>
<div className="relative">
<MenuButton
label="View"
active={openMenu === "view"}
onClick={() => setOpenMenu((current) => (current === "view" ? null : "view"))}
/>
{openMenu === "view" ? (
<MenuPanel>
{ZOOM_OPTIONS.map((option) => (
<MenuItem
key={option.value}
label={option.label}
active={zoomPreset === option.value}
onClick={() => {
setZoomPreset(option.value);
setOpenMenu(null);
}}
/>
))}
</MenuPanel>
) : null}
</div>
<div className="relative">
<MenuButton
label="Filter"
active={openMenu === "filter"}
onClick={() => setOpenMenu((current) => (current === "filter" ? null : "filter"))}
/>
{openMenu === "filter" ? (
<MenuPanel>
{FILTER_OPTIONS.map((option) => (
<MenuItem
key={option.value}
label={option.label}
active={mediaFilter === option.value}
onClick={() => {
setMediaFilter(option.value);
setOpenMenu(null);
}}
/>
))}
<div className="my-2 h-px bg-white/5" />
<MenuItem
label={favoritesOnly ? "Hide Favorites Only" : "Show Favorites Only"}
active={favoritesOnly}
onClick={() => {
setFavoritesOnly(!favoritesOnly);
setOpenMenu(null);
}}
/>
</MenuPanel>
) : null}
</div>
</div>
);
}
+7 -5
View File
@@ -6,14 +6,14 @@
//
// Pass dotClassName (e.g. "fill-amber-400") to light up the central focal point —
// used in the titlebar as the "update available" indicator.
const BLADE = "M0,-4.18 A10,10 0 0 1 6.43,-7.66";
const BLADE = 'M0,-4.18 A10,10 0 0 1 6.43,-7.66'
export function PhokusMark({
className,
dotClassName,
}: {
className?: string;
dotClassName?: string;
className?: string
dotClassName?: string
}) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className}>
@@ -33,7 +33,9 @@ export function PhokusMark({
<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}
{dotClassName ? (
<circle cx="12" cy="12" r="2.6" stroke="none" className={dotClassName} />
) : null}
</svg>
);
)
}
File diff suppressed because it is too large Load Diff
+43 -1116
View File
File diff suppressed because it is too large Load Diff
-106
View File
@@ -1,106 +0,0 @@
import { useEffect, useRef, useState } from "react";
export interface DropdownOption {
value: string;
label: string;
}
export function ThemedDropdown({
value,
options,
onChange,
ariaLabel,
compact = false,
align = "right",
}: {
value: string;
options: DropdownOption[];
onChange: (value: string) => void;
ariaLabel: string;
compact?: boolean;
align?: "left" | "right";
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const current = options.find((option) => option.value === value) ?? options[0];
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
if (!ref.current?.contains(event.target as Node)) setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
};
}, []);
return (
<div ref={ref} className="relative">
<button
type="button"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((currentOpen) => !currentOpen)}
className={`flex items-center justify-between gap-2 rounded-md border transition-colors ${
compact
? "border-white/10 bg-white/[0.04] px-1.5 py-0.5 text-[10px] font-medium light-theme:border-gray-700/50 light-theme:bg-gray-900"
: "min-w-40 border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs light-theme:border-gray-700/50 light-theme:bg-gray-900"
} ${open ? "border-white/20 text-white light-theme:border-gray-700 light-theme:text-white" : "text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:text-white light-theme:hover:border-gray-700 light-theme:hover:bg-gray-800 light-theme:hover:text-white"}`}
>
<span>{current?.label}</span>
<svg
className={`h-3 w-3 shrink-0 text-gray-500 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open ? (
<div
role="listbox"
aria-label={ariaLabel}
className={`absolute top-full z-50 mt-1.5 min-w-full rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl shadow-black/30 backdrop-blur-xl light-theme:border-gray-700/50 ${
align === "right" ? "right-0" : "left-0"
}`}
>
{options.map((option) => {
const selected = option.value === value;
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={selected}
className={`flex w-full items-center justify-between gap-4 whitespace-nowrap rounded-lg px-3 py-2 text-left text-xs transition-colors ${
selected
? "bg-white/[0.08] text-white light-theme:bg-gray-900 light-theme:text-white"
: "text-gray-400 hover:bg-white/[0.055] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => {
onChange(option.value);
setOpen(false);
}}
>
<span>{option.label}</span>
{selected ? (
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : null}
</button>
);
})}
</div>
) : null}
</div>
);
}
+97 -286
View File
@@ -1,160 +1,69 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store";
import { ContextMenu, ImageTile } from "./Gallery";
import { Tooltip } from "./Tooltip";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { ImageRecord, tileSizeForZoom, useGalleryStore } from '../store'
import { ImageTile } from './gallery/ImageTile'
import { ImageContextMenu } from './ImageContextMenu'
import { ScrubberYearBlock } from './timeline/ScrubberYearBlock'
import { TimelineEmptyState, TimelineLoadingState } from './timeline/TimelineEmptyState'
import { buildScrubberYears, buildTimelineRows, groupImages } from './timeline/timelineModel'
const GAP = 6;
const HEADER_HEIGHT = 52;
const SCRUBBER_WIDTH = 48;
const MONTH_SHORT = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] as const;
interface TimelineGroup {
key: string;
label: string;
images: ImageRecord[];
}
// One virtualized row: either a month header or a row of up to `cols` tiles.
type TimelineRow =
| { type: "header"; group: TimelineGroup }
| { type: "tiles"; images: ImageRecord[] };
interface ScrubberMonth {
monthNum: number;
label: string;
groupIndex: number;
}
interface ScrubberYear {
year: string;
firstGroupIndex: number;
months: ScrubberMonth[];
}
function buildLabel(key: string): string {
if (key === "unknown") return "Unknown Date";
const [yearStr, monthStr] = key.split("-");
const year = Number(yearStr);
const month = Number(monthStr);
if (!isFinite(year) || !isFinite(month) || month < 1 || month > 12) return "Unknown Date";
const date = new Date(year, month - 1);
if (isNaN(date.getTime())) return "Unknown Date";
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
}
function groupImages(images: ImageRecord[]): TimelineGroup[] {
const map = new Map<string, ImageRecord[]>();
for (const img of images) {
const ds = img.taken_at ?? img.modified_at;
const key = ds ? ds.substring(0, 7) : "unknown";
let bucket = map.get(key);
if (bucket === undefined) {
bucket = [];
map.set(key, bucket);
}
bucket.push(img);
}
return Array.from(map.entries())
.sort(([a], [b]) => {
if (a === "unknown") return 1;
if (b === "unknown") return -1;
return a < b ? -1 : a > b ? 1 : 0;
})
.map(([key, imgs]) => ({ key, label: buildLabel(key), images: imgs }));
}
function buildScrubberYears(groups: TimelineGroup[]): ScrubberYear[] {
const byYear = new Map<string, ScrubberYear>();
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
if (group.key === "unknown") continue;
const year = group.key.substring(0, 4);
const monthNum = Number(group.key.substring(5, 7));
if (!byYear.has(year)) {
byYear.set(year, { year, firstGroupIndex: i, months: [] });
}
byYear.get(year)!.months.push({
monthNum,
label: MONTH_SHORT[monthNum - 1] ?? "",
groupIndex: i,
});
}
// Keep insertion order so the scrubber runs the same direction as the scrolled
// content (oldest at top with taken_asc), keeping the active highlight aligned.
return Array.from(byYear.values());
}
const GAP = 6
const HEADER_HEIGHT = 52
const SCRUBBER_WIDTH = 48
export function Timeline() {
const images = useGalleryStore((s) => s.images);
const loadMoreImages = useGalleryStore((s) => s.loadMoreImages);
const openImage = useGalleryStore((s) => s.openImage);
const totalImages = useGalleryStore((s) => s.totalImages);
const loadingImages = useGalleryStore((s) => s.loadingImages);
const imageLoadError = useGalleryStore((s) => s.imageLoadError);
const zoomPreset = useGalleryStore((s) => s.zoomPreset);
const images = useGalleryStore((s) => s.images)
const loadMoreImages = useGalleryStore((s) => s.loadMoreImages)
const openImage = useGalleryStore((s) => s.openImage)
const totalImages = useGalleryStore((s) => s.totalImages)
const loadingImages = useGalleryStore((s) => s.loadingImages)
const imageLoadError = useGalleryStore((s) => s.imageLoadError)
const zoomPreset = useGalleryStore((s) => s.zoomPreset)
const parentRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [activeGroupIndex, setActiveGroupIndex] = useState(0);
const 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);
x: number
y: number
image: ImageRecord
} | null>(null)
// parentRef is the scroll container. Its clientWidth already excludes the
// scrubber because they are flex siblings, so no further adjustment is needed.
useLayoutEffect(() => {
const el = parentRef.current;
if (!el) return;
setContainerWidth(el.clientWidth);
const 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();
}, []);
setContainerWidth(entries[0].contentRect.width)
})
ro.observe(el)
return () => ro.disconnect()
}, [])
const tileSize = tileSizeForZoom(zoomPreset);
const groups = useMemo(() => groupImages(images), [images]);
const scrubberYears = useMemo(() => buildScrubberYears(groups), [groups]);
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 showScrubber = groups.length > 1
const cols = useMemo(
() => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))),
[containerWidth, tileSize],
);
[containerWidth, tileSize]
)
// Flatten the month groups into a single list of fixed-height rows — one
// header row per group, then one tile-row per `cols` images. This lets the
// virtualizer render only the on-screen rows, exactly like the Gallery.
// Previously each *group* was one virtual item that rendered ALL of its
// images, so scrolling into a busy month mounted thousands of tiles at once.
const { rows, rowToGroupIndex, groupFirstRow } = useMemo(() => {
const rows: TimelineRow[] = [];
const rowToGroupIndex: number[] = [];
const groupFirstRow: number[] = [];
groups.forEach((group, groupIndex) => {
groupFirstRow[groupIndex] = rows.length;
rows.push({ type: "header", group });
rowToGroupIndex.push(groupIndex);
for (let i = 0; i < group.images.length; i += cols) {
rows.push({ type: "tiles", images: group.images.slice(i, i + cols) });
rowToGroupIndex.push(groupIndex);
}
});
return { rows, rowToGroupIndex, groupFirstRow };
}, [groups, cols]);
const { rows, rowToGroupIndex, groupFirstRow } = useMemo(
() => buildTimelineRows(groups, cols),
[groups, cols]
)
const estimateSize = useCallback(
(index: number): number =>
rows[index]?.type === "header" ? HEADER_HEIGHT : tileSize + GAP,
[rows, tileSize],
);
(index: number): number => (rows[index]?.type === 'header' ? HEADER_HEIGHT : tileSize + GAP),
[rows, tileSize]
)
const virtualizer = useVirtualizer({
count: rows.length,
@@ -162,151 +71,101 @@ export function Timeline() {
estimateSize,
overscan: 6,
paddingStart: GAP,
});
})
// Refs so the scroll handler can read the latest mappings without re-binding.
const rowToGroupIndexRef = useRef(rowToGroupIndex);
rowToGroupIndexRef.current = rowToGroupIndex;
const groupFirstRowRef = useRef(groupFirstRow);
groupFirstRowRef.current = groupFirstRow;
const rowToGroupIndexRef = useRef(rowToGroupIndex)
rowToGroupIndexRef.current = rowToGroupIndex
const groupFirstRowRef = useRef(groupFirstRow)
groupFirstRowRef.current = groupFirstRow
useEffect(() => {
virtualizer.measure();
}, [cols, virtualizer]);
virtualizer.measure()
}, [cols, virtualizer])
const handleScroll = useCallback(() => {
const el = parentRef.current;
if (!el) return;
const el = parentRef.current
if (!el) return
// Active month = the group owning the first row still visible at the top.
const scrollTop = el.scrollTop;
const items = virtualizer.getVirtualItems();
let activeRow = items.length > 0 ? items[0].index : 0;
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;
activeRow = item.index
break
}
}
setActiveGroupIndex(rowToGroupIndexRef.current[activeRow] ?? 0);
setActiveGroupIndex(rowToGroupIndexRef.current[activeRow] ?? 0)
if (scrollTop < 24) return;
const nearBottom = scrollTop + el.clientHeight >= el.scrollHeight - 600;
if (scrollTop < 24) return
const nearBottom = scrollTop + el.clientHeight >= el.scrollHeight - 600
if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages();
void loadMoreImages()
}
}, [virtualizer, images.length, loadMoreImages, loadingImages, totalImages]);
}, [virtualizer, images.length, loadMoreImages, loadingImages, totalImages])
useEffect(() => {
const el = parentRef.current;
if (!el) return;
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
useEffect(() => {
const close = (e: PointerEvent) => {
if ((e.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return;
setContextMenu(null);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setContextMenu(null);
};
window.addEventListener("pointerdown", close);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("pointerdown", close);
window.removeEventListener("keydown", onKey);
};
}, []);
const el = parentRef.current
if (!el) return
el.addEventListener('scroll', handleScroll, { passive: true })
return () => el.removeEventListener('scroll', handleScroll)
}, [handleScroll])
const scrollToGroup = useCallback(
(groupIndex: number) => {
const row = groupFirstRowRef.current[groupIndex] ?? 0;
virtualizer.scrollToIndex(row, { align: "start" });
const row = groupFirstRowRef.current[groupIndex] ?? 0
virtualizer.scrollToIndex(row, { align: 'start' })
},
[virtualizer],
);
[virtualizer]
)
return (
// Outer flex-row: fills remaining height in <main>'s flex-col, then
// splits horizontally between the scroll area and the scrubber.
<div className="flex flex-1 min-h-0 bg-gray-950">
<div className="flex min-h-0 flex-1 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"
>
<div ref={parentRef} className="relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
{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>
<TimelineLoadingState />
) : 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>
<TimelineEmptyState imageLoadError={imageLoadError} />
) : (
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const row = rows[virtualItem.index];
if (!row) return null;
const row = rows[virtualItem.index]
if (!row) return null
return (
<div
key={virtualItem.key}
style={{
position: "absolute",
position: 'absolute',
top: virtualItem.start,
width: "100%",
width: '100%',
height: virtualItem.size,
}}
>
{row.type === "header" ? (
<div
className="flex items-center gap-3 px-4"
style={{ height: HEADER_HEIGHT }}
>
<span className="text-sm font-semibold text-white/80 shrink-0">
{row.type === 'header' ? (
<div className="flex items-center gap-3 px-4" style={{ height: HEADER_HEIGHT }}>
<span className="shrink-0 text-sm font-semibold text-white/80">
{row.group.label}
</span>
<span className="text-xs text-white/25 shrink-0 tabular-nums">
<span className="shrink-0 text-xs text-white/25 tabular-nums">
{row.group.images.length}
</span>
<div className="flex-1 h-px bg-white/[0.06]" />
<div className="h-px flex-1 bg-white/[0.06]" />
</div>
) : (
<div
style={{
display: "grid",
display: 'grid',
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
gap: GAP,
paddingLeft: GAP,
paddingRight: GAP,
paddingBottom: GAP,
boxSizing: "border-box",
boxSizing: 'border-box',
}}
>
{row.images.map((image) => (
@@ -315,22 +174,22 @@ export function Timeline() {
image={image}
onClick={() => openImage(image)}
onContextMenu={(event) => {
event.preventDefault();
setContextMenu({ x: event.clientX, y: event.clientY, image });
event.preventDefault()
setContextMenu({ x: event.clientX, y: event.clientY, image })
}}
/>
))}
</div>
)}
</div>
);
)
})}
</div>
)}
{images.length > 0 && loadingImages ? (
<div className="flex justify-center py-8">
<div className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/20 border-t-white/60" />
</div>
) : null}
</div>
@@ -338,7 +197,7 @@ export function Timeline() {
{/* 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"
className="flex flex-col items-center gap-0.5 overflow-y-auto border-l border-white/[0.04] py-2"
style={{ width: SCRUBBER_WIDTH }}
>
{scrubberYears.map((yearEntry) => (
@@ -353,7 +212,7 @@ export function Timeline() {
) : null}
{contextMenu ? (
<ContextMenu
<ImageContextMenu
x={contextMenu.x}
y={contextMenu.y}
image={contextMenu.image}
@@ -361,53 +220,5 @@ export function Timeline() {
/>
) : null}
</div>
);
}
interface ScrubberYearBlockProps {
yearEntry: ScrubberYear;
activeGroupIndex: number;
onScrollTo: (index: number) => void;
}
function ScrubberYearBlock({ yearEntry, activeGroupIndex, onScrollTo }: ScrubberYearBlockProps) {
const isYearActive = yearEntry.months.some((m) => m.groupIndex === activeGroupIndex);
return (
<div className="w-full flex flex-col items-center">
<Tooltip label={yearEntry.year} anchorToCursor>
<button
className={`w-full text-center py-0.5 text-[10px] font-semibold tracking-wide transition-colors ${
isYearActive ? "text-white/80" : "text-white/30 hover:text-white/55"
}`}
onClick={() => onScrollTo(yearEntry.firstGroupIndex)}
>
{yearEntry.year}
</button>
</Tooltip>
<div
className="grid gap-[3px] pb-1.5"
style={{ gridTemplateColumns: "repeat(3, 10px)" }}
>
{Array.from({ length: 12 }, (_, i) => {
const monthNum = i + 1;
const monthEntry = yearEntry.months.find((m) => m.monthNum === monthNum);
const isActive = monthEntry !== undefined && monthEntry.groupIndex === activeGroupIndex;
if (!monthEntry) {
return <span key={monthNum} className="h-[10px] w-[10px]" />;
}
return (
<Tooltip key={monthNum} label={`${monthEntry.label} ${yearEntry.year}`} anchorToCursor>
<button
onClick={() => onScrollTo(monthEntry.groupIndex)}
className={`h-[10px] w-[10px] rounded-full transition-colors ${
isActive ? "bg-white/70" : "bg-white/15 hover:bg-white/40"
}`}
/>
</Tooltip>
);
})}
</div>
</div>
);
)
}
+87 -91
View File
@@ -1,14 +1,15 @@
import { useState, useEffect, useRef } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useGalleryStore, AppTheme } from "../store";
import { PhokusMark } from "./PhokusMark";
import { Tooltip } from "./Tooltip";
import { useState, useEffect, useRef } from 'react'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useGalleryStore, AppTheme } from '../store'
import { ContextMenu, MenuItem, MenuLabel } from './menu'
import { PhokusMark } from './PhokusMark'
import { Tooltip } from './Tooltip'
const THEME_OPTIONS: { value: AppTheme; label: string }[] = [
{ value: "phokus", label: "Phokus" },
{ value: "subtle-light", label: "Subtle Light" },
{ value: "conventional-dark", label: "Conventional Dark" },
];
{ value: 'phokus', label: 'Phokus' },
{ value: 'subtle-light', label: 'Subtle Light' },
{ value: 'conventional-dark', label: 'Conventional Dark' },
]
// SVG icons for window controls
function MinimizeIcon() {
@@ -16,7 +17,7 @@ function MinimizeIcon() {
<svg width="10" height="2" viewBox="0 0 10 2" fill="none">
<rect y="0.5" width="10" height="1" rx="0.5" fill="currentColor" />
</svg>
);
)
}
function MaximizeIcon() {
@@ -24,16 +25,25 @@ function MaximizeIcon() {
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<rect x="0.5" y="0.5" width="9" height="9" rx="1.5" stroke="currentColor" strokeWidth="1" />
</svg>
);
)
}
function RestoreIcon() {
return (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<rect x="2.5" y="0.5" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1" />
<rect x="0.5" y="2.5" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1" fill="var(--color-gray-950)" />
<rect
x="0.5"
y="2.5"
width="7"
height="7"
rx="1.5"
stroke="currentColor"
strokeWidth="1"
fill="var(--color-gray-950)"
/>
</svg>
);
)
}
function CloseIcon() {
@@ -41,67 +51,52 @@ function CloseIcon() {
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
</svg>
);
)
}
export function TitleBar() {
const [isMaximized, setIsMaximized] = useState(false);
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
const theme = useGalleryStore((state) => state.theme);
const setTheme = useGalleryStore((state) => state.setTheme);
const updateStatus = useGalleryStore((state) => state.updateStatus);
const updateVersion = useGalleryStore((state) => state.updateVersion);
const installUpdate = useGalleryStore((state) => state.installUpdate);
const appWindow = getCurrentWindow();
const [isMaximized, setIsMaximized] = useState(false)
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen)
const theme = useGalleryStore((state) => state.theme)
const setTheme = useGalleryStore((state) => state.setTheme)
const updateStatus = useGalleryStore((state) => state.updateStatus)
const updateVersion = useGalleryStore((state) => state.updateVersion)
const installUpdate = useGalleryStore((state) => state.installUpdate)
const appWindow = getCurrentWindow()
// Right-clicking the settings cog opens a quick theme switcher, anchored under
// the cog so it never overflows the right edge of the window.
const settingsBtnRef = useRef<HTMLButtonElement>(null);
const themeMenuRef = useRef<HTMLDivElement>(null);
const [themeMenu, setThemeMenu] = useState<{ top: number; right: number } | null>(null);
useEffect(() => {
if (!themeMenu) return;
const handleDown = (e: MouseEvent) => {
if (themeMenuRef.current && !themeMenuRef.current.contains(e.target as Node)) setThemeMenu(null);
};
const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") setThemeMenu(null); };
document.addEventListener("mousedown", handleDown);
document.addEventListener("keydown", handleKey);
return () => {
document.removeEventListener("mousedown", handleDown);
document.removeEventListener("keydown", handleKey);
};
}, [themeMenu]);
const settingsBtnRef = useRef<HTMLButtonElement>(null)
const [themeMenu, setThemeMenu] = useState<{ x: number; y: number } | null>(null)
const handleSettingsContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
const rect = settingsBtnRef.current?.getBoundingClientRect();
if (!rect) return;
setThemeMenu({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
};
e.preventDefault()
const rect = settingsBtnRef.current?.getBoundingClientRect()
if (!rect) return
setThemeMenu({ x: rect.right, y: rect.bottom + 4 })
}
useEffect(() => {
// Get initial maximized state
appWindow.isMaximized().then(setIsMaximized);
appWindow.isMaximized().then(setIsMaximized)
// Listen for resize events to update maximized state
const unlisten = appWindow.onResized(() => {
appWindow.isMaximized().then(setIsMaximized);
});
appWindow.isMaximized().then(setIsMaximized)
})
return () => {
unlisten.then((fn) => fn());
};
}, []);
unlisten.then((fn) => fn())
}
}, [])
const handleMinimize = () => appWindow.minimize();
const handleMaximize = () => appWindow.toggleMaximize();
const handleClose = () => appWindow.close();
const handleMinimize = () => appWindow.minimize()
const handleMaximize = () => appWindow.toggleMaximize()
const handleClose = () => appWindow.close()
// An update is waiting for the user to act. Covers the "clicked Later" case too,
// since dismissing the toast doesn't change updateStatus.
const updatePending = updateStatus === "available";
const updatePending = updateStatus === 'available'
return (
// data-tauri-drag-region is the recommended Tauri approach for drag regions.
@@ -109,31 +104,31 @@ export function TitleBar() {
<div
data-tauri-drag-region
className="titlebar relative z-50 flex h-9 shrink-0 items-center bg-gray-950 select-none"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
>
{/* App icon + name — left side. When an update is waiting, the iris lights
up its focal point and the chip becomes a button that re-opens the prompt. */}
<div className="flex items-center gap-2 pl-3 pr-4">
<div className="flex items-center gap-2 pr-4 pl-3">
{updatePending ? (
<div style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}>
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
{/* Instant tooltip (delay 0) — this affordance should read immediately. */}
<Tooltip label={`Click to update — v${updateVersion}`} delay={0} align="start">
<button
onClick={() => void installUpdate()}
aria-label={`Update available — click to update to Phokus v${updateVersion}`}
className="relative flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300 transition-colors hover:bg-white/12"
className="relative flex h-5 w-5 items-center justify-center overflow-hidden rounded-md bg-white/8 text-gray-300 transition-colors hover:bg-white/12"
>
<span className="pointer-events-none absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/60 animate-ping" />
<span className="pointer-events-none absolute top-1/2 left-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 animate-ping rounded-full bg-amber-400/60" />
<PhokusMark className="relative h-4 w-4" dotClassName="fill-amber-400" />
</button>
</Tooltip>
</div>
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300">
<div className="flex h-5 w-5 items-center justify-center overflow-hidden rounded-md bg-white/8 text-gray-300">
<PhokusMark className="h-4 w-4" />
</div>
)}
<span className="text-[11px] font-semibold text-gray-400 tracking-wide">Phokus</span>
<span className="text-[11px] font-semibold tracking-wide text-gray-400">Phokus</span>
</div>
{/* Spacer — draggable region fills here */}
@@ -141,8 +136,8 @@ export function TitleBar() {
{/* Window control buttons — right side, non-draggable */}
<div
className="flex items-stretch h-full"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
className="flex h-full items-stretch"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<Tooltip label="Settings (right-click to switch theme)" anchorToCursor>
<button
@@ -153,8 +148,18 @@ export function TitleBar() {
className="group flex h-full w-10 items-center justify-center text-gray-600 transition-colors duration-100 hover:bg-white/6 hover:text-gray-300"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.607 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.607 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
</Tooltip>
@@ -170,7 +175,7 @@ export function TitleBar() {
{/* Maximize / Restore */}
<button
onClick={handleMaximize}
title={isMaximized ? "Restore" : "Maximize"}
title={isMaximized ? 'Restore' : 'Maximize'}
className="group flex h-full w-10 items-center justify-center text-gray-600 transition-colors duration-100 hover:bg-white/6 hover:text-gray-300"
>
{isMaximized ? <RestoreIcon /> : <MaximizeIcon />}
@@ -188,33 +193,24 @@ export function TitleBar() {
{/* Quick theme switcher — opened by right-clicking the settings cog. */}
{themeMenu && (
<div
ref={themeMenuRef}
className="fixed z-50 min-w-[170px] py-1 px-1 rounded-lg bg-gray-900 border border-white/10 shadow-xl shadow-black/50"
style={{ top: themeMenu.top, right: themeMenu.right, WebkitAppRegion: "no-drag" } as React.CSSProperties}
<ContextMenu
x={themeMenu.x}
y={themeMenu.y}
size="sm"
align="end"
onClose={() => setThemeMenu(null)}
>
<div className="px-3 pt-1 pb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-500">Theme</div>
{THEME_OPTIONS.map((opt) => {
const active = theme === opt.value;
return (
<button
<MenuLabel>Theme</MenuLabel>
{THEME_OPTIONS.map((opt) => (
<MenuItem
key={opt.value}
className={`flex w-full items-center justify-between gap-3 rounded-md px-3 py-1.5 text-left text-[12px] transition-colors ${
active ? "bg-white/8 text-white" : "text-gray-300 hover:bg-white/8 hover:text-white"
}`}
onClick={() => { setTheme(opt.value); setThemeMenu(null); }}
>
{opt.label}
{active && (
<svg className="h-3.5 w-3.5 text-sky-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
);
})}
</div>
label={opt.label}
checked={theme === opt.value}
onSelect={() => setTheme(opt.value)}
/>
))}
</ContextMenu>
)}
</div>
);
)
}
+17 -491
View File
@@ -1,498 +1,24 @@
import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { ColorFilter } from "./ColorFilter";
import { Tooltip } from "./Tooltip";
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
{ value: "date_desc", label: "Newest first" },
{ value: "date_asc", label: "Oldest first" },
{ value: "taken_desc", label: "Taken: newest" },
{ value: "taken_asc", label: "Taken: oldest" },
{ value: "name_asc", label: "Name AZ" },
{ value: "name_desc", label: "Name ZA" },
{ value: "rating_desc", label: "Highest rated" },
{ value: "rating_asc", label: "Lowest rated" },
{ value: "size_desc", label: "Largest first" },
{ value: "size_asc", label: "Smallest first" },
];
const VIDEO_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
{ value: "duration_desc", label: "Longest first" },
{ value: "duration_asc", label: "Shortest first" },
];
function getSortOptions(mediaFilter: MediaFilter) {
if (mediaFilter === "video") {
return [...BASE_SORT_OPTIONS, ...VIDEO_SORT_OPTIONS];
}
return BASE_SORT_OPTIONS;
}
function SortDropdown({
value,
onChange,
options,
}: {
value: SortOrder;
onChange: (v: SortOrder) => void;
options: { value: SortOrder; label: string }[];
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const current = options.find((o) => o.value === value) ?? BASE_SORT_OPTIONS[0];
useEffect(() => {
const close = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
window.addEventListener("pointerdown", close);
return () => window.removeEventListener("pointerdown", close);
}, []);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${
open
? "border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white"
: "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:border-gray-700/40 light-theme:text-gray-600 light-theme:hover:border-gray-700 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
>
<span>{current?.label ?? "Sort"}</span>
<svg
className={`h-3 w-3 text-gray-500 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open ? (
<div className="absolute right-0 top-full z-30 mt-1.5 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 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); }}
>
{option.label}
{option.value === value ? (
<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>
);
}
function FilterPill({
label,
active,
onClick,
variant = "default",
}: {
label: string;
active: boolean;
onClick: () => void;
variant?: "default" | "amber";
}) {
const activeClass =
variant === "amber"
? "bg-amber-500/15 text-amber-300 border border-amber-500/30 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:border-amber-500/50"
: "bg-white/10 text-white light-theme:bg-gray-900 light-theme:text-white";
return (
<button
className={`shrink-0 whitespace-nowrap rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
active
? activeClass
: "text-gray-500 hover:text-gray-200 hover:bg-white/5 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={onClick}
>
{label}
</button>
);
}
function commandPrefix(command: SearchCommand | null): string | null {
switch (command) {
case "semantic":
return "/s";
case "tag":
return "/t";
default:
return null;
}
}
function composeSearchValue(command: SearchCommand | null, query: string): string {
const prefix = commandPrefix(command);
if (!prefix) return query;
return query.length > 0 ? `${prefix} ${query}` : prefix;
}
import { SortControl } from './toolbar/SortControl'
import { ToolbarFilters } from './toolbar/ToolbarFilters'
import { ToolbarSearch } from './toolbar/ToolbarSearch'
import { ToolbarTitle } from './toolbar/ToolbarTitle'
import { useToolbarSearch } from './toolbar/useToolbarSearch'
import { ZoomControl } from './toolbar/ZoomControl'
export function Toolbar() {
const search = useGalleryStore((state) => state.search);
const setSearch = useGalleryStore((state) => state.setSearch);
const clearSearch = useGalleryStore((state) => state.clearSearch);
const sort = useGalleryStore((state) => state.sort);
const setSort = useGalleryStore((state) => state.setSort);
const totalImages = useGalleryStore((state) => state.totalImages);
const loadedCount = useGalleryStore((state) => state.loadedCount);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const collectionTitle = useGalleryStore((state) => state.collectionTitle);
const folders = useGalleryStore((state) => state.folders);
const mediaFilter = useGalleryStore((state) => state.mediaFilter);
const setMediaFilter = useGalleryStore((state) => state.setMediaFilter);
const favoritesOnly = useGalleryStore((state) => state.favoritesOnly);
const setFavoritesOnly = useGalleryStore((state) => state.setFavoritesOnly);
const minimumRating = useGalleryStore((state) => state.minimumRating);
const setMinimumRating = useGalleryStore((state) => state.setMinimumRating);
const failedEmbeddingsOnly = useGalleryStore((state) => state.failedEmbeddingsOnly);
const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly);
const failedTaggingOnly = useGalleryStore((state) => state.failedTaggingOnly);
const setFailedTaggingOnly = useGalleryStore((state) => state.setFailedTaggingOnly);
const colorFilter = useGalleryStore((state) => state.colorFilter);
const setColorFilter = useGalleryStore((state) => state.setColorFilter);
const similarScope = useGalleryStore((state) => state.similarScope);
const setSimilarScope = useGalleryStore((state) => state.setSimilarScope);
const similarSourceAlbumId = useGalleryStore((state) => state.similarSourceAlbumId);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
const activeView = useGalleryStore((state) => state.activeView);
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
const hasAnyFailedTagging = Object.values(mediaJobProgress).some((p) => p.tagging_failed > 0);
const [searchCommand, setSearchCommand] = useState<SearchCommand | null>(null);
const [searchQuery, setSearchQuery] = useState(search);
const [searchPanelOpen, setSearchPanelOpen] = useState(false);
const [tagSuggestions, setTagSuggestions] = useState<ExploreTagEntry[]>([]);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const suggestDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const searchShellRef = useRef<HTMLDivElement>(null);
const userHasTyped = useRef(false);
const selectedFolder = folders.find((folder) => folder.id === selectedFolderId);
const title = collectionTitle ?? (selectedFolder ? selectedFolder.name : "All Media");
const tileSize = tileSizeForZoom(zoomPreset);
const sortOptions = getSortOptions(mediaFilter);
const hasActiveSearch = search.trim().length > 0;
const parsedSearch = parseSearchValue(composeSearchValue(searchCommand, searchQuery));
const isSimilarResults = collectionTitle === "Similar Images";
// Album scope is offered while browsing an album (where it's the default) and
// while viewing similar/region results that were launched from an album.
const showAlbumScope =
activeView === "album" ||
(similarSourceAlbumId !== null &&
(collectionTitle === "Similar Images" || collectionTitle === "Region Search Results"));
// If current sort is video-only but we switched away from video filter, reset to date_desc
useEffect(() => {
if (mediaFilter !== "video" && (sort === "duration_asc" || sort === "duration_desc")) {
setSort("date_desc");
}
}, [mediaFilter, sort, setSort]);
useEffect(() => {
if (!userHasTyped.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => { setSearch(composeSearchValue(searchCommand, searchQuery)); }, 200);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [searchCommand, searchQuery, setSearch]);
useEffect(() => {
const parsed = parseSearchValue(search);
setSearchCommand(parsed.prefix && parsed.mode !== "filename" ? parsed.mode : null);
setSearchQuery(parsed.prefix ? parsed.query : search);
}, [search]);
// Fetch tag suggestions when in tag mode
useEffect(() => {
if (searchCommand !== "tag") {
setTagSuggestions([]);
return;
}
if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current);
suggestDebounceRef.current = setTimeout(async () => {
try {
const results = await invoke<ExploreTagEntry[]>("search_tags_autocomplete", {
params: { query: searchQuery.trim(), folder_id: selectedFolderId ?? null, limit: 10 },
});
setTagSuggestions(Array.isArray(results) ? results : []);
} catch {
setTagSuggestions([]);
}
}, 120);
return () => { if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current); };
}, [searchCommand, searchQuery, selectedFolderId]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const activeElement = document.activeElement;
const searchFocused = activeElement === searchInputRef.current;
if (event.key === "Escape" && (searchFocused || hasActiveSearch)) {
event.preventDefault();
setSearchCommand(null);
setSearchQuery("");
clearSearch();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [clearSearch, hasActiveSearch]);
useEffect(() => {
const close = (event: PointerEvent) => {
if (searchShellRef.current?.contains(event.target as Node)) return;
setSearchPanelOpen(false);
};
window.addEventListener("pointerdown", close);
return () => window.removeEventListener("pointerdown", close);
}, []);
const showTagSuggestions = searchCommand === "tag" && searchPanelOpen;
const showCommandHints = !searchCommand && searchPanelOpen;
const searchState = useToolbarSearch()
return (
<div className="relative z-40 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl">
{/* Primary row */}
<div className="flex items-center gap-2 px-3 h-12 lg:gap-3 lg:px-5">
{/* Title + count */}
<div className="flex items-baseline gap-2.5 min-w-0 shrink-0">
<h2 className="text-[15px] font-semibold text-white truncate max-w-32 lg:max-w-48">{title}</h2>
<span className="text-xs text-gray-600 shrink-0">
{loadedCount < totalImages
? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}`
: totalImages.toLocaleString()}
</span>
{hasActiveSearch && (
<span className="rounded-md border border-blue-500/20 bg-blue-500/10 px-2 py-0.5 text-[10px] uppercase tracking-[0.14em] text-blue-300 shrink-0">
{searchModeLabel(parsedSearch.mode)}
</span>
)}
</div>
{activeView === "timeline" ? <FolderScopeDropdown /> : null}
<div className="flex h-12 items-center gap-2 px-3 lg:gap-3 lg:px-5">
<ToolbarTitle parsedSearch={searchState.parsedSearch} />
<div className="flex-1" />
{/* Search */}
<div ref={searchShellRef} className="relative">
<div className="flex items-center rounded-lg border border-white/8 bg-white/5 overflow-hidden">
<div className="relative">
<svg className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-600"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M21 21l-4.35-4.35M17 11A6 6 0 115 11a6 6 0 0112 0z" />
</svg>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(event) => {
userHasTyped.current = true;
const nextValue = event.target.value;
if (!searchCommand) {
const parsed = parseSearchValue(nextValue);
if (parsed.prefix) {
setSearchCommand(parsed.mode === "filename" ? null : parsed.mode);
setSearchQuery(parsed.query);
return;
}
}
setSearchQuery(nextValue);
}}
onKeyDown={(event) => {
if (event.key === "Backspace" && searchQuery.length === 0 && searchCommand !== null) {
event.preventDefault();
setSearchCommand(null);
}
}}
onFocus={() => setSearchPanelOpen(true)}
placeholder="Search files, or use /s /t"
className={`w-40 bg-transparent py-1.5 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors lg:w-52 xl:w-64 ${searchCommand !== null ? "pl-16" : "pl-8"}`}
/>
{searchCommand !== null ? (
<div className="absolute left-8 top-1/2 flex -translate-y-1/2">
<Tooltip label="Remove search command" anchorToCursor>
<button
type="button"
className="rounded-md border border-white/10 bg-white/[0.05] px-1.5 py-0.5 font-mono text-[11px] text-gray-300 transition-colors hover:bg-white/[0.08] hover:text-white"
onClick={() => { setSearchCommand(null); setTagSuggestions([]); }}
>
{commandPrefix(searchCommand)}
</button>
</Tooltip>
</div>
) : null}
{searchQuery.trim().length > 0 || searchCommand !== null ? (
<div className="absolute right-2 top-1/2 flex -translate-y-1/2">
<Tooltip label="Clear search" anchorToCursor>
<button
aria-label="Clear search"
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-white/5 hover:text-gray-200"
onClick={() => {
setSearchCommand(null);
setSearchQuery("");
setTagSuggestions([]);
clearSearch();
}}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Tooltip>
</div>
) : null}
</div>
</div>
{/* Tag autocomplete suggestions */}
{showTagSuggestions && tagSuggestions.length > 0 ? (
<div className="absolute left-0 top-full z-50 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 py-1 shadow-2xl backdrop-blur">
{tagSuggestions.map((entry) => (
<button
key={entry.tag}
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left transition-colors hover:bg-white/[0.05]"
onMouseDown={(e) => {
// mousedown fires before input blur, so we prevent losing focus
e.preventDefault();
userHasTyped.current = true;
setSearchQuery(entry.tag);
setSearch(`/t ${entry.tag}`);
setSearchPanelOpen(false);
searchInputRef.current?.blur();
}}
>
<span className="text-sm text-white/88">{entry.tag}</span>
<span className="shrink-0 text-[11px] tabular-nums text-white/30">{entry.count.toLocaleString()}</span>
</button>
))}
</div>
) : null}
{/* Tag mode with no suggestions yet — show a brief hint */}
{showTagSuggestions && tagSuggestions.length === 0 && searchQuery.trim().length > 0 ? (
<div className="absolute left-0 top-full z-50 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 px-3 py-2.5 shadow-2xl backdrop-blur">
<p className="text-xs text-white/25">No matching tags</p>
</div>
) : null}
{/* Semantic mode hint */}
{searchCommand === "semantic" && searchPanelOpen ? (
<div className="absolute left-0 top-full z-50 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 px-3 py-2.5 shadow-2xl backdrop-blur">
<p className="text-xs text-white/40">Search by meaning and visual concepts</p>
</div>
) : null}
{/* Command hints — only shown when no command is active */}
{showCommandHints ? (
<div className="absolute left-0 top-full z-50 mt-1.5 w-64 rounded-xl border border-white/10 bg-gray-950/98 py-1 shadow-2xl backdrop-blur">
{(
[
{ command: "tag" as SearchCommand, prefix: "/t", label: "Tags", description: "Search user and AI tags" },
{ command: "semantic" as SearchCommand, prefix: "/s", label: "Semantic", description: "Search by meaning, object, mood" },
] as const
).map((option) => (
<button
key={option.prefix}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-white/[0.05]"
onMouseDown={(e) => {
e.preventDefault();
userHasTyped.current = true;
setSearchCommand(option.command);
searchInputRef.current?.focus();
}}
>
<span className="rounded border border-white/10 bg-white/[0.04] px-1.5 py-0.5 font-mono text-[11px] text-gray-400">
{option.prefix}
</span>
<div>
<p className="text-sm text-gray-200">{option.label}</p>
<p className="text-xs text-gray-500">{option.description}</p>
</div>
</button>
))}
</div>
) : null}
</div>
{/* Sort */}
<SortDropdown value={sort} onChange={setSort} options={sortOptions} />
{/* Divider */}
<div className="h-4 w-px bg-white/10 shrink-0" />
{/* Zoom */}
<div className="flex items-center rounded-lg border border-white/8 overflow-hidden light-theme:border-gray-700/40">
{(["compact", "comfortable", "detail"] as const).map((preset, i) => (
<Tooltip key={preset} label={`${tileSize}px tiles`} followCursor>
<button
className={`px-2.5 py-1.5 text-xs font-medium transition-colors ${
i > 0 ? "border-l border-white/8" : ""
} ${
zoomPreset === preset
? "bg-white/10 text-white 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"
}`}
onClick={() => setZoomPreset(preset)}
>
{preset === "compact" ? "S" : preset === "comfortable" ? "M" : "L"}
</button>
</Tooltip>
))}
</div>
</div>
{/* Filter row — pills scroll horizontally on narrow widths so they never
squash or stack; the color filter stays pinned to the right. */}
<div className="flex items-center gap-1 px-4 pb-1.5">
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly && minimumRating === 0 && colorFilter === null} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); setColorFilter(null); }} />
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Rated" active={minimumRating === 1} onClick={() => { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="4★+" active={minimumRating === 4} onClick={() => { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
{showAlbumScope ? (
<FilterPill label="Similar: Album" active={similarScope === "current_album"} onClick={() => setSimilarScope("current_album")} />
) : null}
<FilterPill label="Similar: Folder" active={similarScope === "current_folder"} onClick={() => setSimilarScope("current_folder")} />
<FilterPill label="Similar: All" active={similarScope === "all_media"} onClick={() => setSimilarScope("all_media")} />
{hasAnyFailedEmbeddings ? (
<FilterPill
label="Failed Embeddings"
active={failedEmbeddingsOnly}
variant="amber"
onClick={() => setFailedEmbeddingsOnly(!failedEmbeddingsOnly)}
/>
) : null}
{hasAnyFailedTagging ? (
<FilterPill
label="Failed Tags"
active={failedTaggingOnly}
variant="amber"
onClick={() => setFailedTaggingOnly(!failedTaggingOnly)}
/>
) : null}
{isSimilarResults ? <span className="ml-2 shrink-0 whitespace-nowrap text-[11px] text-gray-500">Current similar scope: {similarScope === "current_album" ? "this album" : similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
</div>
<ColorFilter />
</div>
</div>
);
<ToolbarSearch searchState={searchState} />
<SortControl />
<div className="h-4 w-px shrink-0 bg-white/10" />
<ZoomControl />
</div>
<ToolbarFilters />
</div>
)
}
+86 -79
View File
@@ -1,22 +1,23 @@
import { MouseEvent, ReactNode, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { motion } from "framer-motion";
import { MouseEvent, ReactNode, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { motion } from 'framer-motion'
type TooltipSide = "top" | "bottom" | "left" | "right";
type TooltipAlign = "center" | "start" | "end";
type TooltipSide = 'top' | 'bottom' | 'left' | 'right'
type TooltipAlign = 'center' | 'start' | 'end'
// Horizontal alignment only applies to top/bottom; left/right center vertically.
function positionClasses(side: TooltipSide, align: TooltipAlign): string {
if (side === "left") return "right-full top-1/2 mr-1.5 -translate-y-1/2";
if (side === "right") return "left-full top-1/2 ml-1.5 -translate-y-1/2";
const vertical = side === "top" ? "bottom-full mb-1.5" : "top-full mt-1.5";
const horizontal = align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
return `${vertical} ${horizontal}`;
if (side === 'left') return 'right-full top-1/2 mr-1.5 -translate-y-1/2'
if (side === 'right') return 'left-full top-1/2 ml-1.5 -translate-y-1/2'
const vertical = side === 'top' ? 'bottom-full mb-1.5' : 'top-full mt-1.5'
const horizontal =
align === 'start' ? 'left-0' : align === 'end' ? 'right-0' : 'left-1/2 -translate-x-1/2'
return `${vertical} ${horizontal}`
}
const BASE_CLASSES =
"pointer-events-none z-50 whitespace-nowrap rounded-md border border-white/10 bg-gray-800 px-2 py-1 text-[11px] text-gray-200 shadow-lg";
const STATIC_CLASSES = `${BASE_CLASSES} transition-opacity duration-100`;
'pointer-events-none z-50 whitespace-nowrap rounded-md border border-white/10 bg-gray-800 px-2 py-1 text-[11px] text-gray-200 shadow-lg'
const STATIC_CLASSES = `${BASE_CLASSES} transition-opacity duration-100`
/**
* Lightweight custom tooltip — fades in (faster than the native one) after a
@@ -27,98 +28,98 @@ const STATIC_CLASSES = `${BASE_CLASSES} transition-opacity duration-100`;
export function Tooltip({
label,
delay = 400,
side = "bottom",
align = "center",
side = 'bottom',
align = 'center',
block = false,
anchorToCursor = false,
followCursor = false,
disabled = false,
className = "",
className = '',
children,
}: {
label: ReactNode;
label: ReactNode
/** Milliseconds the pointer must hover before the tooltip appears (0 = instant). */
delay?: number;
side?: TooltipSide;
delay?: number
side?: TooltipSide
/** Horizontal alignment for top/bottom tooltips. */
align?: TooltipAlign;
align?: TooltipAlign
/** Full-width block wrapper (e.g. wrapping a grid cell) instead of inline. */
block?: boolean;
block?: boolean
/** Position at the cursor when shown, without following subsequent movement. */
anchorToCursor?: boolean;
anchorToCursor?: boolean
/** Track the cursor (fixed position) instead of anchoring to a side. */
followCursor?: boolean;
followCursor?: boolean
/** Render the wrapper but suppress tooltip display. */
disabled?: boolean;
disabled?: boolean
/** Extra classes for the wrapper (e.g. layout). */
className?: string;
children: ReactNode;
className?: string
children: ReactNode
}) {
const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({ x: 0, y: 0 });
const [mounted, setMounted] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const frame = useRef<number | undefined>(undefined);
const tooltipRef = useRef<HTMLSpanElement>(null);
const [visible, setVisible] = useState(false)
const [coords, setCoords] = useState({ x: 0, y: 0 })
const [mounted, setMounted] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const frame = useRef<number | undefined>(undefined)
const tooltipRef = useRef<HTMLSpanElement>(null)
// Resolve a viewport-safe position near the cursor: below-right by default,
// but flipped above the cursor if it would overflow the bottom edge, and
// clamped so it never runs off the right/left.
const place = (clientX: number, clientY: number) => {
const tip = tooltipRef.current;
const tipWidth = tip?.offsetWidth ?? 0;
const tipHeight = tip?.offsetHeight ?? 24;
const gap = 16;
let top = clientY + gap;
const tip = tooltipRef.current
const tipWidth = tip?.offsetWidth ?? 0
const tipHeight = tip?.offsetHeight ?? 24
const gap = 16
let top = clientY + gap
if (top + tipHeight > window.innerHeight - 4) {
top = clientY - gap - tipHeight;
top = clientY - gap - tipHeight
}
let left = clientX + 14
if (left + tipWidth > window.innerWidth - 4) left = window.innerWidth - 4 - tipWidth
if (left < 4) left = 4
setCoords({ x: left, y: top })
}
let left = clientX + 14;
if (left + tipWidth > window.innerWidth - 4) left = window.innerWidth - 4 - tipWidth;
if (left < 4) left = 4;
setCoords({ x: left, y: top });
};
const clear = () => {
if (timer.current) clearTimeout(timer.current);
timer.current = undefined;
if (frame.current !== undefined) cancelAnimationFrame(frame.current);
frame.current = undefined;
};
const show = (event: MouseEvent<HTMLElement>) => {
if (disabled) return;
clear();
if (anchorToCursor || followCursor) place(event.clientX, event.clientY);
if (delay <= 0) {
setVisible(true);
return;
if (timer.current) clearTimeout(timer.current)
timer.current = undefined
if (frame.current !== undefined) cancelAnimationFrame(frame.current)
frame.current = undefined
}
const show = (event: MouseEvent<HTMLElement>) => {
if (disabled) return
clear()
if (anchorToCursor || followCursor) place(event.clientX, event.clientY)
if (delay <= 0) {
setVisible(true)
return
}
timer.current = setTimeout(() => setVisible(true), delay)
}
timer.current = setTimeout(() => setVisible(true), delay);
};
const hide = () => {
clear();
setVisible(false);
};
clear()
setVisible(false)
}
const move = (event: MouseEvent<HTMLElement>) => {
if (!followCursor) return;
const { clientX, clientY } = event;
if (frame.current !== undefined) cancelAnimationFrame(frame.current);
if (!followCursor) return
const { clientX, clientY } = event
if (frame.current !== undefined) cancelAnimationFrame(frame.current)
frame.current = requestAnimationFrame(() => {
frame.current = undefined;
place(clientX, clientY);
});
};
frame.current = undefined
place(clientX, clientY)
})
}
useEffect(() => {
setMounted(true);
return clear;
}, []);
setMounted(true)
return clear
}, [])
useEffect(() => {
if (disabled) hide();
}, [disabled]);
if (disabled) hide()
}, [disabled])
const Wrapper = block ? "div" : "span";
const Wrapper = block ? 'div' : 'span'
const cursorTooltip = (
<motion.span
ref={tooltipRef}
@@ -130,18 +131,22 @@ export function Tooltip({
initial={false}
animate={{ left: coords.x, top: coords.y, opacity: visible ? 1 : 0 }}
transition={{
left: followCursor ? { type: "spring", stiffness: 700, damping: 45, mass: 0.35 } : { duration: 0 },
top: followCursor ? { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } : { duration: 0 },
left: followCursor
? { type: 'spring', stiffness: 700, damping: 45, mass: 0.35 }
: { duration: 0 },
top: followCursor
? { type: 'spring', stiffness: 520, damping: 34, mass: 0.45 }
: { duration: 0 },
opacity: { duration: visible ? 0.1 : 0.05 },
}}
>
{label}
</motion.span>
);
)
return (
<Wrapper
className={`${block ? "relative block w-full" : "relative inline-flex"} ${className}`}
className={`${block ? 'relative block w-full' : 'relative inline-flex'} ${className}`}
onMouseEnter={show}
onMouseMove={followCursor ? move : undefined}
onMouseLeave={hide}
@@ -149,16 +154,18 @@ export function Tooltip({
>
{children}
{disabled ? null : anchorToCursor || followCursor ? (
mounted ? createPortal(cursorTooltip, document.body) : null
mounted ? (
createPortal(cursorTooltip, document.body)
) : null
) : (
<span
role="tooltip"
aria-hidden={!visible}
className={`absolute ${STATIC_CLASSES} ${positionClasses(side, align)} ${visible ? "opacity-100" : "opacity-0"}`}
className={`absolute ${STATIC_CLASSES} ${positionClasses(side, align)} ${visible ? 'opacity-100' : 'opacity-0'}`}
>
{label}
</span>
)}
</Wrapper>
);
)
}
+21 -15
View File
@@ -1,17 +1,19 @@
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../store";
import { AnimatePresence, motion } from 'framer-motion'
import { useGalleryStore } from '../store'
export function UpdateToast() {
const updateStatus = useGalleryStore((s) => s.updateStatus);
const updateVersion = useGalleryStore((s) => s.updateVersion);
const updateProgress = useGalleryStore((s) => s.updateProgress);
const updateDismissed = useGalleryStore((s) => s.updateDismissed);
const installUpdate = useGalleryStore((s) => s.installUpdate);
const dismissUpdate = useGalleryStore((s) => s.dismissUpdate);
const updateStatus = useGalleryStore((s) => s.updateStatus)
const updateVersion = useGalleryStore((s) => s.updateVersion)
const updateProgress = useGalleryStore((s) => s.updateProgress)
const updateDismissed = useGalleryStore((s) => s.updateDismissed)
const installUpdate = useGalleryStore((s) => s.installUpdate)
const dismissUpdate = useGalleryStore((s) => s.dismissUpdate)
const visible =
!updateDismissed &&
(updateStatus === "available" || updateStatus === "downloading" || updateStatus === "installing");
(updateStatus === 'available' ||
updateStatus === 'downloading' ||
updateStatus === 'installing')
return (
<AnimatePresence>
@@ -21,9 +23,9 @@ export function UpdateToast() {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 12 }}
transition={{ duration: 0.18 }}
className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-white/10 bg-gray-900 p-4 shadow-xl"
className="fixed right-4 bottom-4 z-50 w-80 rounded-lg border border-white/10 bg-gray-900 p-4 shadow-xl"
>
{updateStatus === "available" ? (
{updateStatus === 'available' ? (
<>
<p className="text-sm font-medium text-white">Update available</p>
<p className="mt-1 text-xs text-gray-500">
@@ -47,14 +49,18 @@ export function UpdateToast() {
) : (
<>
<p className="text-sm font-medium text-white">
{updateStatus === "installing" ? "Installing update..." : "Downloading update..."}
{updateStatus === 'installing' ? 'Installing update...' : 'Downloading update...'}
</p>
<div className="mt-3 h-1 overflow-hidden rounded-full bg-white/10">
<div
className={`h-full rounded-full bg-emerald-400/80 transition-[width] duration-200 ${
updateProgress === null ? "w-full animate-pulse" : ""
updateProgress === null ? 'w-full animate-pulse' : ''
}`}
style={updateProgress !== null ? { width: `${Math.round(updateProgress * 100)}%` } : undefined}
style={
updateProgress !== null
? { width: `${Math.round(updateProgress * 100)}%` }
: undefined
}
/>
</div>
<p className="mt-2 text-xs text-gray-600">The app will restart when it finishes.</p>
@@ -63,5 +69,5 @@ export function UpdateToast() {
</motion.div>
) : null}
</AnimatePresence>
);
)
}
+43 -439
View File
@@ -1,460 +1,64 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../store";
import { Tooltip } from "./Tooltip";
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
const CONTROLS_HIDE_DELAY_MS = 2500;
const SEEK_STEP_SECONDS = 5;
const VOLUME_STEP = 0.1;
// Session-wide playback preferences shared across player instances.
let persistedVolume = 1;
let persistedMuted = false;
function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const total = Math.floor(seconds);
const s = total % 60;
const m = Math.floor(total / 60) % 60;
const h = Math.floor(total / 3600);
if (h > 0) return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
return `${m}:${s.toString().padStart(2, "0")}`;
}
interface BufferedRange {
start: number;
end: number;
}
function ControlButton({ onClick, label, active = false, children }: {
onClick: () => void;
label: string;
active?: boolean;
children: React.ReactNode;
}) {
return (
<Tooltip label={label} anchorToCursor>
<button
className={`rounded-md p-1.5 transition-colors ${active ? "text-white" : "text-gray-300 hover:text-white"} hover:bg-white/10`}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
>
{children}
</button>
</Tooltip>
);
}
import { VideoControls } from './videoPlayer/VideoControls'
import { useVideoPlayer } from './videoPlayer/useVideoPlayer'
export function VideoPlayer({ src }: { src: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scrubbingRef = useRef(false);
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [buffered, setBuffered] = useState<BufferedRange[]>([]);
const [volume, setVolume] = useState(persistedVolume);
const [muted, setMuted] = useState(
() => useGalleryStore.getState().lightboxAutoMute || persistedMuted,
);
const [playbackRate, setPlaybackRate] = useState(1);
const [loop, setLoop] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [controlsVisible, setControlsVisible] = useState(true);
const [speedMenuOpen, setSpeedMenuOpen] = useState(false);
const speedMenuOpenRef = useRef(speedMenuOpen);
speedMenuOpenRef.current = speedMenuOpen;
// ── Controls visibility ────────────────────────────────────────────────────
const showControls = useCallback(() => {
setControlsVisible(true);
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
hideTimerRef.current = setTimeout(() => {
const video = videoRef.current;
if (video && !video.paused && !scrubbingRef.current && !speedMenuOpenRef.current) {
setControlsVisible(false);
}
}, CONTROLS_HIDE_DELAY_MS);
}, []);
useEffect(() => {
return () => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
};
}, []);
// ── Video element wiring ───────────────────────────────────────────────────
useEffect(() => {
const video = videoRef.current;
if (!video) return;
// Read playback prefs fresh for each opened video — not reactive, so a
// settings change applies to the next video rather than the current one.
const { lightboxAutoplay, lightboxAutoMute } = useGalleryStore.getState();
const startMuted = lightboxAutoMute || persistedMuted;
video.volume = persistedVolume;
video.muted = startMuted;
setMuted(startMuted);
// Autoplay when enabled; if the webview blocks it the catch leaves us paused
// with controls visible, which is a fine fallback.
if (lightboxAutoplay) video.play().catch(() => {});
}, [src]);
const readBuffered = useCallback(() => {
const video = videoRef.current;
if (!video || !Number.isFinite(video.duration) || video.duration <= 0) return;
const ranges: BufferedRange[] = [];
for (let i = 0; i < video.buffered.length; i++) {
ranges.push({
start: video.buffered.start(i) / video.duration,
end: video.buffered.end(i) / video.duration,
});
}
setBuffered(ranges);
}, []);
const togglePlay = useCallback(() => {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
void video.play().catch(() => {});
} else {
video.pause();
}
showControls();
}, [showControls]);
const seekBy = useCallback(
(deltaSeconds: number) => {
const video = videoRef.current;
if (!video || !Number.isFinite(video.duration)) return;
video.currentTime = Math.min(video.duration, Math.max(0, video.currentTime + deltaSeconds));
showControls();
},
[showControls],
);
const applyVolume = useCallback(
(nextVolume: number, nextMuted?: boolean) => {
const video = videoRef.current;
const clamped = Math.min(1, Math.max(0, nextVolume));
setVolume(clamped);
persistedVolume = clamped;
if (nextMuted !== undefined) {
setMuted(nextMuted);
persistedMuted = nextMuted;
if (video) video.muted = nextMuted;
}
if (video) video.volume = clamped;
showControls();
},
[showControls],
);
const toggleMute = useCallback(() => {
const next = !muted;
setMuted(next);
persistedMuted = next;
const video = videoRef.current;
if (video) video.muted = next;
showControls();
}, [muted, showControls]);
const toggleLoop = useCallback(() => {
setLoop((value) => {
const video = videoRef.current;
if (video) video.loop = !value;
return !value;
});
showControls();
}, [showControls]);
const toggleFullscreen = useCallback(() => {
if (document.fullscreenElement) {
void document.exitFullscreen().catch(() => {});
} else {
void containerRef.current?.requestFullscreen().catch(() => {});
}
showControls();
}, [showControls]);
useEffect(() => {
const onChange = () => setFullscreen(document.fullscreenElement !== null);
document.addEventListener("fullscreenchange", onChange);
return () => document.removeEventListener("fullscreenchange", onChange);
}, []);
const setSpeed = useCallback(
(rate: number) => {
setPlaybackRate(rate);
const video = videoRef.current;
if (video) video.playbackRate = rate;
setSpeedMenuOpen(false);
showControls();
},
[showControls],
);
// ── Scrubber ───────────────────────────────────────────────────────────────
const seekToPointer = useCallback((clientX: number) => {
const video = videoRef.current;
const track = trackRef.current;
if (!video || !track || !Number.isFinite(video.duration) || video.duration <= 0) return;
const bounds = track.getBoundingClientRect();
const fraction = Math.min(1, Math.max(0, (clientX - bounds.left) / bounds.width));
video.currentTime = fraction * video.duration;
setCurrentTime(video.currentTime);
}, []);
const handleTrackPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
scrubbingRef.current = true;
seekToPointer(event.clientX);
},
[seekToPointer],
);
const handleTrackPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!scrubbingRef.current) return;
seekToPointer(event.clientX);
},
[seekToPointer],
);
const handleTrackPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
event.currentTarget.releasePointerCapture(event.pointerId);
scrubbingRef.current = false;
}, []);
// ── Keyboard ───────────────────────────────────────────────────────────────
useEffect(() => {
const handler = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) {
return;
}
switch (event.key) {
case " ":
event.preventDefault();
togglePlay();
break;
case "m":
case "M":
toggleMute();
break;
case "f":
case "F":
toggleFullscreen();
break;
case "l":
case "L":
toggleLoop();
break;
case "ArrowLeft":
if (event.shiftKey) {
event.preventDefault();
seekBy(-SEEK_STEP_SECONDS);
}
break;
case "ArrowRight":
if (event.shiftKey) {
event.preventDefault();
seekBy(SEEK_STEP_SECONDS);
}
break;
case "ArrowUp":
event.preventDefault();
// Adjusting volume always unmutes, matching the slider behavior.
applyVolume(persistedVolume + VOLUME_STEP, false);
break;
case "ArrowDown":
event.preventDefault();
applyVolume(persistedVolume - VOLUME_STEP, false);
break;
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [togglePlay, toggleMute, toggleFullscreen, toggleLoop, seekBy, applyVolume]);
const playedFraction = duration > 0 ? currentTime / duration : 0;
const effectiveVolume = muted ? 0 : volume;
const player = useVideoPlayer(src)
return (
<div
ref={containerRef}
className={`media-dark-surface relative flex h-full w-full items-center justify-center bg-black ${controlsVisible ? "" : "cursor-none"}`}
onPointerMove={showControls}
ref={player.containerRef}
className={`media-dark-surface relative flex h-full w-full items-center justify-center bg-black ${player.controlsVisible ? '' : 'cursor-none'}`}
onPointerMove={player.showControls}
onClick={(event) => event.stopPropagation()}
>
<video
ref={videoRef}
ref={player.videoRef}
src={src}
className="h-full w-full object-contain"
onClick={togglePlay}
onDoubleClick={toggleFullscreen}
onClick={player.togglePlay}
onDoubleClick={player.toggleFullscreen}
onPlay={() => {
setPlaying(true);
showControls();
player.setPlaying(true)
player.showControls()
}}
onPause={() => {
setPlaying(false);
setControlsVisible(true);
player.setPlaying(false)
player.setControlsVisible(true)
}}
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
onTimeUpdate={(event) => player.setCurrentTime(event.currentTarget.currentTime)}
onLoadedMetadata={(event) => {
setDuration(event.currentTarget.duration);
readBuffered();
player.setDuration(event.currentTarget.duration)
player.readBuffered()
}}
onDurationChange={(event) => setDuration(event.currentTarget.duration)}
onProgress={readBuffered}
onDurationChange={(event) => player.setDuration(event.currentTarget.duration)}
onProgress={player.readBuffered}
/>
<AnimatePresence>
{controlsVisible ? (
<motion.div
className="absolute inset-x-0 bottom-0 z-10 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-4 pb-2.5 pt-12"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{/* Scrubber */}
<div
ref={trackRef}
className="group/track relative flex h-4 cursor-pointer items-center"
onPointerDown={handleTrackPointerDown}
onPointerMove={handleTrackPointerMove}
onPointerUp={handleTrackPointerUp}
>
<div className="relative h-1 w-full overflow-hidden rounded-full bg-white/15 transition-[height] group-hover/track:h-1.5">
{buffered.map((range, index) => (
<div
key={index}
className="absolute inset-y-0 bg-white/20"
style={{ left: `${range.start * 100}%`, width: `${(range.end - range.start) * 100}%` }}
/>
))}
<div className="absolute inset-y-0 left-0 bg-white/90" style={{ width: `${playedFraction * 100}%` }} />
</div>
<div
className="pointer-events-none absolute h-3 w-3 -translate-x-1/2 rounded-full bg-white opacity-0 shadow transition-opacity group-hover/track:opacity-100"
style={{ left: `${playedFraction * 100}%` }}
<VideoControls
applyVolume={player.applyVolume}
buffered={player.buffered}
controlsVisible={player.controlsVisible}
currentTime={player.currentTime}
duration={player.duration}
effectiveVolume={player.effectiveVolume}
fullscreen={player.fullscreen}
handleTrackPointerDown={player.handleTrackPointerDown}
handleTrackPointerMove={player.handleTrackPointerMove}
handleTrackPointerUp={player.handleTrackPointerUp}
loop={player.loop}
muted={player.muted}
playbackRate={player.playbackRate}
playedFraction={player.playedFraction}
playing={player.playing}
setSpeed={player.setSpeed}
setSpeedMenuOpen={player.setSpeedMenuOpen}
speedMenuOpen={player.speedMenuOpen}
toggleFullscreen={player.toggleFullscreen}
toggleLoop={player.toggleLoop}
toggleMute={player.toggleMute}
togglePlay={player.togglePlay}
trackRef={player.trackRef}
/>
</div>
{/* Control row */}
<div className="mt-1 flex items-center gap-1.5">
<ControlButton onClick={togglePlay} label={playing ? "Pause (Space)" : "Play (Space)"}>
{playing ? (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 5h4v14H7zM13 5h4v14h-4z" />
</svg>
) : (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5.14v13.72L19 12 8 5.14z" />
</svg>
)}
</ControlButton>
<span className="ml-1 text-xs tabular-nums text-gray-300">
{formatTime(currentTime)} <span className="text-gray-500">/ {formatTime(duration)}</span>
</span>
<div className="flex-1" />
{/* Volume */}
<ControlButton onClick={toggleMute} label={muted ? "Unmute (M)" : "Mute (M)"}>
{effectiveVolume === 0 ? (
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M17 9l4 6m0-6l-4 6" />
</svg>
) : (
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15.536 8.464a5 5 0 010 7.072M18.364 5.636a9 9 0 010 12.728" />
</svg>
)}
</ControlButton>
<Tooltip label="Volume (↑/↓)" anchorToCursor>
<input
type="range"
min={0}
max={1}
step={0.02}
value={effectiveVolume}
className="h-1 w-20 cursor-pointer accent-white"
onChange={(event) => applyVolume(parseFloat(event.target.value), false)}
onClick={(event) => event.stopPropagation()}
/>
</Tooltip>
{/* Playback speed */}
<div className="relative">
<Tooltip label="Playback speed" anchorToCursor>
<button
className={`min-w-12 rounded-md px-2 py-1.5 text-xs tabular-nums transition-colors hover:bg-white/10 ${playbackRate !== 1 ? "text-white" : "text-gray-300 hover:text-white"}`}
onClick={(event) => {
event.stopPropagation();
setSpeedMenuOpen((value) => !value);
}}
>
{playbackRate}×
</button>
</Tooltip>
{speedMenuOpen ? (
<div className="absolute bottom-full right-0 z-20 mb-2 min-w-20 rounded-lg border border-white/10 bg-gray-950/95 p-1 shadow-2xl backdrop-blur">
{SPEED_OPTIONS.map((rate) => (
<button
key={rate}
className={`flex w-full items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-left text-xs tabular-nums transition-colors ${
rate === playbackRate ? "bg-white/10 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white"
}`}
onClick={(event) => {
event.stopPropagation();
setSpeed(rate);
}}
>
{rate}×
</button>
))}
</div>
) : null}
</div>
{/* Loop */}
<ControlButton onClick={toggleLoop} label={loop ? "Loop on (L)" : "Loop off (L)"} active={loop}>
<svg className={`h-4.5 w-4.5 ${loop ? "text-emerald-300" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h5M20 20v-5h-5M4 9a8 8 0 0114-3M20 15a8 8 0 01-14 3" />
</svg>
</ControlButton>
{/* Fullscreen */}
<ControlButton onClick={toggleFullscreen} label={fullscreen ? "Exit fullscreen (F)" : "Fullscreen (F)"}>
{fullscreen ? (
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9H4m5 0V4m6 5h5m-5 0V4M9 15H4m5 0v5m6-5h5m-5 0v5" />
</svg>
) : (
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 8V4h4M20 8V4h-4M4 16v4h4M20 16v4h-4" />
</svg>
)}
</ControlButton>
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
)
}
+61 -59
View File
@@ -1,13 +1,14 @@
import { useEffect, useState, type ReactNode } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { invoke } from "@tauri-apps/api/core";
import { useGalleryStore } from "../store";
import { getChangelogForVersion, type ChangelogSection } from "../changelog";
import { Tooltip } from "./Tooltip";
import { useEffect, useState, type ReactNode } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { invoke } from '@tauri-apps/api/core'
import { useGalleryStore } from '../store'
import { getChangelogForVersion, type ChangelogSection } from '../changelog'
import { Tooltip } from './Tooltip'
import { ChevronRightIcon, CloseIcon } from './icons'
// Shown in the tooltip so the user can see the link's destination before
// clicking. The actual navigation is handled by the open_changelog_url command.
const CHANGELOG_URL = "https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md";
const CHANGELOG_URL = 'https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md'
// Per-section accent. These all use the standard colour scale, which the
// subtle-light theme re-maps to readable dark shades via CSS variables — so no
@@ -15,51 +16,51 @@ const CHANGELOG_URL = "https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md"
// index.css: `bg-white`/`text-white` deliberately become *dark* in light mode,
// so neutral surfaces must use the gray scale and trust the remap.
const SECTION_STYLES: Record<string, { label: string; dot: string }> = {
Added: { label: "text-emerald-300", dot: "bg-emerald-400/80" },
Changed: { label: "text-sky-300", dot: "bg-sky-300/80" },
Fixed: { label: "text-amber-300", dot: "bg-amber-400/80" },
Removed: { label: "text-rose-300", dot: "bg-rose-400/80" },
Security: { label: "text-violet-300", dot: "bg-violet-400/80" },
};
Added: { label: 'text-emerald-300', dot: 'bg-emerald-400/80' },
Changed: { label: 'text-sky-300', dot: 'bg-sky-300/80' },
Fixed: { label: 'text-amber-300', dot: 'bg-amber-400/80' },
Removed: { label: 'text-rose-300', dot: 'bg-rose-400/80' },
Security: { label: 'text-violet-300', dot: 'bg-violet-400/80' },
}
const NEUTRAL_STYLE = { label: "text-gray-300", dot: "bg-gray-400/70" };
const NEUTRAL_STYLE = { label: 'text-gray-300', dot: 'bg-gray-400/70' }
// Render the small amount of inline markdown the changelog uses: **bold** and
// `code`. Anything else passes through as plain text.
function renderInline(text: string): ReactNode[] {
const nodes: ReactNode[] = [];
const regex = /\*\*(.+?)\*\*|`([^`]+)`/g;
let lastIndex = 0;
let key = 0;
let match: RegExpExecArray | null;
const nodes: ReactNode[] = []
const regex = /\*\*(.+?)\*\*|`([^`]+)`/g
let lastIndex = 0
let key = 0
let match: RegExpExecArray | null
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push(text.slice(lastIndex, match.index));
nodes.push(text.slice(lastIndex, match.index))
}
if (match[1] !== undefined) {
nodes.push(
<strong key={key++} className="font-semibold text-white">
{match[1]}
</strong>,
);
</strong>
)
} else if (match[2] !== undefined) {
nodes.push(
<code key={key++} className="rounded bg-white/10 px-1 py-0.5 text-[12px] text-gray-200">
{match[2]}
</code>,
);
</code>
)
}
lastIndex = regex.lastIndex;
lastIndex = regex.lastIndex
}
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
nodes.push(text.slice(lastIndex))
}
return nodes;
return nodes
}
function Section({ section }: { section: ChangelogSection }) {
const style = SECTION_STYLES[section.title] ?? NEUTRAL_STYLE;
const [open, setOpen] = useState(true);
const style = SECTION_STYLES[section.title] ?? NEUTRAL_STYLE
const [open, setOpen] = useState(true)
return (
<section>
@@ -69,22 +70,20 @@ function Section({ section }: { section: ChangelogSection }) {
className="flex w-full items-center gap-2 text-left"
aria-expanded={open}
>
<svg
className={`h-3 w-3 shrink-0 text-gray-600 transition-transform ${open ? "rotate-90" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
</svg>
<span className={`text-[11px] font-semibold uppercase tracking-[0.1em] ${style.label}`}>{section.title}</span>
<ChevronRightIcon
className={`h-3 w-3 shrink-0 text-gray-600 transition-transform ${open ? 'rotate-90' : ''}`}
strokeWidth={2.5}
/>
<span className={`text-[11px] font-semibold tracking-[0.1em] uppercase ${style.label}`}>
{section.title}
</span>
<span className="text-[11px] font-medium text-gray-600">{section.items.length}</span>
</button>
<AnimatePresence initial={false}>
{open ? (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18 }}
className="overflow-hidden"
@@ -110,24 +109,24 @@ function Section({ section }: { section: ChangelogSection }) {
) : null}
</AnimatePresence>
</section>
);
)
}
export function WhatsNewModal() {
const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen);
const closeWhatsNew = useGalleryStore((s) => s.closeWhatsNew);
const appVersion = useGalleryStore((s) => s.appVersion);
const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen)
const closeWhatsNew = useGalleryStore((s) => s.closeWhatsNew)
const appVersion = useGalleryStore((s) => s.appVersion)
const entry = getChangelogForVersion(appVersion);
const entry = getChangelogForVersion(appVersion)
useEffect(() => {
if (!whatsNewOpen) return;
if (!whatsNewOpen) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeWhatsNew();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [whatsNewOpen, closeWhatsNew]);
if (event.key === 'Escape') closeWhatsNew()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [whatsNewOpen, closeWhatsNew])
return (
<AnimatePresence>
@@ -150,20 +149,22 @@ export function WhatsNewModal() {
>
<div className="flex items-start justify-between gap-4 border-b border-white/[0.07] px-6 py-5">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-emerald-300">What's new</p>
<p className="text-[11px] font-semibold tracking-[0.12em] text-emerald-300 uppercase">
What's new
</p>
<h3 className="mt-1 text-lg font-semibold text-white">
Phokus v{entry?.version ?? appVersion ?? "—"}
Phokus v{entry?.version ?? appVersion ?? ''}
</h3>
{entry?.date ? <p className="mt-0.5 text-xs text-gray-600">Released {entry.date}</p> : null}
{entry?.date ? (
<p className="mt-0.5 text-xs text-gray-600">Released {entry.date}</p>
) : null}
</div>
<Tooltip label="Close" anchorToCursor>
<button
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={closeWhatsNew}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<CloseIcon className="h-4 w-4" />
</button>
</Tooltip>
</div>
@@ -173,7 +174,8 @@ export function WhatsNewModal() {
entry.sections.map((section) => <Section key={section.title} section={section} />)
) : (
<p className="text-sm text-gray-500">
Release notes for this version aren't available in-app. See the full changelog on GitHub.
Release notes for this version aren't available in-app. See the full changelog on
GitHub.
</p>
)}
</div>
@@ -182,7 +184,7 @@ export function WhatsNewModal() {
<Tooltip label={CHANGELOG_URL} side="top" align="start">
<button
type="button"
onClick={() => void invoke("open_changelog_url")}
onClick={() => void invoke('open_changelog_url')}
className="text-xs text-gray-500 transition-colors hover:text-gray-300"
>
Full changelog
@@ -199,5 +201,5 @@ export function WhatsNewModal() {
</motion.div>
) : null}
</AnimatePresence>
);
)
}
+9 -9
View File
@@ -1,15 +1,15 @@
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../store";
import { AnimatePresence, motion } from 'framer-motion'
import { useGalleryStore } from '../store'
// Shown once on the first launch after an update, inviting the user to read the
// changelog. Distinct from UpdateToast (which drives the download/install flow).
export function WhatsNewToast() {
const whatsNewToast = useGalleryStore((s) => s.whatsNewToast);
const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen);
const openWhatsNew = useGalleryStore((s) => s.openWhatsNew);
const dismissWhatsNewToast = useGalleryStore((s) => s.dismissWhatsNewToast);
const whatsNewToast = useGalleryStore((s) => s.whatsNewToast)
const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen)
const openWhatsNew = useGalleryStore((s) => s.openWhatsNew)
const dismissWhatsNewToast = useGalleryStore((s) => s.dismissWhatsNewToast)
const visible = whatsNewToast !== null && !whatsNewOpen;
const visible = whatsNewToast !== null && !whatsNewOpen
return (
<AnimatePresence>
@@ -19,7 +19,7 @@ export function WhatsNewToast() {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 12 }}
transition={{ duration: 0.18 }}
className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-emerald-400/20 bg-gray-900 p-4 shadow-xl"
className="fixed right-4 bottom-4 z-50 w-80 rounded-lg border border-emerald-400/20 bg-gray-900 p-4 shadow-xl"
>
<p className="text-sm font-medium text-white">What's new in Phokus v{whatsNewToast}</p>
<p className="mt-1 text-xs text-gray-500">See what's changed in this version.</p>
@@ -40,5 +40,5 @@ export function WhatsNewToast() {
</motion.div>
) : null}
</AnimatePresence>
);
)
}
@@ -0,0 +1,67 @@
import { Tooltip } from '../Tooltip'
import { CloseIcon } from '../icons'
import type { BackgroundTask } from './types'
export function FailureActions({
onLocate,
onRetry,
task,
}: {
onLocate: (folderId: number) => void
onRetry: (task: BackgroundTask) => void
task: BackgroundTask
}) {
if (task.pendingMediaWork !== 0 || (!task.hasFailedEmbeddings && !task.hasFailedTagging))
return null
return (
<div className="flex shrink-0 items-center gap-1.5">
{task.hasFailedTagging ? (
<button
className="light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200 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"
onClick={(event) => {
event.stopPropagation()
onLocate(task.id)
}}
>
Locate
</button>
) : null}
<button
className="light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200 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"
onClick={(event) => {
event.stopPropagation()
onRetry(task)
}}
>
Retry
</button>
</div>
)
}
export function DismissTaskButton({
onDismiss,
size = 'normal',
task,
}: {
onDismiss: (task: BackgroundTask) => void
size?: 'normal' | 'small'
task: BackgroundTask
}) {
if (task.id < 0) return null
return (
<Tooltip label="Dismiss" anchorToCursor>
<button
className="shrink-0 rounded-md p-1 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-300"
onClick={(event) => {
event.stopPropagation()
onDismiss(task)
}}
>
<CloseIcon className={size === 'small' ? 'h-3 w-3' : 'h-3.5 w-3.5'} />
</button>
</Tooltip>
)
}
@@ -0,0 +1,82 @@
import type { WorkerKey } from '../../store'
import { ChevronDownIcon } from '../icons'
import { DismissTaskButton, FailureActions } from './BackgroundTaskActions'
import { TaskProgressBar } from './TaskProgressBar'
import { TaskStagePill } from './TaskStagePill'
import type { BackgroundTask } from './types'
export function BackgroundTaskSummary({
expanded,
extraCount,
hasFailed,
isWorkerPaused,
onDismiss,
onLocate,
onRetry,
onToggleExpanded,
onToggleWorker,
primary,
progress,
taskCount,
}: {
expanded: boolean
extraCount: number
hasFailed: boolean
isWorkerPaused: (folderId: number, worker: WorkerKey) => boolean
onDismiss: (task: BackgroundTask) => void
onLocate: (folderId: number) => void
onRetry: (task: BackgroundTask) => void
onToggleExpanded: () => void
onToggleWorker: (folderId: number, worker: WorkerKey) => void
primary: BackgroundTask
progress: number | null
taskCount: number
}) {
return (
<div
className={`group flex h-11 cursor-pointer items-center gap-3 px-5 transition-colors select-none ${
expanded ? 'bg-white/[0.03]' : 'hover:bg-white/[0.02]'
}`}
onClick={onToggleExpanded}
>
<div className="relative shrink-0">
<div className={`h-1.5 w-1.5 rounded-full ${hasFailed ? 'bg-amber-400' : 'bg-blue-400'}`} />
<div
className={`absolute inset-0 h-1.5 w-1.5 animate-ping rounded-full opacity-60 ${hasFailed ? 'bg-amber-400' : 'bg-blue-400'}`}
/>
</div>
<span className="shrink-0 text-[13px] font-medium text-white/60">{primary.name}</span>
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
{primary.stages.map((stage) => (
<TaskStagePill
key={stage.label}
folderId={primary.id}
isWorkerPaused={isWorkerPaused}
onToggleWorker={onToggleWorker}
stage={stage}
/>
))}
</div>
<TaskProgressBar failed={hasFailed} progress={progress} widthClass="w-24" />
{extraCount > 0 ? (
<span className="shrink-0 rounded-full bg-white/8 px-2 py-0.5 text-[10px] text-gray-500">
+{extraCount}
</span>
) : null}
<FailureActions onLocate={onLocate} onRetry={onRetry} task={primary} />
{taskCount > 1 ? (
<ChevronDownIcon
className={`h-3.5 w-3.5 shrink-0 text-gray-600 transition-transform duration-200 ${expanded ? 'rotate-180' : ''}`}
/>
) : null}
<DismissTaskButton onDismiss={onDismiss} task={primary} />
</div>
)
}
@@ -0,0 +1,84 @@
import type { WorkerKey } from '../../store'
import { DismissTaskButton, FailureActions } from './BackgroundTaskActions'
import { FailedWorkerItemRow } from './FailedWorkerItemRow'
import { taskHasTerminalFailure, taskProgress } from './taskModel'
import { TaskProgressBar } from './TaskProgressBar'
import { TaskStagePill } from './TaskStagePill'
import type { BackgroundTask, FailedWorkerItem } from './types'
export function ExpandedTaskPanel({
failedEmbeddingItems,
failedTaggingItems,
isWorkerPaused,
onDismiss,
onLocate,
onRetry,
onToggleWorker,
tasks,
}: {
failedEmbeddingItems: Record<number, FailedWorkerItem[]>
failedTaggingItems: Record<number, FailedWorkerItem[]>
isWorkerPaused: (folderId: number, worker: WorkerKey) => boolean
onDismiss: (task: BackgroundTask) => void
onLocate: (folderId: number) => void
onRetry: (task: BackgroundTask) => void
onToggleWorker: (folderId: number, worker: WorkerKey) => void
tasks: BackgroundTask[]
}) {
return (
<div className="space-y-3 border-t border-white/[0.06] bg-white/[0.02] px-5 py-3">
{tasks.map((task) => {
const progress = taskProgress(task)
const failed = taskHasTerminalFailure(task)
return (
<div key={task.id}>
<div className="flex items-center gap-3">
<span className="w-28 shrink-0 truncate text-[12px] text-white/50">{task.name}</span>
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
{task.stages.map((stage) => (
<TaskStagePill
key={stage.label}
folderId={task.id}
isWorkerPaused={isWorkerPaused}
mutedWhenPaused
onToggleWorker={onToggleWorker}
stage={stage}
/>
))}
</div>
<TaskProgressBar failed={failed} progress={progress} />
{failed ? <FailureActions onLocate={onLocate} onRetry={onRetry} task={task} /> : null}
<DismissTaskButton onDismiss={onDismiss} size="small" task={task} />
</div>
{task.currentFile ? (
<p className="mt-1 truncate pl-[calc(7rem+0.75rem)] text-[10px] text-gray-600">
{task.currentFile}
</p>
) : null}
{failed && failedEmbeddingItems[task.id] && failedEmbeddingItems[task.id].length > 0 ? (
<div className="mt-2 space-y-0.5 pl-[calc(7rem+0.75rem)]">
{failedEmbeddingItems[task.id].map((item) => (
<FailedWorkerItemRow key={item.image_id} item={item} />
))}
</div>
) : null}
{failed && failedTaggingItems[task.id] && failedTaggingItems[task.id].length > 0 ? (
<div className="mt-2 space-y-0.5 pl-[calc(7rem+0.75rem)]">
{failedTaggingItems[task.id].map((item) => (
<FailedWorkerItemRow key={item.image_id} item={item} />
))}
</div>
) : null}
</div>
)
})}
</div>
)
}
@@ -0,0 +1,36 @@
import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { Tooltip } from '../Tooltip'
import { WarningIcon } from '../icons'
import type { FailedWorkerItem } from './types'
export function FailedWorkerItemRow({ item }: { item: FailedWorkerItem }) {
return (
<div className="flex min-w-0 items-start gap-1.5">
<WarningIcon
className="light-theme:text-amber-700 mt-px h-2.5 w-2.5 shrink-0 text-amber-500"
strokeWidth={2.5}
/>
<div className="min-w-0 flex-1">
<p className="light-theme:text-amber-700 truncate text-[10px] font-medium text-amber-400/80">
{item.filename}
</p>
{item.error ? <p className="truncate text-[9px] text-gray-600">{item.error}</p> : null}
</div>
<Tooltip label="Reveal in Explorer" anchorToCursor>
<button
className="light-theme:text-gray-600 light-theme:hover:text-gray-100 shrink-0 text-gray-700 transition-colors hover:text-gray-300"
onClick={() => void revealItemInDir(item.path)}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"
/>
</svg>
</button>
</Tooltip>
</div>
)
}
@@ -0,0 +1,24 @@
export function TaskProgressBar({
failed,
progress,
widthClass = 'w-20',
}: {
failed: boolean
progress: number | null
widthClass?: string
}) {
return (
<div className={`${widthClass} h-px shrink-0 overflow-hidden rounded-full bg-white/8`}>
<div
className={`h-full rounded-full transition-all duration-500 ${
failed
? 'bg-amber-400/60'
: progress === null
? 'w-full animate-pulse bg-blue-500/40'
: 'bg-blue-500'
}`}
style={progress !== null ? { width: `${progress}%` } : undefined}
/>
</div>
)
}
@@ -0,0 +1,71 @@
import type { WorkerKey } from '../../store'
import { Tooltip } from '../Tooltip'
import { PlayIcon } from '../icons'
import type { TaskStage } from './types'
import { WORKER_FOR_STAGE } from './types'
export function TaskStagePill({
folderId,
isWorkerPaused,
mutedWhenPaused = false,
onToggleWorker,
stage,
}: {
folderId: number
isWorkerPaused: (folderId: number, worker: WorkerKey) => boolean
mutedWhenPaused?: boolean
onToggleWorker: (folderId: number, worker: WorkerKey) => void
stage: TaskStage
}) {
const workerKey = WORKER_FOR_STAGE[stage.label]
const isPaused = workerKey ? isWorkerPaused(folderId, workerKey) : false
return (
<span
className={`flex shrink-0 items-center gap-1 rounded-md px-2 py-0.5 text-[11px] ${
stage.failed
? 'light-theme:border light-theme:border-amber-500/40 light-theme:bg-amber-100 light-theme:text-amber-700 bg-amber-500/10 text-amber-400'
: isPaused
? 'bg-white/4 text-gray-600'
: mutedWhenPaused
? 'bg-white/5 text-gray-500'
: 'bg-white/5 text-gray-400'
}`}
>
{isPaused && mutedWhenPaused ? (
<svg className="h-2 w-2 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
) : null}
<span>{stage.label}</span>
<span
className={`tabular-nums ${stage.failed ? 'light-theme:text-amber-700 text-amber-500' : isPaused && !mutedWhenPaused ? 'text-gray-700' : 'text-gray-600'}`}
>
{stage.detail}
</span>
{workerKey ? (
<Tooltip label={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`} anchorToCursor>
<button
className={
mutedWhenPaused
? 'ml-0.5 text-gray-600 transition-colors hover:text-white'
: 'ml-0.5 opacity-0 transition-opacity group-hover:opacity-100 hover:text-white'
}
onClick={(event) => {
event.stopPropagation()
onToggleWorker(folderId, workerKey)
}}
>
{isPaused ? (
<PlayIcon className="h-2.5 w-2.5" />
) : (
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
)}
</button>
</Tooltip>
) : null}
</span>
)
}
+233
View File
@@ -0,0 +1,233 @@
import type {
DuplicateScanProgress,
Folder,
FolderJobProgress,
IndexProgress,
WorkerKey,
} from '../../store'
import type { BackgroundTask, TaskStage } from './types'
export function buildFolderTasks({
dismissed,
folders,
indexingProgress,
mediaJobProgress,
workerPaused,
}: {
dismissed: Record<number, string>
folders: Folder[]
indexingProgress: Record<number, IndexProgress>
mediaJobProgress: Record<number, FolderJobProgress>
workerPaused: Record<number, Record<WorkerKey, boolean>>
}): BackgroundTask[] {
return folders
.map((folder): BackgroundTask | null => {
const index = indexingProgress[folder.id]
const jobs = mediaJobProgress[folder.id]
const thumbnailPending = jobs?.thumbnail_pending ?? 0
const metadataPending = jobs?.metadata_pending ?? 0
const embeddingPending = jobs?.embedding_pending ?? 0
const embeddingReady = jobs?.embedding_ready ?? 0
const embeddingFailed = jobs?.embedding_failed ?? 0
const taggingPending = jobs?.tagging_pending ?? 0
const taggingReady = jobs?.tagging_ready ?? 0
const taggingFailed = jobs?.tagging_failed ?? 0
const captionPending = jobs?.caption_pending ?? 0
const captionReady = jobs?.caption_ready ?? 0
const captionFailed = jobs?.caption_failed ?? 0
const paused = workerPaused[folder.id]
const isActive =
(!!index && !index.done) ||
(thumbnailPending > 0 && !(paused?.thumbnail ?? false)) ||
(metadataPending > 0 && !(paused?.metadata ?? false)) ||
(embeddingPending > 0 && !(paused?.embedding ?? false)) ||
(taggingPending > 0 && !(paused?.tagging ?? false)) ||
captionPending > 0
const pendingMediaWork =
thumbnailPending + metadataPending + embeddingPending + taggingPending + captionPending
const embeddingProcessed = embeddingReady + embeddingFailed
const embeddingTotal = embeddingProcessed + embeddingPending
const taggingProcessed = taggingReady + taggingFailed
const taggingTotal = taggingProcessed + taggingPending
const captionProcessed = captionReady + captionFailed
const captionTotal = captionProcessed + captionPending
const hasFailedEmbeddings = embeddingFailed > 0
const hasFailedTagging = taggingFailed > 0
const hasFailedCaptions = captionFailed > 0
if (
!index &&
pendingMediaWork === 0 &&
!hasFailedEmbeddings &&
!hasFailedTagging &&
!hasFailedCaptions
)
return null
const stages: TaskStage[] = []
if (index && !index.done) {
stages.push({
label: 'Scanning',
detail: `${index.indexed.toLocaleString()} / ${index.total.toLocaleString()}`,
progress: index.total > 0 ? (index.indexed / index.total) * 100 : 0,
failed: false,
})
}
if (thumbnailPending > 0) {
stages.push({
label: 'Thumbnails',
detail: thumbnailPending.toLocaleString(),
progress: null,
failed: false,
})
}
if (metadataPending > 0) {
stages.push({
label: 'Metadata',
detail: metadataPending.toLocaleString(),
progress: null,
failed: false,
})
}
if (embeddingPending > 0) {
stages.push({
label: 'Embeddings',
detail: `${embeddingProcessed.toLocaleString()} / ${embeddingTotal.toLocaleString()}`,
progress: embeddingTotal > 0 ? (embeddingProcessed / embeddingTotal) * 100 : 0,
failed: false,
})
}
if (taggingPending > 0) {
stages.push({
label: 'Tags',
detail: `${taggingProcessed.toLocaleString()} / ${taggingTotal.toLocaleString()}`,
progress: taggingTotal > 0 ? (taggingProcessed / taggingTotal) * 100 : 0,
failed: false,
})
}
if (captionPending > 0) {
stages.push({
label: 'Captions',
detail: `${captionProcessed.toLocaleString()} / ${captionTotal.toLocaleString()}`,
progress: captionTotal > 0 ? (captionProcessed / captionTotal) * 100 : 0,
failed: false,
})
}
if (hasFailedEmbeddings && pendingMediaWork === 0) {
stages.push({
label: 'Failed',
detail: `${embeddingFailed.toLocaleString()} embeddings`,
progress: null,
failed: true,
})
}
if (hasFailedTagging && pendingMediaWork === 0) {
stages.push({
label: 'Failed',
detail: `${taggingFailed.toLocaleString()} tags`,
progress: null,
failed: true,
})
}
if (hasFailedCaptions && pendingMediaWork === 0) {
stages.push({
label: 'Failed',
detail: `${captionFailed.toLocaleString()} captions`,
progress: null,
failed: true,
})
}
const snapshot = `${pendingMediaWork}:${embeddingFailed}:${taggingFailed}:${captionFailed}`
return {
id: folder.id,
name: folder.name,
stages,
isActive,
hasFailedEmbeddings,
hasFailedTagging,
hasFailedCaptions,
pendingMediaWork,
embeddingProcessed,
embeddingTotal,
currentFile: index && !index.done ? index.current_file || null : null,
snapshot,
}
})
.filter((task): task is BackgroundTask => task !== null)
.filter((task) => dismissed[task.id] !== task.snapshot)
.sort((a, b) => Number(b.isActive) - Number(a.isActive))
}
export function buildDuplicateScanTask(
duplicateScanning: boolean,
duplicateScanProgress: DuplicateScanProgress | null
): BackgroundTask | null {
if (!duplicateScanning) return null
return {
id: -1,
name: 'Duplicate Scan',
stages: [
{
label:
duplicateScanProgress?.phase === 'checking'
? 'Checking'
: duplicateScanProgress?.phase === 'confirming'
? 'Confirming'
: 'Hashing',
detail: duplicateScanProgress
? `${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ''}`
: 'Starting…',
progress:
duplicateScanProgress && duplicateScanProgress.total > 0
? (duplicateScanProgress.processed / duplicateScanProgress.total) * 100
: null,
failed: false,
},
],
isActive: true,
hasFailedEmbeddings: false,
hasFailedTagging: false,
hasFailedCaptions: false,
pendingMediaWork: 1,
embeddingProcessed: 0,
embeddingTotal: 0,
currentFile: null,
snapshot: '',
}
}
export function taskProgress(task: BackgroundTask): number | null {
const embeddingStage = task.stages.find((stage) => stage.label === 'Embeddings')
const taggingStage = task.stages.find((stage) => stage.label === 'Tags')
const scanningStage = task.stages.find((stage) => stage.label === 'Scanning')
const duplicateStage = task.id === -1 ? task.stages[0] : null
return (
embeddingStage?.progress ??
taggingStage?.progress ??
scanningStage?.progress ??
duplicateStage?.progress ??
null
)
}
export function taskHasTerminalFailure(task: BackgroundTask): boolean {
return (
(task.hasFailedEmbeddings || task.hasFailedTagging || task.hasFailedCaptions) &&
task.pendingMediaWork === 0
)
}
+37
View File
@@ -0,0 +1,37 @@
import type { WorkerKey } from '../../store'
export const WORKER_FOR_STAGE: Record<string, WorkerKey> = {
Thumbnails: 'thumbnail',
Metadata: 'metadata',
Embeddings: 'embedding',
Tags: 'tagging',
}
export interface TaskStage {
label: string
detail: string
progress: number | null
failed: boolean
}
export interface BackgroundTask {
id: number
name: string
stages: TaskStage[]
isActive: boolean
hasFailedEmbeddings: boolean
hasFailedTagging: boolean
hasFailedCaptions: boolean
pendingMediaWork: number
embeddingProcessed: number
embeddingTotal: number
currentFile: string | null
snapshot: string
}
export interface FailedWorkerItem {
image_id: number
filename: string
path: string
error: string | null
}
+16
View File
@@ -0,0 +1,16 @@
import { AlbumPicker } from '../AlbumPicker'
interface BulkAlbumPopoverProps {
onPick: (albumId: number) => Promise<void>
}
export function BulkAlbumPopover({ onPick }: BulkAlbumPopoverProps) {
return (
<div
data-bulk-popover
className="absolute bottom-full left-1/2 mb-2 w-60 -translate-x-1/2 rounded-xl border border-white/10 bg-gray-950/98 p-2 shadow-2xl backdrop-blur"
>
<AlbumPicker onPick={(albumId) => void onPick(albumId)} />
</div>
)
}
+46
View File
@@ -0,0 +1,46 @@
import { WarningIcon } from '../icons'
interface BulkDeleteConfirmProps {
deleting: boolean
selectedCount: number
onCancel: () => void
onDelete: () => Promise<void>
}
export function BulkDeleteConfirm({
deleting,
selectedCount,
onCancel,
onDelete,
}: BulkDeleteConfirmProps) {
return (
<div
data-bulk-popover
className="absolute bottom-full left-1/2 mb-2 w-64 -translate-x-1/2 rounded-xl border border-red-500/30 bg-gray-950/98 p-3 shadow-2xl backdrop-blur"
>
<div className="mb-1 flex items-center gap-1.5 text-red-300">
<WarningIcon className="h-3.5 w-3.5 shrink-0" />
<p className="text-xs font-semibold">Delete from disk</p>
</div>
<p className="mb-2.5 text-[11px] leading-relaxed text-gray-400">
Permanently delete {selectedCount} file{selectedCount === 1 ? '' : 's'} from your computer.
This removes the actual file{selectedCount === 1 ? '' : 's'} from disk and cannot be undone.
</p>
<div className="flex justify-end gap-1.5">
<button
className="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={onCancel}
>
Cancel
</button>
<button
className="rounded-md bg-red-500/20 px-2.5 py-1 text-[11px] font-medium text-red-300 transition-colors hover:bg-red-500/30 hover:text-red-200 disabled:opacity-50"
onClick={() => void onDelete()}
disabled={deleting}
>
{deleting ? 'Deleting…' : `Delete ${selectedCount} from disk`}
</button>
</div>
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { Tooltip } from '../Tooltip'
import { StarIcon } from '../icons'
interface BulkRatingPopoverProps {
onClose: () => void
onSetRating: (rating: number) => Promise<void>
}
export function BulkRatingPopover({ onClose, onSetRating }: BulkRatingPopoverProps) {
const setRating = async (rating: number) => {
await onSetRating(rating)
onClose()
}
return (
<div
data-bulk-popover
className="absolute bottom-full left-1/2 mb-2 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-white/10 bg-gray-950/98 p-2 shadow-2xl backdrop-blur"
>
{Array.from({ length: 5 }, (_, index) => {
const rating = index + 1
return (
<Tooltip key={rating} label={`Set ${rating} star${rating === 1 ? '' : 's'}`}>
<button
className="rounded-md p-1 text-white/25 transition-colors hover:bg-white/5 hover:text-amber-300"
onClick={() => void setRating(rating)}
>
<StarIcon className="h-4 w-4" />
</button>
</Tooltip>
)
})}
<button
className="ml-1 rounded-md border border-white/10 px-2 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/5 hover:text-white"
onClick={() => void setRating(0)}
>
Clear
</button>
</div>
)
}
@@ -0,0 +1,35 @@
import { Tooltip } from '../Tooltip'
interface BulkSelectionSummaryProps {
loadedCount: number
selectedCount: number
totalImages: number
onSelectAll: () => void
}
export function BulkSelectionSummary({
loadedCount,
selectedCount,
totalImages,
onSelectAll,
}: BulkSelectionSummaryProps) {
const showSelectAll = loadedCount < totalImages || loadedCount > selectedCount
return (
<div className="flex items-center gap-2 px-1.5">
<span className="text-xs font-medium text-white">{selectedCount} selected</span>
{showSelectAll ? (
<Tooltip
label={loadedCount < totalImages ? 'Selects loaded items only' : 'Select all loaded'}
>
<button
className="text-[11px] text-gray-500 transition-colors hover:text-gray-300"
onClick={onSelectAll}
>
Select all{loadedCount < totalImages ? ' loaded' : ''}
</button>
</Tooltip>
) : null}
</div>
)
}
+14 -11
View File
@@ -1,25 +1,26 @@
import { useBulkTagEditor } from "./useBulkTagEditor";
import { Tooltip } from "../Tooltip";
import { useBulkTagEditor } from './useBulkTagEditor'
import { Tooltip } from '../Tooltip'
import { CloseIcon } from '../icons'
// Presentational tag-editing fields shared by the popover and modal surfaces.
export function BulkTagFields({ autoFocus = false }: { autoFocus?: boolean }) {
const { selectedCount, input, setInput, suggestions, appliedTags, pending, addTag, removeTag } =
useBulkTagEditor();
useBulkTagEditor()
return (
<div className="space-y-2">
<form
className="flex gap-1.5"
onSubmit={(event) => {
event.preventDefault();
void addTag(input);
event.preventDefault()
void addTag(input)
}}
>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input
autoFocus={autoFocus}
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white placeholder-gray-600 focus:border-white/20 focus:outline-none"
placeholder={`Add tag to ${selectedCount} item${selectedCount === 1 ? "" : "s"}`}
placeholder={`Add tag to ${selectedCount} item${selectedCount === 1 ? '' : 's'}`}
value={input}
onChange={(event) => setInput(event.target.value)}
disabled={pending}
@@ -36,7 +37,11 @@ export function BulkTagFields({ autoFocus = false }: { autoFocus?: boolean }) {
{suggestions.length > 0 ? (
<div className="flex flex-wrap gap-1">
{suggestions.map((suggestion) => (
<Tooltip key={suggestion.tag} label={`${suggestion.count.toLocaleString()} tagged`} anchorToCursor>
<Tooltip
key={suggestion.tag}
label={`${suggestion.count.toLocaleString()} tagged`}
anchorToCursor
>
<button
className="rounded-md border border-white/10 bg-white/[0.03] px-2 py-0.5 text-[11px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => void addTag(suggestion.tag)}
@@ -61,9 +66,7 @@ export function BulkTagFields({ autoFocus = false }: { autoFocus?: boolean }) {
className="text-gray-600 transition-colors hover:text-white"
onClick={() => void removeTag(tag)}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<CloseIcon className="h-3 w-3" />
</button>
</Tooltip>
</span>
@@ -71,5 +74,5 @@ export function BulkTagFields({ autoFocus = false }: { autoFocus?: boolean }) {
</div>
) : null}
</div>
);
)
}
+7 -11
View File
@@ -1,5 +1,6 @@
import { BulkTagFields } from "./BulkTagFields";
import { Tooltip } from "../Tooltip";
import { BulkTagFields } from './BulkTagFields'
import { Tooltip } from '../Tooltip'
import { CloseIcon } from '../icons'
// Inline popover surface for bulk tagging — the default editing surface.
// Anchored above the bar by the parent; closes on outside click via the
@@ -12,19 +13,14 @@ export function BulkTagPopover({ onClose }: { onClose: () => void }) {
onClick={(event) => event.stopPropagation()}
>
<div className="mb-2 flex items-center justify-between">
<p className="text-[11px] uppercase tracking-wider text-gray-500">Add tags</p>
<p className="text-[11px] tracking-wider text-gray-500 uppercase">Add tags</p>
<Tooltip label="Close" anchorToCursor>
<button
className="text-gray-600 transition-colors hover:text-white"
onClick={onClose}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<button className="text-gray-600 transition-colors hover:text-white" onClick={onClose}>
<CloseIcon className="h-3.5 w-3.5" />
</button>
</Tooltip>
</div>
<BulkTagFields autoFocus />
</div>
);
)
}
+1
View File
@@ -0,0 +1 @@
export type BulkPanel = 'tag' | 'rating' | 'album' | 'delete' | null
+45 -45
View File
@@ -1,73 +1,73 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { ExploreTagEntry, useGalleryStore } from "../../store";
import { useCallback, useEffect, useRef, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { ExploreTagEntry, useGalleryStore } from '../../store'
// Shared logic for the bulk tag editor, consumed by both the inline popover and
// the modal surface so they stay behaviorally identical.
export function useBulkTagEditor() {
const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const bulkAddTags = useGalleryStore((state) => state.bulkAddTags);
const bulkRemoveTag = useGalleryStore((state) => state.bulkRemoveTag);
const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size)
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId)
const bulkAddTags = useGalleryStore((state) => state.bulkAddTags)
const bulkRemoveTag = useGalleryStore((state) => state.bulkRemoveTag)
const [input, setInput] = useState("");
const [suggestions, setSuggestions] = useState<ExploreTagEntry[]>([]);
const [appliedTags, setAppliedTags] = useState<string[]>([]);
const [pending, setPending] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const requestRef = useRef(0);
const [input, setInput] = useState('')
const [suggestions, setSuggestions] = useState<ExploreTagEntry[]>([])
const [appliedTags, setAppliedTags] = useState<string[]>([])
const [pending, setPending] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const requestRef = useRef(0)
useEffect(() => {
const query = input.trim();
const query = input.trim()
if (!query) {
requestRef.current += 1;
setSuggestions([]);
return;
requestRef.current += 1
setSuggestions([])
return
}
if (debounceRef.current) clearTimeout(debounceRef.current);
const requestId = ++requestRef.current;
if (debounceRef.current) clearTimeout(debounceRef.current)
const requestId = ++requestRef.current
debounceRef.current = setTimeout(async () => {
try {
const results = await invoke<ExploreTagEntry[]>("search_tags_autocomplete", {
const results = await invoke<ExploreTagEntry[]>('search_tags_autocomplete', {
params: { query, folder_id: selectedFolderId ?? null, limit: 8 },
});
if (requestId !== requestRef.current) return;
setSuggestions(results);
})
if (requestId !== requestRef.current) return
setSuggestions(results)
} catch {
if (requestId !== requestRef.current) return;
setSuggestions([]);
if (requestId !== requestRef.current) return
setSuggestions([])
}
}, 120);
}, 120)
return () => {
requestRef.current += 1;
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [input, selectedFolderId]);
requestRef.current += 1
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [input, selectedFolderId])
const addTag = useCallback(
async (raw: string) => {
const tag = raw.trim();
if (!tag || pending) return;
setPending(true);
const tag = raw.trim()
if (!tag || pending) return
setPending(true)
try {
await bulkAddTags([tag]);
setAppliedTags((prev) => (prev.includes(tag) ? prev : [...prev, tag]));
setInput("");
setSuggestions([]);
await bulkAddTags([tag])
setAppliedTags((prev) => (prev.includes(tag) ? prev : [...prev, tag]))
setInput('')
setSuggestions([])
} finally {
setPending(false);
setPending(false)
}
},
[bulkAddTags, pending],
);
[bulkAddTags, pending]
)
const removeTag = useCallback(
async (tag: string) => {
await bulkRemoveTag(tag);
setAppliedTags((prev) => prev.filter((entry) => entry !== tag));
await bulkRemoveTag(tag)
setAppliedTags((prev) => prev.filter((entry) => entry !== tag))
},
[bulkRemoveTag],
);
[bulkRemoveTag]
)
return { selectedCount, input, setInput, suggestions, appliedTags, pending, addTag, removeTag };
return { selectedCount, input, setInput, suggestions, appliedTags, pending, addTag, removeTag }
}
@@ -0,0 +1,45 @@
export function DuplicateScanLoadingState({ progressLabel }: { progressLabel: string | null }) {
return (
<div className="flex flex-1 items-center justify-center gap-3 text-white/25">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/15 border-t-white/50" />
<span className="text-sm">{progressLabel ? `${progressLabel}` : 'Preparing scan…'}</span>
</div>
)
}
export function DuplicateScanIntroState() {
return (
<div className="flex flex-1 items-center justify-center px-8">
<div className="max-w-sm text-center">
<svg
className="mx-auto mb-4 h-10 w-10 text-white/10"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
<p className="text-sm text-white/30">
Finds files with identical content regardless of filename or location. Click{' '}
<strong className="text-white/50">Scan for duplicates</strong> to begin.
</p>
<p className="mt-2 text-xs text-white/20">
Large libraries may take a minute files are hashed from disk.
</p>
</div>
</div>
)
}
export function DuplicateScanEmptyState() {
return (
<div className="flex flex-1 items-center justify-center">
<p className="text-sm text-white/25">No duplicate files found.</p>
</div>
)
}
@@ -0,0 +1,180 @@
import { DuplicateGroup, DuplicateScanProgress } from '../../store'
import { FolderScopeDropdown } from '../FolderScopeDropdown'
import { Tooltip } from '../Tooltip'
import { WarningIcon } from '../icons'
import {
duplicateProgressLabel,
duplicateProgressPercent,
formatBytes,
formatRelativeTime,
} from './format'
export function DuplicateFinderHeader({
confirmingDelete,
deleteResult,
deleting,
duplicateGroups,
duplicateLastScanned,
duplicateScanError,
duplicateScanning,
duplicateScanProgress,
duplicateScanWarning,
hasResults,
hasScanned,
onClearSelection,
onConfirmDelete,
onDelete,
onScan,
onSelectKeepFirstAll,
selectedCount,
setConfirmingDelete,
}: {
confirmingDelete: boolean
deleteResult: string | null
deleting: boolean
duplicateGroups: DuplicateGroup[]
duplicateLastScanned: number | null
duplicateScanError: string | null
duplicateScanning: boolean
duplicateScanProgress: DuplicateScanProgress | null
duplicateScanWarning: string | null
hasResults: boolean
hasScanned: boolean
onClearSelection: () => void
onConfirmDelete: () => void
onDelete: () => void
onScan: () => void
onSelectKeepFirstAll: () => void
selectedCount: number
setConfirmingDelete: (value: boolean | ((current: boolean) => boolean)) => void
}) {
const totalWasted = duplicateGroups.reduce(
(sum, group) => sum + group.file_size * (group.images.length - 1),
0
)
const totalDuplicateImages = duplicateGroups.reduce(
(sum, group) => sum + group.images.length - 1,
0
)
const progressLabel = duplicateProgressLabel(duplicateScanProgress)
const progressPercent = duplicateProgressPercent(duplicateScanProgress)
return (
<div className="shrink-0 border-b border-white/[0.05] px-6 py-4">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-[15px] font-semibold text-white">Duplicate Finder</h2>
<p className="mt-0.5 text-[11px] text-white/30">
{duplicateScanning
? duplicateScanProgress
? `${progressLabel}${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ''}`
: 'Starting scan…'
: hasResults
? `${duplicateGroups.length} group${duplicateGroups.length === 1 ? '' : 's'} · ${formatBytes(totalWasted)} reclaimable`
: duplicateLastScanned !== null
? 'No duplicates found'
: 'Scan your library for identical files'}
</p>
{!duplicateScanning && duplicateLastScanned !== null ? (
<p className="mt-0.5 text-[10px] text-white/20">
Last scanned {formatRelativeTime(duplicateLastScanned)}
</p>
) : null}
</div>
<div className="flex items-center gap-2">
<FolderScopeDropdown />
{hasResults && selectedCount === 0 && !deleting ? (
<Tooltip
label={`Mark ${totalDuplicateImages} duplicate${totalDuplicateImages === 1 ? '' : 's'} for deletion across all groups (keeps first in each)`}
anchorToCursor
>
<button
className="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 rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white"
onClick={onSelectKeepFirstAll}
>
Select all duplicates
</button>
</Tooltip>
) : null}
{selectedCount > 0 ? (
<>
<span className="text-[11px] text-white/40">{selectedCount} marked for deletion</span>
<button
className="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 rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white disabled:opacity-40"
onClick={onClearSelection}
disabled={deleting}
>
Deselect all
</button>
<div className="relative">
<button
className="rounded-lg border border-red-400/25 bg-red-500/10 px-3 py-1.5 text-xs text-red-300 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => setConfirmingDelete((value) => !value)}
disabled={deleting}
>
{deleting
? 'Deleting…'
: `Delete ${selectedCount} file${selectedCount === 1 ? '' : 's'}`}
</button>
{confirmingDelete && !deleting ? (
<>
<div className="fixed inset-0 z-40" onClick={onConfirmDelete} />
<div className="absolute top-full right-0 z-50 mt-2 w-72 rounded-xl border border-red-500/30 bg-gray-950/98 p-3 text-left shadow-2xl backdrop-blur">
<div className="mb-1 flex items-center gap-1.5 text-red-300">
<WarningIcon className="h-3.5 w-3.5 shrink-0" />
<p className="text-xs font-semibold">Delete from disk</p>
</div>
<p className="mb-2.5 text-[11px] leading-relaxed text-gray-400">
Permanently delete {selectedCount} file{selectedCount === 1 ? '' : 's'} from
your computer. This removes the actual file{selectedCount === 1 ? '' : 's'}{' '}
from disk and cannot be undone.
</p>
<div className="flex justify-end gap-1.5">
<button
className="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={onConfirmDelete}
>
Cancel
</button>
<button
className="rounded-md bg-red-500/20 px-2.5 py-1 text-[11px] font-medium text-red-300 transition-colors hover:bg-red-500/30 hover:text-red-200"
onClick={onDelete}
>
Delete {selectedCount} from disk
</button>
</div>
</div>
</>
) : null}
</div>
</>
) : null}
<button
className="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 rounded-lg border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
onClick={onScan}
disabled={duplicateScanning}
>
{duplicateScanning ? 'Scanning…' : hasScanned ? 'Rescan' : 'Scan for duplicates'}
</button>
</div>
</div>
{duplicateScanning && duplicateScanProgress ? (
<div className="mt-3 h-px overflow-hidden rounded-full bg-white/[0.07]">
<div
className="h-full rounded-full bg-blue-500/60 transition-[width] duration-200"
style={{ width: `${progressPercent}%` }}
/>
</div>
) : null}
{duplicateScanError ? (
<p className="mt-2 text-[11px] text-red-400/80">{duplicateScanError}</p>
) : null}
{duplicateScanWarning ? (
<p className="mt-2 text-[11px] text-amber-300/70">{duplicateScanWarning}</p>
) : null}
{deleteResult ? <p className="mt-2 text-[11px] text-white/40">{deleteResult}</p> : null}
</div>
)
}
@@ -0,0 +1,107 @@
import { DuplicateGroup, useGalleryStore } from '../../store'
import { mediaSrc } from '../../lib/mediaSrc'
import { Tooltip } from '../Tooltip'
import { formatBytes } from './format'
export function DuplicateGroupCard({ group }: { group: DuplicateGroup }) {
const selectedIds = useGalleryStore((state) => state.duplicateSelectedIds)
const toggleDuplicateSelected = useGalleryStore((state) => state.toggleDuplicateSelected)
const selectAllDuplicates = useGalleryStore((state) => state.selectAllDuplicates)
const groupSelectedCount = group.images.filter((image) => selectedIds.has(image.id)).length
const noneSelected = groupSelectedCount === 0
const handleKeepFirst = () => {
const toDelete = group.images.slice(1).map((image) => image.id)
for (const image of group.images) {
if (selectedIds.has(image.id)) toggleDuplicateSelected(image.id)
}
selectAllDuplicates(toDelete)
}
const handleDeselectGroup = () => {
for (const image of group.images) {
if (selectedIds.has(image.id)) toggleDuplicateSelected(image.id)
}
}
return (
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.02] p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-400">
{group.images.length} copies
</span>
<span className="text-[11px] text-white/30">{formatBytes(group.file_size)} each</span>
<span className="text-[11px] text-white/20">
{formatBytes(group.file_size * (group.images.length - 1))} wasted
</span>
</div>
<div className="flex items-center gap-2">
{noneSelected ? (
<button
className="text-[11px] text-white/35 transition-colors hover:text-white/70"
onClick={handleKeepFirst}
>
Keep first
</button>
) : (
<button
className="text-[11px] text-white/35 transition-colors hover:text-white/70"
onClick={handleDeselectGroup}
>
Deselect all
</button>
)}
</div>
</div>
<div className="flex flex-wrap gap-3">
{group.images.map((image) => {
const isSelected = selectedIds.has(image.id)
const src = mediaSrc(image.thumbnail_path)
return (
<Tooltip key={image.id} label={image.path} anchorToCursor>
<button
className={`media-dark-surface group relative overflow-hidden rounded-xl border transition-all ${
isSelected
? 'border-red-400/50 ring-1 ring-red-400/30'
: 'border-white/8 hover:border-white/20'
}`}
style={{ width: 140, height: 105 }}
onClick={() => toggleDuplicateSelected(image.id)}
>
{src ? (
<img src={src} alt="" className="h-full w-full object-cover" draggable={false} />
) : (
<div className="h-full w-full bg-white/[0.03]" />
)}
{isSelected ? (
<div className="absolute inset-0 flex items-center justify-center bg-red-950/60">
<svg
className="h-6 w-6 text-red-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</div>
) : null}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-2 pt-4 pb-1.5 opacity-0 transition-opacity group-hover:opacity-100">
<p className="truncate text-[9px] text-white/60">
{image.path.split(/[\\/]/).slice(-2).join('/')}
</p>
</div>
</button>
</Tooltip>
)
})}
</div>
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { DuplicateScanProgress } from '../../store'
export function formatBytes(bytes: number): string {
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`
return `${bytes} B`
}
export function formatRelativeTime(unixSecs: number): string {
const diff = Math.floor(Date.now() / 1000) - unixSecs
if (diff < 60) return 'just now'
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
export function duplicateProgressLabel(progress: DuplicateScanProgress | null): string | null {
if (!progress) return null
if (progress.phase === 'checking') return 'Checking file sizes'
if (progress.phase === 'hashing') return 'Hashing duplicate candidates'
return 'Confirming exact matches'
}
export function duplicateProgressPercent(progress: DuplicateScanProgress | null): number {
return progress && progress.total > 0
? Math.round((progress.processed / progress.total) * 100)
: 0
}
+281
View File
@@ -0,0 +1,281 @@
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { motion, useReducedMotion } from 'framer-motion'
import type { VisualClusterEntry } from '../../store'
import { mediaSrc } from '../../lib/mediaSrc'
import { Tooltip } from '../Tooltip'
import { ACCENTS, GOLDEN_ANGLE, seeded } from './layout'
interface PlacedNode {
entry: VisualClusterEntry
index: number
x: number
y: number
w: number
h: number
zIndex: number
accent: string
driftX: number
driftY: number
driftDuration: number
rotateSeed: number
}
function buildCloud(
entries: VisualClusterEntry[],
containerW: number,
containerH: number
): PlacedNode[] {
if (!entries.length || containerW <= 0 || containerH <= 0) return []
const maxCount = Math.max(...entries.map((e) => e.count))
const cx = containerW / 2
const cy = containerH / 2
const n = entries.length
const ASPECT = 0.72
const PAD = 18
// Card width scales with image count; the sub-linear exponent (< 1) widens the
// gap so the busiest clusters read as clearly larger and more prominent.
const rawWidth = (count: number) => {
const ratio = Math.max(count / maxCount, 0.05)
return 92 + Math.pow(ratio, 0.65) * 158 // ~92250px before fit scaling
}
// Shrink every card uniformly when their padded footprint can't fit the
// canvas, so overlap resolution can actually pull them apart instead of
// settling into a pile. (0.6 leaves headroom for imperfect packing.)
const totalArea = entries.reduce((sum, e) => {
const w = rawWidth(e.count)
return sum + (w + PAD) * (w * ASPECT + PAD)
}, 0)
const usableArea = containerW * containerH * 0.6
const fit = totalArea > usableArea ? Math.sqrt(usableArea / totalArea) : 1
const spreadX = containerW * 0.44
const spreadY = containerH * 0.4
// 1. Seed positions on a phyllotaxis spiral, sized by count.
const nodes: PlacedNode[] = entries.map((entry, i) => {
const w = rawWidth(entry.count) * fit
const h = w * ASPECT
const radialRatio = Math.sqrt((i + 0.5) / n)
const angle = i * GOLDEN_ANGLE
return {
entry,
index: i,
x: cx + Math.cos(angle) * radialRatio * spreadX,
y: cy + Math.sin(angle) * radialRatio * spreadY,
w,
h,
// Bigger (busier) clusters stack above smaller ones, so they stay
// clickable even if a sliver of overlap survives.
zIndex: Math.round(w),
accent: ACCENTS[i % ACCENTS.length],
driftX: (seeded(i + 11) - 0.5) * 18,
driftY: (seeded(i + 17) - 0.5) * 14,
driftDuration: 8 + seeded(i + 23) * 7,
rotateSeed: (seeded(i + 31) - 0.5) * 4,
}
})
// 2. Resolve overlaps by pushing pairs apart, clamping inside the canvas every
// pass so edge cards settle in-bounds instead of being shoved out and
// re-overlapping there.
const marginX = 14
const marginY = 14
for (let iter = 0; iter < 160; iter++) {
for (let a = 0; a < nodes.length; a++) {
const na = nodes[a]
for (let b = a + 1; b < nodes.length; b++) {
const nb = nodes[b]
const dx = nb.x - na.x
const dy = nb.y - na.y
const overlapX = (na.w + nb.w) / 2 + PAD - Math.abs(dx)
const overlapY = (na.h + nb.h) / 2 + PAD - Math.abs(dy)
if (overlapX <= 0 || overlapY <= 0) continue
// Push along the smaller overlap axis (ternary yields ±1 so coincident
// cards still separate rather than stalling at a zero push).
if (overlapX < overlapY) {
const push = (overlapX / 2) * (dx >= 0 ? 1 : -1)
nb.x += push
na.x -= push
} else {
const push = (overlapY / 2) * (dy >= 0 ? 1 : -1)
nb.y += push
na.y -= push
}
}
}
for (const node of nodes) {
node.x = Math.min(Math.max(node.x, node.w / 2 + marginX), containerW - node.w / 2 - marginX)
node.y = Math.min(Math.max(node.y, node.h / 2 + marginY), containerH - node.h / 2 - marginY)
}
}
return nodes
}
function CloudCard({
node,
onOpen,
animated,
}: {
node: PlacedNode
onOpen: (imageIds: number[]) => void
animated: boolean
}) {
const src = mediaSrc(node.entry.thumbnail_path)
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 (
<Tooltip
label={`Open cluster — ${node.entry.count.toLocaleString()} ${node.entry.count === 1 ? 'image' : 'images'}`}
followCursor
>
<motion.button
className="explore-cluster-card group absolute overflow-hidden rounded-2xl border border-white/8 bg-white/[0.04] text-left shadow-[0_8px_28px_rgba(0,0,0,0.38)] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
style={{
width: w,
height: h,
left: node.x - w / 2,
top: node.y - h / 2,
zIndex: node.zIndex,
}}
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, zIndex: 500, transition: { duration: 0.18 } }}
onClick={() => onOpen(node.entry.image_ids)}
>
{src ? (
<img
src={src}
alt=""
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
draggable={false}
loading="lazy"
decoding="async"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.07] to-transparent" />
)}
<div className="explore-cluster-overlay absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" />
{/* Accent glow on hover */}
<div
className="absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
style={{ background: `radial-gradient(ellipse at bottom, ${accent}25, transparent 70%)` }}
/>
<div className="absolute inset-x-0 bottom-0 p-3">
<div
className="explore-cluster-rule mb-2 h-px rounded-full"
style={{ background: `linear-gradient(to right, ${accent}80, transparent)` }}
/>
<p className="explore-cluster-label text-[9px] tracking-[0.18em] text-white/35 uppercase">
Cluster
</p>
<p className="explore-cluster-count text-base leading-none font-semibold text-white">
{node.entry.count.toLocaleString()}
</p>
</div>
{/* Anchored to the card corner (not in the count's flex row) so a wide
count can't push it past the edge on small cards. */}
<span
className="explore-cluster-open absolute right-3 bottom-3 rounded-full border px-2 py-0.5 text-[9px] tracking-[0.1em] uppercase opacity-0 transition-opacity duration-200 group-hover:opacity-100"
style={{ borderColor: `${accent}50`, color: accent, backgroundColor: `${accent}12` }}
>
Open
</span>
</motion.button>
</Tooltip>
)
}
// Separate component so its useLayoutEffect fires when the canvas is actually
// mounted, not at ExploreView mount time when the container may still be hidden
// behind a loading state.
export function ClusterCloud({
entries,
onOpen,
}: {
entries: VisualClusterEntry[]
onOpen: (imageIds: number[]) => void
}) {
const reducedMotion = useReducedMotion()
const canvasRef = useRef<HTMLDivElement>(null)
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 })
useLayoutEffect(() => {
const el = canvasRef.current
if (!el) return
const update = () => {
const r = el.getBoundingClientRect()
setCanvasSize({ w: r.width, h: r.height })
}
update()
const ro = new ResizeObserver(update)
ro.observe(el)
return () => ro.disconnect()
}, [])
const nodes = useMemo(
() => buildCloud(entries, canvasSize.w, canvasSize.h),
[entries, canvasSize.w, canvasSize.h]
)
return (
<div ref={canvasRef} className="relative isolate min-h-0 flex-1 overflow-hidden">
<div className="explore-cluster-grid pointer-events-none absolute inset-0 [background-image:radial-gradient(circle,rgba(255,255,255,0.6)_1px,transparent_1px)] [background-size:28px_28px] opacity-[0.07]" />
{nodes.map((node) => (
<CloudCard
key={`${node.entry.representative_image_id}:${node.index}`}
node={node}
onOpen={onOpen}
animated={!reducedMotion && node.index < 12}
/>
))}
</div>
)
}
@@ -0,0 +1,39 @@
import { motion } from 'framer-motion'
import type { ExploreMode } from '../../store'
function Spinner() {
return (
<motion.div
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' }}
/>
)
}
export function ExploreLoadingPanel({ mode }: { mode: ExploreMode }) {
const title = mode === 'visual' ? 'Building visual clusters' : 'Reading tag frequencies'
const subtitle =
mode === 'visual'
? 'Grouping similar images into browsable clusters.'
: 'Collecting tags from the current library scope.'
return (
<div className="flex flex-1 items-center justify-center px-8">
<div className="flex max-w-sm flex-col items-center text-center">
<div className="mb-4 flex items-center justify-center gap-3 text-white/65">
<Spinner />
<span className="text-sm font-medium">{title}</span>
</div>
<p className="text-sm leading-relaxed text-white/25">{subtitle}</p>
<div className="mt-6 h-px w-40 overflow-hidden rounded-full bg-white/[0.06]">
<motion.div
className="h-full w-16 rounded-full bg-white/25"
animate={{ x: [-72, 184] }}
transition={{ duration: 1.4, repeat: Infinity, ease: 'easeInOut' }}
/>
</div>
</div>
</div>
)
}
+390
View File
@@ -0,0 +1,390 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { motion, useReducedMotion } from 'framer-motion'
import type { ExploreTagEntry, RelatedTagEntry } from '../../store'
import { useGalleryStore } from '../../store'
import { Tooltip } from '../Tooltip'
import { ACCENTS, GOLDEN_ANGLE, LIGHT_ACCENTS, seeded } from './layout'
interface AtlasNode {
entry: ExploreTagEntry
index: number
x: number
y: number
w: number
h: number
fontSize: number
ratio: number
accent: string
driftX: number
driftY: number
}
interface TagAnchor {
x: number
y: number
}
export const TAG_ATLAS_MAX_VISIBLE = 132
const TAG_ATLAS_DENSE_THRESHOLD = 120
function buildTagAtlas(
entries: ExploreTagEntry[],
containerW: number,
containerH: number,
isLight: boolean
): AtlasNode[] {
if (!entries.length || containerW <= 0 || containerH <= 0) return []
const logs = entries.map((entry) => Math.log(Math.max(entry.count, 1)))
const logMin = Math.min(...logs)
const logRange = Math.max(...logs) - logMin || 1
const cx = containerW / 2
const cy = containerH * 0.48
const spreadX = containerW * 0.47
const spreadY = containerH * 0.46
const accents = isLight ? LIGHT_ACCENTS : ACCENTS
const nodes = entries.map((entry, index) => {
const ratio = (Math.log(Math.max(entry.count, 1)) - logMin) / logRange
const densityScale = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 0.94 : 1
const fontSize = (9.75 + Math.pow(ratio, 0.92) * 19.5) * densityScale
const maxWidthRatio = index < 48 ? 0.32 : index < 100 ? 0.26 : 0.2
const textWidth = entry.tag.length * fontSize * 0.5 + (ratio > 0.55 ? 36 : 26)
const w = Math.min(containerW * maxWidthRatio, textWidth)
const h = fontSize * 1.18 + 14
const radialRatio = Math.sqrt((index + 0.5) / entries.length)
const angle = index * GOLDEN_ANGLE
return {
entry,
index,
x: cx + Math.cos(angle) * radialRatio * spreadX,
y: cy + Math.sin(angle) * radialRatio * spreadY,
w,
h,
fontSize,
ratio,
accent: accents[index % accents.length],
driftX: (seeded(index + 101) - 0.5) * 7,
driftY: (seeded(index + 113) - 0.5) * 6,
}
})
const padX = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 4 : 10
const padY = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 3 : 7
const margin = 28
for (let iter = 0; iter < 140; iter++) {
for (let a = 0; a < nodes.length; a++) {
const na = nodes[a]
for (let b = a + 1; b < nodes.length; b++) {
const nb = nodes[b]
const dx = nb.x - na.x
const dy = nb.y - na.y
const overlapX = (na.w + nb.w) / 2 + padX - Math.abs(dx)
const overlapY = (na.h + nb.h) / 2 + padY - Math.abs(dy)
if (overlapX <= 0 || overlapY <= 0) continue
if (overlapX < overlapY) {
const push = (overlapX / 2) * (dx >= 0 ? 1 : -1)
nb.x += push
na.x -= push
} else {
const push = (overlapY / 2) * (dy >= 0 ? 1 : -1)
nb.y += push
na.y -= push
}
}
}
for (const node of nodes) {
node.x = Math.min(Math.max(node.x, node.w / 2 + margin), containerW - node.w / 2 - margin)
node.y = Math.min(Math.max(node.y, node.h / 2 + margin), containerH - node.h / 2 - margin)
if (node.x <= node.w / 2 + margin || node.x >= containerW - node.w / 2 - margin) {
node.y += (seeded(node.index + iter + 211) - 0.5) * 2
}
if (node.y <= node.h / 2 + margin || node.y >= containerH - node.h / 2 - margin) {
node.x += (seeded(node.index + iter + 223) - 0.5) * 2
}
}
}
for (let iter = 0; iter < 5; iter++) {
const weighted = nodes.reduce(
(acc, node) => {
const weight = 1 + Math.pow(node.ratio, 1.35) * 9
return {
x: acc.x + node.x * weight,
y: acc.y + node.y * weight,
weight: acc.weight + weight,
}
},
{ x: 0, y: 0, weight: 0 }
)
const offsetX = containerW * 0.48 - weighted.x / weighted.weight
const offsetY = containerH * 0.44 - weighted.y / weighted.weight
if (Math.abs(offsetX) < 0.5 && Math.abs(offsetY) < 0.5) break
for (const node of nodes) {
node.x = Math.min(
Math.max(node.x + offsetX, node.w / 2 + margin),
containerW - node.w / 2 - margin
)
node.y = Math.min(
Math.max(node.y + offsetY, node.h / 2 + margin),
containerH - node.h / 2 - margin
)
}
}
return nodes
}
export function TagAtlas({
entries,
onSearch,
loadRelatedTags,
}: {
entries: ExploreTagEntry[]
onSearch: (tag: string) => void
loadRelatedTags: (tag: string) => Promise<RelatedTagEntry[]>
}) {
const theme = useGalleryStore((state) => state.theme)
const isLight = theme === 'subtle-light'
const reducedMotion = useReducedMotion()
const canvasRef = useRef<HTMLDivElement>(null)
const buttonRefs = useRef(new Map<string, HTMLButtonElement>())
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 })
const [activeTag, setActiveTag] = useState<string | null>(null)
const [relatedTags, setRelatedTags] = useState<RelatedTagEntry[]>([])
const [anchors, setAnchors] = useState<Record<string, TagAnchor>>({})
const visibleEntries = useMemo(
() =>
entries.length > TAG_ATLAS_MAX_VISIBLE ? entries.slice(0, TAG_ATLAS_MAX_VISIBLE) : entries,
[entries]
)
useLayoutEffect(() => {
const el = canvasRef.current
if (!el) return
const update = () => {
const r = el.getBoundingClientRect()
setCanvasSize({ w: r.width, h: r.height })
}
update()
const ro = new ResizeObserver(update)
ro.observe(el)
return () => ro.disconnect()
}, [])
useEffect(() => {
if (!activeTag) {
setRelatedTags([])
return
}
let cancelled = false
void loadRelatedTags(activeTag).then((entries) => {
if (!cancelled) setRelatedTags(entries)
})
return () => {
cancelled = true
}
}, [activeTag, loadRelatedTags])
const nodes = useMemo(
() => buildTagAtlas(visibleEntries, canvasSize.w, canvasSize.h, isLight),
[visibleEntries, canvasSize.w, canvasSize.h, isLight]
)
const nodeByTag = useMemo(() => new Map(nodes.map((node) => [node.entry.tag, node])), [nodes])
const activeNode = activeTag ? nodeByTag.get(activeTag) : undefined
const minOpacity = isLight ? 0.62 : 0.42
const visibleConnections = useMemo(
() =>
activeNode
? relatedTags
.map((related) => {
const node = nodeByTag.get(related.tag)
return node ? { node, related } : null
})
.filter((item): item is { node: AtlasNode; related: RelatedTagEntry } => item !== null)
.slice(0, visibleEntries.length > TAG_ATLAS_DENSE_THRESHOLD ? 6 : 10)
: [],
[activeNode, nodeByTag, relatedTags, visibleEntries.length]
)
const activeAnchor = activeTag ? anchors[activeTag] : undefined
const maxShared = Math.max(1, ...visibleConnections.map(({ related }) => related.shared_count))
const connectedByTag = useMemo(
() => new Map(visibleConnections.map(({ node, related }) => [node.entry.tag, related])),
[visibleConnections]
)
const setButtonRef = useCallback((tag: string, element: HTMLButtonElement | null) => {
if (element) {
buttonRefs.current.set(tag, element)
} else {
buttonRefs.current.delete(tag)
}
}, [])
const measureAnchors = useCallback(() => {
const canvas = canvasRef.current
if (!canvas || !activeTag) {
setAnchors({})
return
}
const canvasRect = canvas.getBoundingClientRect()
const tagsToMeasure = [activeTag, ...visibleConnections.map(({ node }) => node.entry.tag)]
const nextAnchors: Record<string, TagAnchor> = {}
for (const tag of tagsToMeasure) {
const element = buttonRefs.current.get(tag)
if (!element) continue
const rect = element.getBoundingClientRect()
nextAnchors[tag] = {
x: rect.left - canvasRect.left + rect.width / 2,
y: rect.top - canvasRect.top + rect.height / 2,
}
}
setAnchors(nextAnchors)
}, [activeTag, visibleConnections])
useLayoutEffect(() => {
if (!activeTag) {
setAnchors({})
return
}
let firstFrame = 0
let secondFrame = 0
const settleTimer = window.setTimeout(measureAnchors, 180)
firstFrame = window.requestAnimationFrame(() => {
measureAnchors()
secondFrame = window.requestAnimationFrame(measureAnchors)
})
return () => {
window.cancelAnimationFrame(firstFrame)
window.cancelAnimationFrame(secondFrame)
window.clearTimeout(settleTimer)
}
}, [activeTag, canvasSize.w, canvasSize.h, measureAnchors])
return (
<div ref={canvasRef} className="relative min-h-0 flex-1 overflow-hidden px-8 py-8">
<svg className="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
<defs>
<radialGradient id="tag-atlas-glow" cx="50%" cy="50%" r="50%">
<stop
offset="0%"
stopColor={isLight ? 'rgba(60,50,30,0.18)' : 'rgba(255,255,255,0.2)'}
/>
<stop offset="100%" stopColor="rgba(0,0,0,0)" />
</radialGradient>
</defs>
{activeNode && activeAnchor ? (
<circle
cx={activeAnchor.x}
cy={activeAnchor.y}
r={Math.max(activeNode.w, activeNode.h) * 0.62}
fill="url(#tag-atlas-glow)"
opacity="0.24"
/>
) : null}
{visibleConnections.map(({ node, related }) => {
const from = activeAnchor
const to = anchors[node.entry.tag]
if (!from || !to) return null
const strength = related.shared_count / maxShared
return (
<g key={`${activeTag}:${node.entry.tag}`}>
<motion.line
x1={from.x}
y1={from.y}
x2={to.x}
y2={to.y}
stroke={node.accent}
strokeWidth={0.35 + strength * 0.55}
strokeOpacity={0.05 + strength * 0.12}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
initial={reducedMotion ? undefined : { pathLength: 0, opacity: 0 }}
animate={reducedMotion ? undefined : { pathLength: 1, opacity: 1 }}
transition={{ duration: 0.32, ease: 'easeOut' }}
/>
{!reducedMotion ? (
<motion.line
x1={from.x}
y1={from.y}
x2={to.x}
y2={to.y}
stroke={node.accent}
strokeWidth={0.65 + strength * 0.5}
strokeOpacity={0.16 + strength * 0.1}
strokeLinecap="round"
pathLength={1}
strokeDasharray="0.08 1"
vectorEffect="non-scaling-stroke"
initial={{ strokeDashoffset: 1.08, opacity: 0 }}
animate={{ strokeDashoffset: [1.08, 0], opacity: [0, 0.65, 0] }}
transition={{
duration: 4.1 + seeded(node.index + 307) * 1.2,
repeat: Infinity,
ease: 'easeInOut',
delay: seeded(node.index + 317) * 0.75,
}}
/>
) : null}
</g>
)
})}
</svg>
{nodes.map((node) => {
const isActive = activeTag === node.entry.tag
const connectedRelated = connectedByTag.get(node.entry.tag)
const isRelated = connectedRelated !== undefined
const dimmed = activeTag !== null && !isActive && !isRelated
const opacity = dimmed ? 0.2 : minOpacity + node.ratio * (1 - minOpacity)
return (
<Tooltip
key={node.entry.tag}
label={`${node.entry.tag}${node.entry.count.toLocaleString()} ${node.entry.count === 1 ? 'image' : 'images'}`}
followCursor
delay={250}
>
<button
ref={(element) => setButtonRef(node.entry.tag, element)}
className={`explore-tag-word group absolute inline-flex items-center justify-center rounded-full px-2 py-1 text-center transition-[opacity,transform] duration-300 ease-out before:absolute before:inset-x-[-14px] before:inset-y-[-8px] before:rounded-full before:bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.16),rgba(255,255,255,0.06)_46%,rgba(255,255,255,0)_72%)] before:opacity-0 before:blur-md before:transition-opacity before:duration-300 before:content-[''] hover:before:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/25 ${
isActive ? 'before:opacity-100' : ''
}`}
style={{
left: node.x - node.w / 2,
top: node.y - node.h / 2,
width: node.w,
minHeight: node.h,
fontSize: node.fontSize,
color:
node.ratio > 0.55 ? node.accent : isLight ? '#4b5563' : 'rgba(255,255,255,0.82)',
opacity,
transform: `translate3d(0, 0, 0) scale(${isActive ? 1.045 : isRelated ? 1.015 : 1})`,
zIndex: isActive ? 30 : isRelated ? 20 : Math.round(node.ratio * 10),
}}
onMouseEnter={() => setActiveTag(node.entry.tag)}
onFocus={() => setActiveTag(node.entry.tag)}
onMouseLeave={() => setActiveTag(null)}
onBlur={() => setActiveTag(null)}
onClick={() => onSearch(node.entry.tag)}
>
<span className="relative z-10 block w-full truncate text-center leading-none font-medium">
{node.entry.tag}
</span>
<span
className={`absolute top-full left-1/2 z-10 mt-1 shrink-0 -translate-x-1/2 rounded-full px-1.5 py-0.5 text-[9px] tabular-nums shadow-sm backdrop-blur-sm transition-opacity ${
isActive || isRelated ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
style={{ backgroundColor: `${node.accent}1A`, color: node.accent }}
>
{connectedRelated
? connectedRelated.shared_count.toLocaleString()
: node.entry.count.toLocaleString()}
</span>
</button>
</Tooltip>
)
})}
</div>
)
}
+471
View File
@@ -0,0 +1,471 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import type { ExploreTagEntry } from '../../store'
import { Dropdown } from '../menu'
import { Tooltip } from '../Tooltip'
type TagManageSort = 'count_desc' | 'count_asc' | 'az' | 'za'
const TAG_MANAGE_SORTS: { value: TagManageSort; label: string }[] = [
{ value: 'count_desc', label: 'Most used' },
{ value: 'count_asc', label: 'Least used' },
{ value: 'az', label: 'A-Z' },
{ value: 'za', label: 'Z-A' },
]
function AiSourceGlyph({ className = '' }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M8 2.75l.82 2.42a2.1 2.1 0 0 0 1.32 1.32l2.41.81-2.41.82a2.1 2.1 0 0 0-1.32 1.32L8 11.85l-.82-2.41a2.1 2.1 0 0 0-1.32-1.32L3.45 7.3l2.41-.81a2.1 2.1 0 0 0 1.32-1.32L8 2.75Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.35"
/>
<path
d="M12.25 11.15l.25.74.75.26-.75.25-.25.75-.26-.75-.74-.25.74-.26.26-.74Z"
fill="currentColor"
/>
</svg>
)
}
function RenameGlyph({ className = '' }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M3.25 11.85l.45-2.14 5.95-5.95a1.33 1.33 0 0 1 1.88 0l.71.71a1.33 1.33 0 0 1 0 1.88L6.29 12.3l-2.14.45a.76.76 0 0 1-.9-.9Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.35"
/>
<path d="M8.75 4.65l2.6 2.6" stroke="currentColor" strokeLinecap="round" strokeWidth="1.35" />
</svg>
)
}
function DeleteGlyph({ className = '' }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3.25 4.65h9.5" stroke="currentColor" strokeLinecap="round" strokeWidth="1.35" />
<path
d="M6.35 4.55V3.7c0-.55.45-1 1-1h1.3c.55 0 1 .45 1 1v.85"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.35"
/>
<path
d="M5.1 6.2l.35 5.65c.04.63.56 1.12 1.19 1.12h2.72c.63 0 1.15-.49 1.19-1.12l.35-5.65"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.35"
/>
</svg>
)
}
// Compact management tile for a single tag. Rename doubles as merge when the new
// name already exists, and delete applies across the scoped tag set.
function TagManageTile({
entry,
onSearch,
onRename,
onDelete,
}: {
entry: ExploreTagEntry
onSearch: (tag: string) => void
onRename: (from: string, to: string) => Promise<void>
onDelete: (tag: string) => Promise<void>
}) {
const [editing, setEditing] = useState(false)
const [value, setValue] = useState(entry.tag)
const [confirming, setConfirming] = useState(false)
const [busy, setBusy] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (editing) {
setValue(entry.tag)
setTimeout(() => inputRef.current?.select(), 0)
}
}, [editing, entry.tag])
const commitRename = async () => {
const next = value.trim()
if (!next || next === entry.tag) {
setEditing(false)
return
}
setBusy(true)
try {
await onRename(entry.tag, next)
setEditing(false)
} finally {
setBusy(false)
}
}
const sourceLabel = entry.has_ai_source
? entry.has_user_source
? 'Used by AI tags and user tags'
: 'AI-generated tag'
: 'User tag'
return (
<div
data-tag-manager-tile
className={`tag-manager-tile ${entry.has_ai_source ? 'tag-manager-tile-ai' : ''} group relative min-w-0 rounded-lg border bg-white/[0.018] px-3 py-2 transition-[background-color,border-color,transform] duration-150 focus-within:bg-white/[0.045] hover:bg-white/[0.04] ${
entry.has_ai_source
? 'border-white/[0.08] shadow-[inset_0_1px_0_rgba(125,211,252,0.055)] focus-within:border-white/[0.15] hover:border-white/[0.13]'
: 'border-white/[0.06] focus-within:border-white/[0.14] hover:border-white/[0.12]'
} ${editing || confirming ? 'min-h-[82px]' : 'min-h-[46px]'}`}
>
<div className="flex min-w-0 items-start gap-2">
{editing ? (
<input
ref={inputRef}
className="tag-manager-edit-input min-w-0 flex-1 rounded-md border border-blue-400/35 bg-black/25 px-2 py-1 text-sm text-white ring-1 ring-blue-500/30 outline-none"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
void commitRename()
}
if (e.key === 'Escape') setEditing(false)
}}
disabled={busy}
/>
) : (
<Tooltip label="Search this tag" delay={500} anchorToCursor className="min-w-0 flex-1">
<button
className="tag-manager-name block w-full truncate pr-36 text-left text-sm font-medium text-white/85 transition-colors hover:text-white"
onClick={() => onSearch(entry.tag)}
>
{entry.tag}
</button>
</Tooltip>
)}
<div className="absolute top-2 right-2.5 shrink-0">
{entry.has_ai_source ? (
<Tooltip label={sourceLabel} delay={400} anchorToCursor>
<span className="tag-manager-count tag-manager-count-ai inline-flex h-5 items-center gap-1 rounded-full border border-sky-300/10 bg-sky-300/[0.075] px-1.5 text-[10px] text-sky-200/75 tabular-nums">
<AiSourceGlyph className="tag-manager-ai-glyph h-3 w-3" />
{entry.count.toLocaleString()}
</span>
</Tooltip>
) : (
<span className="tag-manager-count rounded-full bg-white/[0.055] px-2 py-0.5 text-[10px] text-white/38 tabular-nums">
{entry.count.toLocaleString()}
</span>
)}
</div>
</div>
{editing ? (
<div className="mt-2 flex items-center gap-1">
<button
className="tag-manager-save rounded-md bg-blue-500/20 px-2 py-1 text-[11px] text-blue-200 transition-colors hover:bg-blue-500/30 disabled:opacity-50"
onClick={() => void commitRename()}
disabled={busy || !value.trim()}
>
Save
</button>
<button
className="tag-manager-secondary rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/50 transition-colors hover:bg-white/5 hover:text-white/80"
onClick={() => setEditing(false)}
disabled={busy}
>
Cancel
</button>
</div>
) : confirming ? (
<div className="mt-2 flex items-center gap-1">
<button
className="tag-manager-danger rounded-md bg-red-500/20 px-2 py-1 text-[11px] text-red-300 transition-colors hover:bg-red-500/30 disabled:opacity-50"
onClick={async () => {
setBusy(true)
try {
await onDelete(entry.tag)
setConfirming(false)
} finally {
setBusy(false)
}
}}
disabled={busy}
>
Delete
</button>
<button
className="tag-manager-secondary rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/50 transition-colors hover:bg-white/5 hover:text-white/80"
onClick={() => setConfirming(false)}
disabled={busy}
>
Cancel
</button>
</div>
) : (
<div className="pointer-events-none absolute top-1/2 right-[5rem] flex -translate-y-1/2 items-center gap-1 opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
<Tooltip label="Rename or merge into another tag" delay={400} anchorToCursor>
<button
className="tag-manager-action inline-flex h-6 w-6 items-center justify-center rounded-md border border-white/10 bg-gray-950/80 text-white/55 transition-colors hover:bg-white/8 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
onClick={() => setEditing(true)}
aria-label={`Rename ${entry.tag}`}
>
<RenameGlyph className="h-3.5 w-3.5" />
</button>
</Tooltip>
<Tooltip label="Delete this tag in the current scope" delay={400} anchorToCursor>
<button
className="tag-manager-action tag-manager-action-danger inline-flex h-6 w-6 items-center justify-center rounded-md border border-white/10 bg-gray-950/80 text-white/55 transition-colors hover:bg-red-500/10 hover:text-red-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400/80"
onClick={() => setConfirming(true)}
aria-label={`Delete ${entry.tag}`}
>
<DeleteGlyph className="h-3.5 w-3.5" />
</button>
</Tooltip>
</div>
)}
</div>
)
}
export function TagManageList({
entries,
onSearch,
onRename,
onDelete,
onResetAiTags,
scopeLabel,
}: {
entries: ExploreTagEntry[]
onSearch: (tag: string) => void
onRename: (from: string, to: string) => Promise<void>
onDelete: (tag: string) => Promise<void>
onResetAiTags: () => Promise<number>
scopeLabel: string
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLDivElement>(null)
const [query, setQuery] = useState('')
const [sort, setSort] = useState<TagManageSort>('count_desc')
const [columns, setColumns] = useState(3)
const [resetConfirming, setResetConfirming] = useState(false)
const [resetting, setResetting] = useState(false)
const [resetStatus, setResetStatus] = useState<string | null>(null)
useLayoutEffect(() => {
const el = measureRef.current
if (!el) return
const update = () => {
const width = el.getBoundingClientRect().width
setColumns(width >= 1160 ? 4 : width >= 780 ? 3 : width >= 520 ? 2 : 1)
}
update()
const ro = new ResizeObserver(update)
ro.observe(el)
return () => ro.disconnect()
}, [])
const filteredEntries = useMemo(() => {
const needle = query.trim().toLowerCase()
const filtered = needle
? entries.filter((entry) => entry.tag.toLowerCase().includes(needle))
: entries
return [...filtered].sort((left, right) => {
switch (sort) {
case 'count_asc':
return left.count - right.count || left.tag.localeCompare(right.tag)
case 'az':
return left.tag.localeCompare(right.tag)
case 'za':
return right.tag.localeCompare(left.tag)
case 'count_desc':
default:
return right.count - left.count || left.tag.localeCompare(right.tag)
}
})
}, [entries, query, sort])
const rowCount = Math.ceil(filteredEntries.length / columns)
const rowVirtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => scrollRef.current,
estimateSize: () => 54,
overscan: 7,
})
const visibleItems = rowVirtualizer.getVirtualItems()
const totalUses = useMemo(() => entries.reduce((sum, entry) => sum + entry.count, 0), [entries])
const runResetAiTags = async () => {
if (!resetConfirming) {
setResetConfirming(true)
setResetStatus(
`Reset AI tags for ${scopeLabel}? User tags are preserved, and retagging is not queued automatically.`
)
return
}
setResetting(true)
setResetStatus(null)
try {
const count = await onResetAiTags()
setResetStatus(
count === 0
? 'No AI tag data found for this scope.'
: `Reset AI tags for ${count.toLocaleString()} image${count === 1 ? '' : 's'}. Queue tagging when you're ready to retag.`
)
} catch (error) {
setResetStatus(String(error))
} finally {
setResetting(false)
setResetConfirming(false)
}
}
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="tag-manager-header shrink-0 border-b border-white/[0.05] bg-black/[0.08] px-6 py-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-[11px] text-white/32">
<span className="tag-manager-stat rounded-full bg-white/[0.045] px-2 py-1 tabular-nums">
{entries.length.toLocaleString()} tags
</span>
<span className="tag-manager-stat rounded-full bg-white/[0.045] px-2 py-1 tabular-nums">
{totalUses.toLocaleString()} uses
</span>
{query.trim() ? (
<span className="tag-manager-match rounded-full bg-blue-500/10 px-2 py-1 text-blue-200/70 tabular-nums">
{filteredEntries.length.toLocaleString()} matches
</span>
) : null}
</div>
<p className="tag-manager-help mt-2 max-w-2xl text-[11px] leading-relaxed text-white/28">
Rename tags to clean them up, rename into an existing tag to merge, or delete a tag
everywhere in the current scope.
</p>
{resetStatus ? (
<p className="mt-2 text-[11px] leading-relaxed text-amber-200/80">{resetStatus}</p>
) : null}
</div>
<div className="flex min-w-[320px] flex-1 flex-wrap justify-end gap-2">
<button
className={`tag-manager-reset-button h-9 rounded-lg border px-3 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
resetConfirming
? 'tag-manager-reset-button-confirm border-red-400/30 bg-red-500/15 text-red-200 hover:bg-red-500/25'
: 'border-white/8 bg-black/20 text-white/50 hover:bg-white/[0.06] hover:text-white/80'
}`}
onClick={() => void runResetAiTags()}
disabled={resetting}
>
{resetting ? 'Resetting...' : resetConfirming ? 'Confirm reset' : 'Reset AI tags'}
</button>
{resetConfirming ? (
<button
className="tag-manager-reset-cancel h-9 rounded-lg border border-white/8 bg-black/20 px-3 text-xs text-white/45 transition-colors hover:bg-white/[0.06] hover:text-white/75 disabled:opacity-50"
onClick={() => {
setResetConfirming(false)
setResetStatus(null)
}}
disabled={resetting}
>
Cancel
</button>
) : null}
<div className="relative min-w-[220px] flex-1 sm:max-w-sm">
<input
className="tag-manager-filter h-9 w-full rounded-lg border border-white/8 bg-black/20 px-3 pr-8 text-sm text-white/85 transition-colors outline-none placeholder:text-white/22 focus:border-blue-400/35 focus:bg-black/28"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Filter tags"
/>
{query ? (
<span className="absolute top-2 right-2 z-10">
<Tooltip label="Clear filter" delay={400} anchorToCursor>
<button
className="tag-manager-clear inline-flex h-5 w-5 items-center justify-center rounded-md text-white/35 transition-colors hover:bg-white/8 hover:text-white/75 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/70"
onClick={() => setQuery('')}
aria-label="Clear tag filter"
>
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.2}
d="M6 6l12 12M18 6L6 18"
/>
</svg>
</button>
</Tooltip>
</span>
) : null}
</div>
<Dropdown
value={sort}
onChange={setSort}
options={TAG_MANAGE_SORTS}
ariaLabel="Sort managed tags"
align="right"
/>
</div>
</div>
</div>
<div
ref={scrollRef}
data-tag-manager-scroll
className="min-h-0 flex-1 overflow-y-auto px-6 py-5"
>
<div ref={measureRef} className="mx-auto w-full max-w-7xl">
{filteredEntries.length === 0 ? (
<div className="tag-manager-empty flex h-48 items-center justify-center rounded-lg border border-white/[0.06] bg-white/[0.02] text-sm text-white/30">
No tags match that filter.
</div>
) : (
<div
className="relative w-full"
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{visibleItems.map((virtualRow) => {
const start = virtualRow.index * columns
const rowEntries = filteredEntries.slice(start, start + columns)
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className="absolute top-0 left-0 grid w-full gap-x-2 pb-2"
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{rowEntries.map((entry) => (
<TagManageTile
key={entry.tag}
entry={entry}
onSearch={onSearch}
onRename={onRename}
onDelete={onDelete}
/>
))}
</div>
)
})}
</div>
)}
</div>
</div>
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
export const ACCENTS = [
'#60a5fa',
'#c084fc',
'#4ade80',
'#fbbf24',
'#f472b4',
'#2dd4bf',
'#fb923c',
'#a78bfa',
'#34d399',
'#f87171',
]
// Darker variants of each accent for the light theme -- the bright originals are
// tuned for dark cards and wash out on the cream background.
export const LIGHT_ACCENTS = [
'#2563eb',
'#9333ea',
'#16a34a',
'#d97706',
'#db2777',
'#0d9488',
'#ea580c',
'#7c3aed',
'#059669',
'#dc2626',
]
export const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5))
export function seeded(n: number): number {
const x = Math.sin(n * 9301 + 49297) * 233280
return x - Math.floor(x)
}
+76
View File
@@ -0,0 +1,76 @@
import { DirEntry } from '../../store'
import { Tooltip } from '../Tooltip'
import { CheckIcon, ChevronRightIcon } from '../icons'
export function FolderRow({
entry,
selected,
alreadyAdded,
onToggle,
onNavigate,
}: {
entry: DirEntry
selected: boolean
alreadyAdded: boolean
onToggle: () => void
onNavigate: () => void
}) {
return (
<div
className={`group flex h-11 items-center gap-3 rounded-md border px-3 transition-colors ${
alreadyAdded
? 'light-theme:bg-gray-900 light-theme:text-gray-500 border-transparent bg-white/[0.025] text-gray-600'
: selected
? 'light-theme:border-gray-700/45 light-theme:bg-gray-800/55 light-theme:text-white border-white/15 bg-white/[0.085] text-white shadow-[inset_0_0_0_1px_rgb(255_255_255_/_0.035)]'
: 'light-theme:text-gray-500 light-theme:hover:bg-gray-900 light-theme:hover:text-white border-transparent text-gray-300 hover:bg-white/[0.055] hover:text-white'
}`}
>
<button
type="button"
className={`grid h-5 w-5 shrink-0 place-items-center rounded border transition-colors ${
selected
? 'light-theme:border-gray-700/55 light-theme:bg-gray-700 light-theme:text-white border-white/30 bg-gray-200 text-gray-950'
: 'light-theme:border-gray-700/50 light-theme:bg-gray-950 border-white/15 bg-white/[0.035] text-transparent hover:border-white/30'
} ${alreadyAdded ? 'cursor-not-allowed opacity-40' : ''}`}
onClick={onToggle}
disabled={alreadyAdded}
aria-label={selected ? `Remove ${entry.name} from folders to add` : `Choose ${entry.name}`}
>
<CheckIcon className="h-3.5 w-3.5" />
</button>
<Tooltip label={entry.path} anchorToCursor className="min-w-0 flex-1">
<button
type="button"
className="flex w-full min-w-0 items-center gap-2 text-left"
onClick={onNavigate}
>
<svg
className="h-4 w-4 shrink-0 text-amber-300/90"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" />
</svg>
<span className="truncate text-sm">{entry.name}</span>
</button>
</Tooltip>
{alreadyAdded ? (
<span className="light-theme:border-gray-700/40 light-theme:bg-gray-950 rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-500">
Added
</span>
) : null}
<Tooltip label={entry.has_children ? 'Open folder' : 'No subfolders'} anchorToCursor>
<button
type="button"
className="light-theme:hover:bg-gray-900 light-theme:hover:text-white rounded-md p-1 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={onNavigate}
>
<ChevronRightIcon className={`h-4 w-4 ${entry.has_children ? '' : 'opacity-45'}`} />
</button>
</Tooltip>
</div>
)
}
@@ -0,0 +1,75 @@
import { Tooltip } from '../Tooltip'
import { CloseIcon } from '../icons'
import { folderName } from './pathUtils'
export function StagedFoldersPanel({
stagedPaths,
onRemove,
onClear,
}: {
stagedPaths: string[]
onRemove: (path: string) => void
onClear: () => void
}) {
return (
<aside className="light-theme:border-gray-300/70 light-theme:bg-gray-900/35 flex min-h-0 w-full flex-col border-t border-white/[0.07] bg-white/[0.018] lg:w-80 lg:border-t-0 lg:border-l">
<div className="light-theme:border-gray-300/70 flex items-center justify-between gap-3 border-b border-white/[0.07] px-5 py-4">
<p className="text-[11px] font-semibold tracking-[0.16em] text-gray-500 uppercase">
Folders to add ({stagedPaths.length})
</p>
{stagedPaths.length > 0 ? (
<button
type="button"
className="light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={onClear}
>
Clear all
</button>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{stagedPaths.length === 0 ? (
<div className="light-theme:border-gray-700/35 flex h-full min-h-40 flex-col items-center justify-center rounded-md border border-dashed border-white/[0.08] px-5 text-center">
<p className="text-sm text-gray-500">No folders selected.</p>
<p className="light-theme:text-gray-500 mt-1 max-w-52 text-xs leading-relaxed text-gray-600">
Choose folders on the left and they will collect here.
</p>
</div>
) : (
<div className="space-y-2">
{stagedPaths.map((path) => (
<Tooltip key={path} label={path} anchorToCursor block>
<div className="group light-theme:border-gray-700/40 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 flex min-h-12 items-center gap-2 rounded-md border border-white/[0.07] bg-white/[0.045] px-3 py-2 text-gray-200 transition-colors hover:border-white/15 hover:bg-white/[0.07]">
<svg
className="h-4 w-4 shrink-0 text-amber-300/90"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" />
</svg>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{folderName(path)}</p>
<p className="light-theme:text-gray-500 mt-0.5 truncate text-[11px] text-gray-600">
{path}
</p>
</div>
<Tooltip label="Remove from folders to add" anchorToCursor>
<button
type="button"
className="light-theme:hover:bg-gray-700 rounded-md p-1 text-gray-500 opacity-80 transition-colors group-hover:opacity-100 hover:bg-white/[0.08] hover:text-white"
onClick={() => onRemove(path)}
aria-label={`Remove ${path} from folders to add`}
>
<CloseIcon className="h-3.5 w-3.5" />
</button>
</Tooltip>
</div>
</Tooltip>
))}
</div>
)}
</div>
</aside>
)
}
@@ -0,0 +1,13 @@
import { FolderAddResult } from '../../store'
export function StatusLine({ results }: { results: FolderAddResult[] | null }) {
if (!results) return null
const added = results.filter((result) => result.status === 'added').length
const skipped = results.filter((result) => result.status === 'skipped').length
const failed = results.filter((result) => result.status === 'error').length
return (
<p className="light-theme:text-gray-600 text-xs text-gray-500">
Added {added}, skipped {skipped}, failed {failed}.
</p>
)
}
+69
View File
@@ -0,0 +1,69 @@
export interface Breadcrumb {
label: string
path: string | null
}
export function normalizePath(path: string): string {
return path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
}
export function cleanAddressInput(path: string): string {
const trimmed = path.trim()
if (trimmed.length >= 2) {
const first = trimmed[0]
const last = trimmed[trimmed.length - 1]
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
return trimmed.slice(1, -1).trim()
}
}
return trimmed
}
export function friendlyDirectoryError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
if (/cannot find the path|os error 3|not found|no such file/i.test(message)) {
return 'Folder not found. Check the path and try again.'
}
return message
}
export function folderName(path: string): string {
const trimmed = path.replace(/[\\/]+$/, '')
const parts = trimmed.split(/[\\/]+/).filter(Boolean)
return parts.length > 0 ? parts[parts.length - 1] : path
}
export function buildBreadcrumbs(path: string | null): Breadcrumb[] {
if (!path) return [{ label: 'This PC / Home', path: null }]
const separator = path.includes('\\') ? '\\' : '/'
const normalized = path.replace(/[\\/]+$/, '')
const windowsDrive = normalized.match(/^[A-Za-z]:/)
if (windowsDrive) {
const drive = windowsDrive[0]
const rest = normalized
.slice(2)
.split(/[\\/]+/)
.filter(Boolean)
let current = drive
return [
{ label: 'This PC', path: null },
{ label: drive, path: drive },
...rest.map((part) => {
current = current.endsWith('\\') ? `${current}${part}` : `${current}\\${part}`
return { label: part, path: current }
}),
]
}
const parts = normalized.split(/[\\/]+/).filter(Boolean)
let current = separator === '/' ? '' : ''
return [
{ label: '/', path: null },
...parts.map((part) => {
current = `${current}/${part}`
return { label: part, path: current }
}),
]
}
@@ -0,0 +1,219 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { DirListing, FolderAddResult, useGalleryStore } from '../../store'
import {
buildBreadcrumbs,
cleanAddressInput,
friendlyDirectoryError,
normalizePath,
} from './pathUtils'
export function useFolderPicker() {
const folderPickerOpen = useGalleryStore((state) => state.folderPickerOpen)
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen)
const folders = useGalleryStore((state) => state.folders)
const listDirectories = useGalleryStore((state) => state.listDirectories)
const addFolders = useGalleryStore((state) => state.addFolders)
const [listing, setListing] = useState<DirListing | null>(null)
const [currentPath, setCurrentPath] = useState<string | null>(null)
const [addressDraft, setAddressDraft] = useState('')
const [addressEditing, setAddressEditing] = useState(false)
const [stagedPaths, setStagedPaths] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [adding, setAdding] = useState(false)
const [error, setError] = useState<string | null>(null)
const [results, setResults] = useState<FolderAddResult[] | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const addressInputRef = useRef<HTMLInputElement>(null)
const libraryPaths = useMemo(
() => new Set(folders.map((folder) => normalizePath(folder.path))),
[folders]
)
const stagedSet = useMemo(() => new Set(stagedPaths.map(normalizePath)), [stagedPaths])
const breadcrumbs = useMemo(() => buildBreadcrumbs(listing?.current ?? null), [listing?.current])
useEffect(() => {
if (!folderPickerOpen) return
let cancelled = false
setLoading(true)
setError(null)
void listDirectories(currentPath)
.then((nextListing) => {
if (cancelled) return
setListing(nextListing)
setAddressDraft(nextListing.current ?? '')
setAddressEditing(false)
scrollRef.current?.scrollTo({ top: 0, left: 0 })
})
.catch((loadError) => {
if (cancelled) return
setListing({ current: currentPath, parent: null, entries: [] })
setError(friendlyDirectoryError(loadError))
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [currentPath, folderPickerOpen, listDirectories])
useEffect(() => {
if (!folderPickerOpen) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (addressEditing) {
setAddressDraft(listing?.current ?? '')
setAddressEditing(false)
return
}
setFolderPickerOpen(false)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [addressEditing, folderPickerOpen, listing?.current, setFolderPickerOpen])
useEffect(() => {
if (!addressEditing) return
requestAnimationFrame(() => {
addressInputRef.current?.focus()
addressInputRef.current?.select()
})
}, [addressEditing])
useEffect(() => {
if (folderPickerOpen) return
setCurrentPath(null)
setAddressDraft('')
setAddressEditing(false)
setListing(null)
setStagedPaths([])
setError(null)
setResults(null)
setAdding(false)
}, [folderPickerOpen])
const entries = listing?.entries ?? []
const addressPath = cleanAddressInput(addressEditing ? addressDraft : (listing?.current ?? ''))
const normalizedAddressPath = addressPath ? normalizePath(addressPath) : ''
const addressAlreadyAdded = normalizedAddressPath
? libraryPaths.has(normalizedAddressPath)
: false
const addressAlreadyStaged = normalizedAddressPath ? stagedSet.has(normalizedAddressPath) : false
const togglePath = (path: string) => {
const normalized = normalizePath(path)
if (libraryPaths.has(normalized)) return
setResults(null)
setStagedPaths((current) => {
const exists = current.some((staged) => normalizePath(staged) === normalized)
return exists
? current.filter((staged) => normalizePath(staged) !== normalized)
: [...current, path]
})
}
const stagePath = (path: string) => {
const cleaned = cleanAddressInput(path)
if (!cleaned) {
setError('Enter a folder path first.')
return
}
const normalized = normalizePath(cleaned)
if (libraryPaths.has(normalized)) {
setError('That folder is already in your library.')
return
}
if (stagedSet.has(normalized)) {
setError('That folder is already selected.')
return
}
setError(null)
setResults(null)
setStagedPaths((current) => [...current, cleaned])
}
const navigateToAddress = () => {
const cleaned = cleanAddressInput(addressDraft)
setResults(null)
setError(null)
setCurrentPath(cleaned || null)
}
const updateAddressDraft = (nextDraft: string) => {
setAddressDraft(nextDraft)
setResults(null)
}
const beginAddressEdit = () => {
setAddressDraft(listing?.current ?? '')
setAddressEditing(true)
}
const removeStagedPath = (path: string) => {
const normalized = normalizePath(path)
setResults(null)
setStagedPaths((current) => current.filter((staged) => normalizePath(staged) !== normalized))
}
const clearStagedPaths = () => {
setResults(null)
setStagedPaths([])
}
const confirmAdd = async () => {
if (stagedPaths.length === 0 || adding) return
setAdding(true)
setError(null)
try {
const addResults = await addFolders(stagedPaths)
const failed = addResults.filter((result) => result.status === 'error')
setResults(addResults)
if (failed.length > 0) {
setStagedPaths(stagedPaths.filter((_, i) => addResults[i]?.status === 'error'))
setError(failed.map((failure) => failure.data).join('; '))
return
}
setFolderPickerOpen(false)
} catch (addError) {
setError(addError instanceof Error ? addError.message : String(addError))
} finally {
setAdding(false)
}
}
return {
adding,
addressAlreadyAdded,
addressAlreadyStaged,
addressDraft,
addressEditing,
addressInputRef,
addressPath,
beginAddressEdit,
breadcrumbs,
clearStagedPaths,
confirmAdd,
entries,
error,
folderPickerOpen,
libraryPaths,
listing,
loading,
navigateToAddress,
removeStagedPath,
results,
scrollRef,
setCurrentPath,
setFolderPickerOpen,
stagePath,
stagedPaths,
stagedSet,
togglePath,
updateAddressDraft,
}
}
@@ -0,0 +1,76 @@
import { ParsedSearch } from '../../store'
import { PhotoIcon } from '../icons'
export function GalleryLoadingState({
isSimilarResults,
parsedSearch,
}: {
isSimilarResults: boolean
parsedSearch: ParsedSearch
}) {
return (
<div className="absolute inset-0 flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
<div className="min-w-72 rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8">
<div className="mx-auto h-5 w-5 animate-spin rounded-full border-2 border-white/20 border-t-white/60" />
<p className="mt-4 text-sm font-medium text-white/40">
{isSimilarResults
? 'Finding similar images'
: parsedSearch.mode === 'semantic' && parsedSearch.query.length > 0
? `Searching for matches to "${parsedSearch.query}"`
: parsedSearch.mode === 'tag' && parsedSearch.query.length > 0
? `Searching tags for "${parsedSearch.query}"`
: 'Loading media'}
</p>
<p className="mt-1 text-xs text-white/20">
{isSimilarResults
? 'Comparing visual embeddings'
: parsedSearch.mode === 'semantic' && parsedSearch.query.length > 0
? 'Semantic search can take a little longer than filename search'
: parsedSearch.mode === 'tag' && parsedSearch.query.length > 0
? 'Matching against AI and user tags'
: 'Fetching results'}
</p>
</div>
</div>
)
}
export function GalleryEmptyState({
imageLoadError,
isSimilarResults,
parsedSearch,
}: {
imageLoadError: string | null
isSimilarResults: boolean
parsedSearch: ParsedSearch
}) {
return (
<div className="absolute inset-0 flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8">
<PhotoIcon className="mx-auto mb-4 h-12 w-12 text-white/10" strokeWidth={0.75} />
<p className="text-sm font-medium text-white/30">
{imageLoadError
? 'Could not load results'
: isSimilarResults
? 'No similar images found'
: parsedSearch.mode === 'semantic' && parsedSearch.query.length > 0
? 'No semantic matches found'
: parsedSearch.mode === 'tag' && parsedSearch.query.length > 0
? 'No tag matches found'
: 'No media found'}
</p>
<p className="mt-1 text-xs text-white/15">
{imageLoadError
? imageLoadError
: isSimilarResults
? 'This item may be visually isolated, or more embeddings may need to finish processing'
: parsedSearch.mode === 'semantic' && parsedSearch.query.length > 0
? 'Try a broader phrase, or wait for more embeddings to finish processing'
: parsedSearch.mode === 'tag' && parsedSearch.query.length > 0
? 'Try a shorter tag, or wait for more tagging jobs to finish'
: 'Try adjusting your filters or add a new folder'}
</p>
</div>
</div>
)
}
+170
View File
@@ -0,0 +1,170 @@
import { useState } from 'react'
import { ImageRecord, useGalleryStore } from '../../store'
import { mediaSrc } from '../../lib/mediaSrc'
import { Tooltip } from '../Tooltip'
import { CheckIcon, PhotoIcon, PlayIcon, StarIcon, WarningIcon } from '../icons'
import { formatDuration } from './format'
import { TruncatedFilename } from './TruncatedFilename'
export function ImageTile({
image,
onClick,
onContextMenu,
}: {
image: ImageRecord
onClick: () => void
onContextMenu: (event: React.MouseEvent<HTMLDivElement>) => void
}) {
const [loaded, setLoaded] = useState(false)
const [errored, setErrored] = useState(false)
const findSimilar = useGalleryStore((state) => state.findSimilar)
const selected = useGalleryStore((state) => state.gallerySelectedIds.has(image.id))
const selectionActive = useGalleryStore((state) => state.gallerySelectedIds.size > 0)
const toggleGallerySelected = useGalleryStore((state) => state.toggleGallerySelected)
const canFindSimilar = image.embedding_status === 'ready'
const src = mediaSrc(image.thumbnail_path)
return (
<div
className={`media-dark-surface group relative overflow-hidden rounded-xl bg-white/[0.04] text-left transition-shadow focus:outline-none ${
selected ? 'ring-2 ring-blue-400/80 ring-inset' : ''
}`}
style={{ width: '100%', aspectRatio: '1 / 1' }}
onContextMenu={onContextMenu}
>
<button
type="button"
className="absolute inset-0 z-10 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
aria-label={`Open ${image.filename}`}
onClick={(event) => {
event.stopPropagation()
if (selectionActive) toggleGallerySelected(image.id)
else onClick()
}}
onDoubleClick={(event) => {
event.stopPropagation()
onClick()
}}
/>
<button
type="button"
role="checkbox"
aria-checked={selected}
aria-label={selected ? 'Deselect' : 'Select'}
className="group/cb absolute top-0 left-0 z-20 h-11 w-11 cursor-pointer"
onClick={(event) => {
event.stopPropagation()
toggleGallerySelected(image.id)
}}
>
<div
className={`absolute top-2 left-2 flex h-5 w-5 items-center justify-center rounded-full border transition-all duration-150 ${
selected
? 'border-blue-400 bg-blue-500 text-white opacity-100'
: 'border-white/70 bg-black/40 text-transparent opacity-0 backdrop-blur-sm group-hover/cb:opacity-100'
}`}
>
<CheckIcon className="h-3 w-3" strokeWidth={3} />
</div>
</button>
{src && !errored ? (
<>
{!loaded ? <div className="absolute inset-0 animate-pulse bg-white/[0.04]" /> : null}
<img
src={src}
alt={image.filename}
className={`h-full w-full object-cover transition-all duration-300 ${
loaded ? 'scale-100 opacity-100' : 'scale-[1.02] opacity-0'
} group-hover:scale-[1.03]`}
loading="lazy"
onLoad={() => setLoaded(true)}
onError={() => setErrored(true)}
/>
</>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-white/[0.03] text-white/20">
{image.media_kind === 'video' ? (
<PlayIcon className="h-7 w-7" />
) : (
<PhotoIcon className="h-7 w-7" strokeWidth={1} />
)}
</div>
)}
{image.media_kind === 'video' ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="rounded-full bg-black/40 p-3 text-white opacity-50 backdrop-blur-sm transition-opacity duration-200 group-hover:opacity-90">
<PlayIcon className="h-5 w-5" />
</div>
</div>
) : null}
<div className="pointer-events-none absolute top-2 right-2 flex flex-col items-end gap-1">
{image.embedding_status === 'failed' ? (
<Tooltip
label={image.embedding_error ?? 'Embedding failed'}
followCursor
className="pointer-events-auto"
>
<div className="flex items-center gap-1 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 backdrop-blur-sm">
<WarningIcon className="h-2.5 w-2.5 shrink-0" strokeWidth={2.5} />
</div>
</Tooltip>
) : null}
{image.favorite ? (
<div className="rounded-full bg-black/50 p-1 text-rose-400 backdrop-blur-sm">
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" />
</svg>
</div>
) : null}
{image.rating > 0 ? (
<div className="flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-1 text-amber-300 backdrop-blur-sm">
{Array.from({ length: image.rating }, (_, index) => (
<StarIcon key={index} className="h-2.5 w-2.5" />
))}
</div>
) : null}
{image.media_kind === 'video' && image.duration_ms ? (
<div className="rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80 backdrop-blur-sm">
{formatDuration(image.duration_ms)}
</div>
) : null}
</div>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
<div className="absolute right-0 bottom-0 left-0 z-20 translate-y-1 p-2.5 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100">
<TruncatedFilename filename={image.filename} />
<div className="mt-1.5 flex items-center justify-between gap-2">
{image.rating > 0 ? (
<div className="flex items-center gap-0.5">
{Array.from({ length: image.rating }, (_, index) => (
<StarIcon key={index} className="h-2.5 w-2.5 text-amber-300" />
))}
</div>
) : (
<span />
)}
<button
className={`pointer-events-auto relative z-20 rounded-md px-2 py-0.5 text-[10px] backdrop-blur-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80 ${
canFindSimilar
? 'bg-white/10 text-white/80 hover:bg-white/20 hover:text-white'
: 'cursor-not-allowed bg-white/5 text-white/30'
}`}
onClick={(event) => {
event.stopPropagation()
if (!canFindSimilar) return
findSimilar(image.id, image.folder_id)
}}
disabled={!canFindSimilar}
>
Similar
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,34 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { Tooltip } from '../Tooltip'
export function TruncatedFilename({ filename }: { filename: string }) {
const textRef = useRef<HTMLParagraphElement>(null)
const [isTruncated, setIsTruncated] = useState(false)
useLayoutEffect(() => {
const text = textRef.current
if (!text) return
const update = () => {
setIsTruncated(text.scrollWidth > text.clientWidth)
}
update()
const observer = new ResizeObserver(update)
observer.observe(text)
return () => observer.disconnect()
}, [filename])
const label = (
<p ref={textRef} className="truncate text-[12px] leading-tight font-medium text-white">
{filename}
</p>
)
return (
<Tooltip label={filename} delay={500} block followCursor disabled={!isTruncated}>
{label}
</Tooltip>
)
}
+11
View File
@@ -0,0 +1,11 @@
export function formatDuration(durationMs: number | null): string | null {
if (!durationMs || durationMs <= 0) return null
const totalSeconds = Math.floor(durationMs / 1000)
const seconds = totalSeconds % 60
const minutes = Math.floor(totalSeconds / 60) % 60
const hours = Math.floor(totalSeconds / 3600)
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Shared icons for paths that repeat across the app. One-off icons stay
* inline at their call site — only extract here once a shape shows up in
* three or more places. Stroke icons take a per-site strokeWidth because
* weights legitimately differ by context (menus vs badges vs empty states).
*/
export interface IconProps {
className?: string
strokeWidth?: number
}
function strokeIcon(d: string, defaultStrokeWidth: number, displayName: string) {
function Icon({ className = '', strokeWidth = defaultStrokeWidth }: IconProps) {
return (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d={d} />
</svg>
)
}
Icon.displayName = displayName
return Icon
}
export const CheckIcon = strokeIcon('M5 13l4 4L19 7', 2.5, 'CheckIcon')
export const CloseIcon = strokeIcon('M6 18L18 6M6 6l12 12', 2, 'CloseIcon')
export const ChevronDownIcon = strokeIcon('M19 9l-7 7-7-7', 2, 'ChevronDownIcon')
export const ChevronRightIcon = strokeIcon('M9 5l7 7-7 7', 2, 'ChevronRightIcon')
export const PlusIcon = strokeIcon('M12 4v16m8-8H4', 1.75, 'PlusIcon')
export const PhotoIcon = strokeIcon(
'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z',
1.5,
'PhotoIcon'
)
export const FolderIcon = strokeIcon(
'M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z',
1.5,
'FolderIcon'
)
export const WarningIcon = strokeIcon(
'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',
2,
'WarningIcon'
)
export function StarIcon({ className = '' }: { className?: string }) {
return (
<svg className={className} fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
)
}
export function PlayIcon({ className = '' }: { className?: string }) {
return (
<svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 5v14l11-7z" />
</svg>
)
}
@@ -0,0 +1,104 @@
import { useState } from 'react'
import { Album } from '../../store'
interface LightboxAlbumMenuProps {
imageId: number
albums: Album[]
addToAlbum: (albumId: number, imageIds: number[]) => Promise<number>
createAlbum: (name: string) => Promise<Album>
}
export function LightboxAlbumMenu({
imageId,
albums,
addToAlbum,
createAlbum,
}: LightboxAlbumMenuProps) {
const [albumMenuOpen, setAlbumMenuOpen] = useState(false)
const [albumAddedTo, setAlbumAddedTo] = useState<number | null>(null)
const [newAlbumName, setNewAlbumName] = useState('')
const [albumAdding, setAlbumAdding] = useState(false)
return (
<div className="relative">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs tracking-wider text-gray-500 uppercase">Albums</p>
<button
className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => {
setAlbumMenuOpen((open) => !open)
setAlbumAddedTo(null)
}}
>
Add to album
</button>
</div>
{albumMenuOpen ? (
<div className="rounded-lg border border-white/10 bg-white/[0.03] p-1.5">
<div className="max-h-40 overflow-y-auto">
{albums.length === 0 ? (
<p className="px-2 py-1.5 text-[11px] text-gray-600">
No albums yet create one below.
</p>
) : (
albums.map((album) => (
<button
key={album.id}
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1 text-left text-xs text-gray-300 transition-colors hover:bg-white/5 hover:text-white"
onClick={() => {
if (albumAdding) return
setAlbumAdding(true)
void addToAlbum(album.id, [imageId])
.then(() => setAlbumAddedTo(album.id))
.catch(() => undefined)
.finally(() => setAlbumAdding(false))
}}
disabled={albumAdding}
>
<span className="truncate">{album.name}</span>
{albumAddedTo === album.id ? (
<span className="shrink-0 text-[10px] text-emerald-400">Added</span>
) : (
<span className="shrink-0 text-[10px] text-gray-600">{album.image_count}</span>
)}
</button>
))
)}
</div>
<form
className="mt-1 flex gap-1 border-t border-white/[0.06] pt-1.5"
onSubmit={(event) => {
event.preventDefault()
const name = newAlbumName.trim()
if (!name || albumAdding) return
setAlbumAdding(true)
void createAlbum(name)
.then(async (album) => {
await addToAlbum(album.id, [imageId])
setAlbumAddedTo(album.id)
setNewAlbumName('')
})
.catch(() => undefined)
.finally(() => setAlbumAdding(false))
}}
>
<input
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white placeholder-gray-600 focus:border-white/20 focus:outline-none"
placeholder="New album…"
value={newAlbumName}
onChange={(event) => setNewAlbumName(event.target.value)}
disabled={albumAdding}
/>
<button
type="submit"
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50"
disabled={albumAdding || !newAlbumName.trim()}
>
Add
</button>
</form>
</div>
) : null}
</div>
)
}
@@ -0,0 +1,509 @@
import { MutableRefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { Album, ImageExif, ImageRecord, ImageTag } from '../../store'
import { Tooltip } from '../Tooltip'
import { CloseIcon, StarIcon } from '../icons'
import { embeddingLabel, formatBytes, formatDate, formatDuration, ratingPill } from './format'
import { LightboxAlbumMenu } from './LightboxAlbumMenu'
interface LightboxDetailsPanelProps {
selectedImage: ImageRecord
currentIndex: number
imageCount: number
imageTags: ImageTag[]
setImageTags: React.Dispatch<React.SetStateAction<ImageTag[]>>
imageExif: ImageExif | null
tagInput: string
setTagInput: React.Dispatch<React.SetStateAction<string>>
tagAdding: boolean
setTagAdding: React.Dispatch<React.SetStateAction<boolean>>
tagsExpanded: boolean
setTagsExpanded: React.Dispatch<React.SetStateAction<boolean>>
taggingQueued: boolean
setTaggingQueued: React.Dispatch<React.SetStateAction<boolean>>
currentImageIdRef: MutableRefObject<number | null>
albums: Album[]
canFindSimilar: boolean
canSearchRegion: boolean
regionSelectMode: boolean
regionSearching: boolean
taggerReady: boolean
taggerButtonTooltip: string
closeImage: () => void
findSimilar: (imageId: number, sourceFolderId: number | null) => Promise<void>
updateImageDetails: (
imageId: number,
updates: { favorite?: boolean; rating?: number }
) => Promise<void>
addUserTag: (imageId: number, tag: string) => Promise<ImageTag>
removeTag: (tagId: number) => Promise<void>
queueTaggingForImage: (imageId: number) => Promise<number>
addToAlbum: (albumId: number, imageIds: number[]) => Promise<number>
createAlbum: (name: string) => Promise<Album>
onToggleRegionMode: () => void
}
export function LightboxDetailsPanel({
selectedImage,
currentIndex,
imageCount,
imageTags,
setImageTags,
imageExif,
tagInput,
setTagInput,
tagAdding,
setTagAdding,
tagsExpanded,
setTagsExpanded,
taggingQueued,
setTaggingQueued,
currentImageIdRef,
albums,
canFindSimilar,
canSearchRegion,
regionSelectMode,
regionSearching,
taggerReady,
taggerButtonTooltip,
closeImage,
findSimilar,
updateImageDetails,
addUserTag,
removeTag,
queueTaggingForImage,
addToAlbum,
createAlbum,
onToggleRegionMode,
}: LightboxDetailsPanelProps) {
const aiRating = selectedImage.ai_rating ? ratingPill(selectedImage.ai_rating) : null
const hasCameraInfo =
imageExif &&
(imageExif.make ||
imageExif.model ||
imageExif.lens ||
imageExif.f_number ||
imageExif.exposure_time ||
imageExif.iso ||
imageExif.focal_length ||
(imageExif.gps_lat != null && imageExif.gps_lon != null))
return (
<div className="lightbox-panel flex w-64 shrink-0 flex-col border-l border-white/5 bg-gray-900/95 lg:w-72">
<div className="flex shrink-0 items-center justify-between px-5 pt-5 pb-4">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{selectedImage.filename}</p>
<p className="text-xs text-gray-500">Details</p>
</div>
<div className="flex items-center gap-2">
<Tooltip label={selectedImage.favorite ? 'Remove favorite' : 'Add favorite'} followCursor>
<button
className={`rounded-full border p-2 ${selectedImage.favorite ? 'border-rose-400/40 bg-rose-500/10 text-rose-300' : 'border-white/10 bg-white/5 text-gray-400 hover:text-white'}`}
onClick={() =>
void updateImageDetails(selectedImage.id, { favorite: !selectedImage.favorite })
}
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" />
</svg>
</button>
</Tooltip>
<Tooltip
label={canFindSimilar ? 'Find similar images' : 'Embeddings not ready'}
followCursor
>
<button
className={`flex items-center gap-1.5 rounded-full border px-2 py-1.5 text-xs lg:px-3 ${
canFindSimilar
? 'border-white/10 bg-white/5 text-gray-300 hover:text-white'
: 'cursor-not-allowed border-white/5 bg-white/[0.03] text-gray-600'
}`}
onClick={() => {
if (!canFindSimilar) return
void findSimilar(selectedImage.id, selectedImage.folder_id)
}}
disabled={!canFindSimilar}
>
<svg
className="h-4 w-4 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.6}
d="M13 3l1.55 4.65L19 9.2l-4.45 1.55L13 15.4l-1.55-4.65L7 9.2l4.45-1.55L13 3z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.6}
d="M5.5 14.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2L2.5 17.5l2.2-.8.8-2.2z"
/>
</svg>
<span className="hidden lg:inline">
{canFindSimilar ? 'Similar' : 'Embeddings not ready'}
</span>
</button>
</Tooltip>
</div>
<button className="rounded p-1 text-gray-400 hover:text-white" onClick={closeImage}>
<CloseIcon className="h-4 w-4" />
</button>
</div>
{canSearchRegion && (
<div className="shrink-0 px-5 pb-3">
<Tooltip
label={
regionSelectMode
? 'Cancel region selection'
: 'Draw a region on the image to search for similar content'
}
followCursor
>
<button
className={`w-full rounded-lg border px-3 py-2 text-xs transition-colors ${
regionSelectMode
? 'border-violet-400/40 bg-violet-500/15 text-violet-300 hover:bg-violet-500/20'
: regionSearching
? 'cursor-not-allowed border-white/5 bg-white/[0.03] text-gray-500'
: 'border-white/10 bg-white/5 text-gray-300 hover:bg-white/10 hover:text-white'
}`}
onClick={() => {
if (regionSearching) return
onToggleRegionMode()
}}
disabled={regionSearching}
>
{regionSearching ? (
<span className="flex items-center justify-center gap-1.5">
<svg className="h-3 w-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
Searching region
</span>
) : regionSelectMode ? (
<span className="flex items-center justify-center gap-1.5">
<CloseIcon className="h-3 w-3" />
Cancel selection
</span>
) : (
<span className="flex items-center justify-center gap-1.5">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
/>
</svg>
Search within image
</span>
)}
</button>
</Tooltip>
</div>
)}
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 pb-4 text-sm">
<div className="grid grid-cols-2 gap-x-6 gap-y-4">
<div className="col-span-2">
<p className="mb-1 text-xs tracking-wider text-gray-500 uppercase">Rating</p>
<div className="flex items-center gap-1">
{Array.from({ length: 5 }, (_, index) => {
const rating = index + 1
return (
<Tooltip
key={rating}
label={`Set ${rating} star rating`}
followCursor
delay={750}
>
<button
className="rounded-md p-1"
onClick={() => void updateImageDetails(selectedImage.id, { rating })}
>
<StarIcon
className={`h-5 w-5 ${rating <= selectedImage.rating ? 'text-amber-300' : 'text-white/20 hover:text-white/50'}`}
/>
</button>
</Tooltip>
)
})}
{selectedImage.rating > 0 ? (
<Tooltip label="Remove rating" followCursor>
<button
className="ml-2 rounded-md border border-white/10 p-1.5 text-gray-400 hover:bg-white/5 hover:text-white"
onClick={() => void updateImageDetails(selectedImage.id, { rating: 0 })}
>
<CloseIcon className="h-3.5 w-3.5" />
</button>
</Tooltip>
) : null}
</div>
</div>
<div>
<p className="mb-1 text-xs tracking-wider text-gray-500 uppercase">Dimensions</p>
<p className="text-white">
{selectedImage.width && selectedImage.height
? `${selectedImage.width} x ${selectedImage.height}px`
: 'Pending / unavailable'}
</p>
</div>
{selectedImage.media_kind === 'video' ? (
<>
<div>
<p className="mb-1 text-xs tracking-wider text-gray-500 uppercase">Duration</p>
<p className="text-white">{formatDuration(selectedImage.duration_ms)}</p>
</div>
<div>
<p className="mb-1 text-xs tracking-wider text-gray-500 uppercase">Video codec</p>
<p className="text-white">{selectedImage.video_codec ?? 'Pending / unavailable'}</p>
</div>
<div>
<p className="mb-1 text-xs tracking-wider text-gray-500 uppercase">Audio codec</p>
<p className="text-white">{selectedImage.audio_codec ?? 'None / unavailable'}</p>
</div>
{selectedImage.metadata_error ? (
<div className="col-span-2">
<p className="mb-1 text-xs tracking-wider text-gray-500 uppercase">Metadata</p>
<p className="text-amber-300">{selectedImage.metadata_error}</p>
</div>
) : null}
</>
) : null}
<div>
<p className="mb-1 text-xs tracking-wider text-gray-500 uppercase">Type</p>
<p className="text-white">{selectedImage.mime_type}</p>
</div>
<div>
<p className="mb-1 text-xs tracking-wider text-gray-500 uppercase">File size</p>
<p className="text-white">{formatBytes(selectedImage.file_size)}</p>
</div>
<div className="col-span-2">
<p className="mb-1 text-xs tracking-wider text-gray-500 uppercase">Modified</p>
<p className="text-white">{formatDate(selectedImage.modified_at)}</p>
</div>
<div className="col-span-2">
<p className="mb-1 text-xs tracking-wider text-gray-500 uppercase">Embedding</p>
<p className="text-white">
{embeddingLabel(selectedImage.embedding_status, selectedImage.embedding_model)}
</p>
{selectedImage.embedding_error ? (
<p className="mt-1 text-xs text-amber-300">{selectedImage.embedding_error}</p>
) : null}
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-xs tracking-wider text-gray-500 uppercase">Tags</p>
<div className="flex items-center gap-1.5">
{aiRating ? (
<span
className={`rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${aiRating.className}`}
>
{aiRating.label}
</span>
) : null}
{selectedImage.media_kind === 'image' ? (
<Tooltip label={taggerButtonTooltip} followCursor>
<button
className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
disabled={!taggerReady || taggingQueued}
onClick={() => {
setTaggingQueued(true)
void queueTaggingForImage(selectedImage.id).catch(() => undefined)
}}
>
{taggingQueued ? 'Queued' : 'AI tags'}
</button>
</Tooltip>
) : null}
</div>
</div>
{imageTags.length > 0 ? (
<>
<div className="flex flex-wrap gap-1.5">
{(tagsExpanded ? imageTags : imageTags.slice(0, 8)).map((tag) => (
<Tooltip
key={tag.id}
label={
tag.source === 'ai' && tag.confidence !== null
? `AI confidence: ${(tag.confidence * 100).toFixed(0)}%`
: ''
}
followCursor
disabled={tag.source !== 'ai' || tag.confidence === null}
>
<span
className={`group flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs ${
tag.source === 'ai'
? 'border-sky-400/20 bg-sky-500/8 text-sky-300'
: 'border-white/10 bg-white/5 text-gray-300'
}`}
>
{tag.tag}
<Tooltip label="Remove tag" followCursor>
<button
className="text-gray-600 opacity-0 transition-opacity group-hover:opacity-100 hover:text-white"
onClick={() => {
void removeTag(tag.id).then(() =>
setImageTags((prev) => prev.filter((item) => item.id !== tag.id))
)
}}
>
<CloseIcon className="h-3 w-3" />
</button>
</Tooltip>
</span>
</Tooltip>
))}
</div>
{imageTags.length > 8 && (
<button
className="mt-1.5 text-xs text-gray-500 transition-colors hover:text-gray-300"
onClick={() => setTagsExpanded((expanded) => !expanded)}
>
{tagsExpanded ? 'Show less' : `+${imageTags.length - 8} more`}
</button>
)}
</>
) : (
<p className="text-xs text-gray-600">No tags yet</p>
)}
<form
className="mt-2 flex gap-1.5"
onSubmit={(event) => {
event.preventDefault()
const raw = tagInput.trim()
if (!raw || tagAdding) return
setTagAdding(true)
const taggedImageId = selectedImage.id
void addUserTag(taggedImageId, raw)
.then((newTag) => {
if (currentImageIdRef.current !== taggedImageId) return
setImageTags((prev) => [...prev, newTag])
setTagInput('')
})
.catch(() => undefined)
.finally(() => setTagAdding(false))
}}
>
<input
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white placeholder-gray-600 focus:border-white/20 focus:outline-none"
placeholder="Add tag…"
value={tagInput}
onChange={(event) => setTagInput(event.target.value)}
disabled={tagAdding}
/>
<button
type="submit"
className="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
disabled={tagAdding || !tagInput.trim()}
>
Add
</button>
</form>
</div>
<LightboxAlbumMenu
imageId={selectedImage.id}
albums={albums}
addToAlbum={addToAlbum}
createAlbum={createAlbum}
/>
{hasCameraInfo ? (
<div>
<p className="mb-2 text-xs tracking-wider text-gray-500 uppercase">Camera</p>
<div className="space-y-1.5">
{imageExif.make || imageExif.model ? (
<p className="text-sm text-white">
{[imageExif.make, imageExif.model].filter(Boolean).join(' ')}
</p>
) : null}
{imageExif.lens ? <p className="text-xs text-gray-400">{imageExif.lens}</p> : null}
{imageExif.f_number ||
imageExif.exposure_time ||
imageExif.iso ||
imageExif.focal_length ? (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-gray-400">
{imageExif.f_number ? <span>{imageExif.f_number}</span> : null}
{imageExif.exposure_time ? <span>{imageExif.exposure_time}</span> : null}
{imageExif.iso ? <span>ISO {imageExif.iso}</span> : null}
{imageExif.focal_length ? <span>{imageExif.focal_length}</span> : null}
</div>
) : null}
{imageExif.gps_lat != null && imageExif.gps_lon != null ? (
<Tooltip label="Open location in your browser" anchorToCursor>
<button
className="inline-flex items-center gap-1 text-xs text-sky-400 transition-colors hover:text-sky-300"
onClick={() =>
void invoke('open_map_location', {
params: { lat: imageExif.gps_lat, lon: imageExif.gps_lon },
})
}
>
{imageExif.gps_lat.toFixed(5)}, {imageExif.gps_lon.toFixed(5)}
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</button>
</Tooltip>
) : null}
</div>
</div>
) : null}
<div>
<div className="mb-1 flex items-center justify-between">
<p className="text-xs tracking-wider text-gray-500 uppercase">Path</p>
<Tooltip label="Reveal in Explorer" anchorToCursor>
<button
className="rounded p-0.5 text-gray-600 transition-colors hover:text-gray-300"
onClick={() => void revealItemInDir(selectedImage.path)}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"
/>
</svg>
</button>
</Tooltip>
</div>
<p className="text-xs break-all text-gray-400">{selectedImage.path}</p>
</div>
</div>
<div className="mt-auto shrink-0 px-5 pt-2 pb-4 text-center text-xs text-gray-600">
{currentIndex + 1} / {imageCount}
</div>
</div>
)
}
@@ -0,0 +1,33 @@
import { ChevronRightIcon } from '../icons'
interface LightboxNavButtonProps {
direction: 'previous' | 'next'
disabled: boolean
onClick: () => void
}
export function LightboxNavButton({ direction, disabled, onClick }: LightboxNavButtonProps) {
const isNext = direction === 'next'
return (
<button
className={`absolute top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20 ${
isNext ? 'right-72 lg:right-80' : 'left-4'
}`}
disabled={disabled}
onClick={(event) => {
event.stopPropagation()
onClick()
}}
aria-label={isNext ? 'Next image' : 'Previous image'}
>
{isNext ? (
<ChevronRightIcon className="h-5 w-5" />
) : (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
)}
</button>
)
}
@@ -0,0 +1,200 @@
import { AnimatePresence, motion } from 'framer-motion'
import { RefObject } from 'react'
import { ImageRecord } from '../../store'
import { mediaSrc } from '../../lib/mediaSrc'
import { VideoPlayer } from '../VideoPlayer'
import { Tooltip } from '../Tooltip'
import { SelectionOverlay, ViewTransform } from './types'
import { MAX_ZOOM, MIN_ZOOM, zoomViewAt } from './viewTransform'
interface LightboxViewportProps {
selectedImage: ImageRecord
imageViewportRef: RefObject<HTMLDivElement | null>
imgRef: RefObject<HTMLImageElement | null>
view: ViewTransform
zoom: number
regionSelectMode: boolean
isPanning: boolean
selectionOverlay: SelectionOverlay | null
canStartSlideshow: boolean
onStartSlideshow: () => void
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void
onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void
setView: React.Dispatch<React.SetStateAction<ViewTransform>>
clampPan: (view: ViewTransform) => ViewTransform
}
export function LightboxViewport({
selectedImage,
imageViewportRef,
imgRef,
view,
zoom,
regionSelectMode,
isPanning,
selectionOverlay,
canStartSlideshow,
onStartSlideshow,
onPointerDown,
onPointerMove,
onPointerUp,
setView,
clampPan,
}: LightboxViewportProps) {
return (
<div
ref={imageViewportRef}
className={`group relative flex flex-1 items-center justify-center overflow-hidden p-10 ${
regionSelectMode
? 'cursor-crosshair select-none'
: isPanning
? 'cursor-grabbing select-none'
: zoom > 1 && selectedImage.media_kind === 'image'
? 'cursor-grab'
: ''
}`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
{regionSelectMode && (
<div className="pointer-events-none absolute inset-x-0 top-4 z-20 flex justify-center">
<div className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-xs text-gray-300 backdrop-blur">
<svg
className="h-3.5 w-3.5 text-violet-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
/>
</svg>
Draw a region to search {' '}
<kbd className="ml-1 rounded border border-white/20 bg-white/10 px-1.5 py-0.5 font-mono text-[10px]">
Esc
</kbd>{' '}
to cancel
</div>
</div>
)}
{selectionOverlay && selectionOverlay.width > 4 && selectionOverlay.height > 4 && (
<div
className="pointer-events-none fixed z-30 rounded border-2 border-violet-400 bg-violet-400/15 shadow-[0_0_0_9999px_rgba(0,0,0,0.35)]"
style={{
left: selectionOverlay.left,
top: selectionOverlay.top,
width: selectionOverlay.width,
height: selectionOverlay.height,
}}
/>
)}
<AnimatePresence mode="wait">
<motion.div
key={selectedImage.id}
className={
selectedImage.media_kind === 'video'
? 'absolute inset-0'
: 'flex items-center justify-center'
}
initial={{ opacity: 0.3, scale: 0.985 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0.3, scale: 0.985 }}
transition={{ duration: 0.12 }}
>
{selectedImage.media_kind === 'video' ? (
<VideoPlayer src={mediaSrc(selectedImage.path) ?? ''} />
) : (
<img
ref={imgRef}
src={mediaSrc(selectedImage.path) ?? ''}
alt={selectedImage.filename}
className="max-w-full rounded-2xl shadow-2xl"
draggable={false}
style={{
maxHeight: 'calc(100vh - 10rem)',
transform: `translate(${view.panX}px, ${view.panY}px) scale(${zoom})`,
transformOrigin: 'center center',
...(regionSelectMode ? { opacity: 0.85 } : {}),
}}
/>
)}
</motion.div>
</AnimatePresence>
{!regionSelectMode && (
<div className="pointer-events-none absolute top-6 right-6 opacity-75 transition-opacity group-hover:opacity-100">
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 p-1 shadow-2xl shadow-black/25 backdrop-blur">
<Tooltip
label={canStartSlideshow ? 'Start slideshow' : 'No images available for slideshow'}
followCursor
>
<button
aria-label="Start slideshow"
className={`rounded-full p-2 transition-colors ${
canStartSlideshow
? 'text-gray-300 hover:bg-white/10 hover:text-white'
: 'cursor-not-allowed text-gray-600'
}`}
disabled={!canStartSlideshow}
onClick={(event) => {
event.stopPropagation()
onStartSlideshow()
}}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M8 5.5v13l10-6.5-10-6.5z"
/>
</svg>
</button>
</Tooltip>
{selectedImage.media_kind === 'image' ? (
<>
<div className="mx-0.5 h-5 w-px bg-white/10" />
<button
aria-label="Zoom out"
className="rounded-full px-2 py-1 text-sm text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() =>
setView((currentView) =>
clampPan(
zoomViewAt(currentView, Math.max(MIN_ZOOM, currentView.zoom - 0.25), 0, 0)
)
)
}
>
-
</button>
<span className="min-w-14 text-center text-xs text-gray-300">
{Math.round(zoom * 100)}%
</span>
<button
aria-label="Zoom in"
className="rounded-full px-2 py-1 text-sm text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() =>
setView((currentView) =>
clampPan(
zoomViewAt(currentView, Math.min(MAX_ZOOM, currentView.zoom + 0.25), 0, 0)
)
)
}
>
+
</button>
</>
) : null}
</div>
</div>
)}
</div>
)
}
+181
View File
@@ -0,0 +1,181 @@
import { AnimatePresence, motion } from 'framer-motion'
import { ImageRecord } from '../../store'
import { mediaSrc } from '../../lib/mediaSrc'
import { Tooltip } from '../Tooltip'
import { ChevronRightIcon, CloseIcon } from '../icons'
import { SlideshowMotion } from './useSlideshow'
interface SlideshowViewProps {
selectedImage: ImageRecord
imageCount: number
position: number
controlsShown: boolean
paused: boolean
loadingMore: boolean
motionConfig: SlideshowMotion
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void
onShowControls: () => void
onExit: () => void
onGo: (direction: -1 | 1) => void
onTogglePaused: () => void
}
export function SlideshowView({
selectedImage,
imageCount,
position,
controlsShown,
paused,
loadingMore,
motionConfig,
onPointerMove,
onShowControls,
onExit,
onGo,
onTogglePaused,
}: SlideshowViewProps) {
return (
<div
className={`relative flex h-full w-full items-center justify-center overflow-hidden bg-black ${
controlsShown ? '' : 'cursor-none'
}`}
onClick={(event) => {
event.stopPropagation()
onShowControls()
}}
onPointerMove={onPointerMove}
>
<AnimatePresence initial={false}>
{selectedImage.media_kind === 'image' ? (
<motion.div
key={selectedImage.id}
className="absolute inset-0 flex items-center justify-center"
initial={motionConfig.imageInitial}
animate={motionConfig.imageAnimate}
exit={motionConfig.imageExit}
transition={motionConfig.imageTransition}
>
<motion.img
src={mediaSrc(selectedImage.path) ?? ''}
alt={selectedImage.filename}
className="max-h-full max-w-full object-contain"
draggable={false}
initial={motionConfig.contentInitial}
animate={motionConfig.contentAnimate}
transition={motionConfig.contentTransition}
/>
</motion.div>
) : (
<motion.div
key="slideshow-waiting"
className="text-sm text-gray-500"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
Finding next image
</motion.div>
)}
</AnimatePresence>
<motion.div
className="pointer-events-none absolute inset-x-0 top-0 flex items-start justify-between p-5"
initial={false}
animate={{ opacity: controlsShown ? 1 : 0, y: controlsShown ? 0 : -6 }}
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
>
<div className="max-w-[60vw] rounded-full border border-white/10 bg-black/45 px-3 py-1.5 text-xs whitespace-nowrap text-gray-300 shadow-2xl shadow-black/30 backdrop-blur-md">
<span className="font-medium text-white">{position}</span>
<span className="mx-1 text-gray-600">/</span>
<span>{imageCount}</span>
<span className="mx-2 text-gray-700"></span>
<span className="inline-block max-w-[42vw] truncate align-bottom text-gray-400">
{selectedImage.filename}
</span>
</div>
<Tooltip label="Exit slideshow" followCursor>
<button
aria-label="Exit slideshow"
className="pointer-events-auto rounded-full border border-white/10 bg-black/45 p-2 text-gray-300 shadow-2xl shadow-black/30 backdrop-blur-md transition-colors hover:bg-white/10 hover:text-white"
onClick={(event) => {
event.stopPropagation()
onExit()
}}
>
<CloseIcon className="h-4 w-4" strokeWidth={1.8} />
</button>
</Tooltip>
</motion.div>
<motion.div
className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-center p-6"
initial={false}
animate={{ opacity: controlsShown ? 1 : 0, y: controlsShown ? 0 : 8 }}
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
>
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/50 p-1.5 shadow-2xl shadow-black/30 backdrop-blur-md">
<Tooltip label="Previous image" followCursor>
<button
aria-label="Previous image"
className="rounded-full p-2 text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
disabled={imageCount <= 1}
onClick={(event) => {
event.stopPropagation()
onGo(-1)
}}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
</Tooltip>
<Tooltip label={paused ? 'Resume slideshow' : 'Pause slideshow'} followCursor>
<button
aria-label={paused ? 'Resume slideshow' : 'Pause slideshow'}
className="rounded-full border border-white/10 bg-white/10 p-2.5 text-white transition-colors hover:bg-white/15"
onClick={(event) => {
event.stopPropagation()
onTogglePaused()
onShowControls()
}}
>
{paused ? (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5.14v13.72a1 1 0 001.52.86l10.55-6.86a1 1 0 000-1.72L9.52 4.28A1 1 0 008 5.14z" />
</svg>
) : (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 5a1 1 0 00-1 1v12a1 1 0 001 1h2a1 1 0 001-1V6a1 1 0 00-1-1H7zM15 5a1 1 0 00-1 1v12a1 1 0 001 1h2a1 1 0 001-1V6a1 1 0 00-1-1h-2z" />
</svg>
)}
</button>
</Tooltip>
<Tooltip label="Next image" followCursor>
<button
aria-label="Next image"
className="rounded-full p-2 text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
disabled={imageCount <= 1}
onClick={(event) => {
event.stopPropagation()
onGo(1)
}}
>
<ChevronRightIcon className="h-5 w-5" strokeWidth={1.8} />
</button>
</Tooltip>
</div>
</motion.div>
{loadingMore ? (
<div className="pointer-events-none absolute right-6 bottom-6 rounded-full border border-white/10 bg-black/45 px-3 py-1.5 text-xs text-gray-500 backdrop-blur-md">
Loading more
</div>
) : null}
</div>
)
}
+62
View File
@@ -0,0 +1,62 @@
import { AiRating } from '../../store'
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function formatDate(iso: string | null): string {
if (!iso) return 'Unknown'
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export function formatDuration(durationMs: number | null): string {
if (!durationMs || durationMs <= 0) return 'Pending / unavailable'
const totalSeconds = Math.floor(durationMs / 1000)
const seconds = totalSeconds % 60
const minutes = Math.floor(totalSeconds / 60) % 60
const hours = Math.floor(totalSeconds / 3600)
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
export function embeddingLabel(status: string, model: string | null): string {
if (status === 'ready') {
return model ? `Ready (${model})` : 'Ready'
}
if (status === 'failed') {
return 'Failed'
}
if (status === 'processing') {
return 'Processing'
}
return 'Queued'
}
export function ratingPill(rating: AiRating): { label: string; className: string } {
switch (rating) {
case 'general':
return {
label: 'General',
className: 'border-emerald-400/25 bg-emerald-500/10 text-emerald-300',
}
case 'sensitive':
return { label: 'Sensitive', className: 'border-sky-400/25 bg-sky-500/10 text-sky-300' }
case 'questionable':
return {
label: 'Questionable',
className: 'border-amber-400/25 bg-amber-500/10 text-amber-300',
}
case 'explicit':
return { label: 'Explicit', className: 'border-red-400/25 bg-red-500/10 text-red-300' }
}
}
+26
View File
@@ -0,0 +1,26 @@
export interface DragRect {
startX: number
startY: number
endX: number
endY: number
}
export interface ViewTransform {
zoom: number
panX: number
panY: number
}
export interface NormalizedCrop {
x: number
y: number
w: number
h: number
}
export interface SelectionOverlay {
left: number
top: number
width: number
height: number
}
@@ -0,0 +1,96 @@
import { useEffect, useRef, useState } from 'react'
import { ImageExif, ImageRecord, ImageTag, TaggerModelStatus } from '../../store'
interface UseLightboxMediaDetailsParams {
selectedImage: ImageRecord | null
taggerModelStatus: TaggerModelStatus | null
getImageTags: (imageId: number) => Promise<ImageTag[]>
getImageExif: (imageId: number) => Promise<ImageExif>
loadTaggerModelStatus: () => Promise<void>
onSelectedImageReset: () => void
}
export function useLightboxMediaDetails({
selectedImage,
taggerModelStatus,
getImageTags,
getImageExif,
loadTaggerModelStatus,
onSelectedImageReset,
}: UseLightboxMediaDetailsParams) {
const [imageTags, setImageTags] = useState<ImageTag[]>([])
const [imageExif, setImageExif] = useState<ImageExif | null>(null)
const [tagInput, setTagInput] = useState('')
const [tagAdding, setTagAdding] = useState(false)
const [tagsExpanded, setTagsExpanded] = useState(false)
const [taggingQueued, setTaggingQueued] = useState(false)
const currentImageIdRef = useRef<number | null>(null)
currentImageIdRef.current = selectedImage?.id ?? null
useEffect(() => {
setImageTags([])
setImageExif(null)
setTagInput('')
setTagsExpanded(false)
setTaggingQueued(false)
onSelectedImageReset()
}, [onSelectedImageReset, selectedImage?.id])
useEffect(() => {
if (!selectedImage) return
let cancelled = false
void getImageTags(selectedImage.id)
.then((tags) => {
if (!cancelled) setImageTags(tags)
})
.catch(() => {
if (!cancelled) setImageTags([])
})
return () => {
cancelled = true
}
}, [getImageTags, selectedImage?.ai_tagged_at, selectedImage?.id])
useEffect(() => {
if (!selectedImage || selectedImage.media_kind !== 'image') {
setImageExif(null)
return
}
let cancelled = false
void getImageExif(selectedImage.id)
.then((exif) => {
if (!cancelled) setImageExif(exif)
})
.catch(() => {
if (!cancelled) setImageExif(null)
})
return () => {
cancelled = true
}
}, [getImageExif, selectedImage?.id, selectedImage?.media_kind])
useEffect(() => {
if (selectedImage?.media_kind !== 'image' || taggerModelStatus !== null) return
void loadTaggerModelStatus()
}, [loadTaggerModelStatus, selectedImage?.media_kind, taggerModelStatus])
useEffect(() => {
if (selectedImage?.ai_tagged_at) setTaggingQueued(false)
}, [selectedImage?.ai_tagged_at])
return {
currentImageIdRef,
imageTags,
setImageTags,
imageExif,
tagInput,
setTagInput,
tagAdding,
setTagAdding,
tagsExpanded,
setTagsExpanded,
taggingQueued,
setTaggingQueued,
}
}
@@ -0,0 +1,109 @@
import { useCallback, useEffect } from 'react'
import { ImageRecord } from '../../store'
import { ViewTransform } from './types'
import { zoomViewAt } from './viewTransform'
interface UseLightboxNavigationParams {
selectedImage: ImageRecord | null
images: ImageRecord[]
currentIndex: number
slideshowActive: boolean
regionSelectMode: boolean
closeImage: () => void
exitRegionMode: () => void
exitSlideshow: () => void
goSlideshow: (direction: -1 | 1) => void
showSlideshowControls: () => void
setSlideshowPaused: React.Dispatch<React.SetStateAction<boolean>>
openImage: (image: ImageRecord) => void
setView: React.Dispatch<React.SetStateAction<ViewTransform>>
clampPan: (view: ViewTransform) => ViewTransform
}
export function useLightboxNavigation({
selectedImage,
images,
currentIndex,
slideshowActive,
regionSelectMode,
closeImage,
exitRegionMode,
exitSlideshow,
goSlideshow,
showSlideshowControls,
setSlideshowPaused,
openImage,
setView,
clampPan,
}: UseLightboxNavigationParams) {
const goPrev = useCallback(() => {
if (currentIndex > 0) openImage(images[currentIndex - 1])
}, [currentIndex, images, openImage])
const goNext = useCallback(() => {
if (currentIndex >= 0 && currentIndex < images.length - 1) openImage(images[currentIndex + 1])
}, [currentIndex, images, openImage])
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (!selectedImage) return
if (slideshowActive) {
if (event.key === 'Escape') {
event.preventDefault()
exitSlideshow()
return
}
if (event.key === ' ') {
event.preventDefault()
setSlideshowPaused((paused) => !paused)
showSlideshowControls()
return
}
if (event.key === 'ArrowLeft' && !event.shiftKey) {
event.preventDefault()
goSlideshow(-1)
return
}
if (event.key === 'ArrowRight' && !event.shiftKey) {
event.preventDefault()
goSlideshow(1)
return
}
return
}
if (event.key === 'Escape') {
if (regionSelectMode) {
exitRegionMode()
} else {
closeImage()
}
}
if (regionSelectMode) return
if (event.key === 'ArrowLeft' && !event.shiftKey) goPrev()
if (event.key === 'ArrowRight' && !event.shiftKey) goNext()
if (event.key === '+' || event.key === '=')
setView((view) => clampPan(zoomViewAt(view, Math.min(3, view.zoom + 0.25), 0, 0)))
if (event.key === '-')
setView((view) => clampPan(zoomViewAt(view, Math.max(0.75, view.zoom - 0.25), 0, 0)))
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [
clampPan,
closeImage,
exitRegionMode,
exitSlideshow,
goNext,
goPrev,
goSlideshow,
regionSelectMode,
selectedImage,
setSlideshowPaused,
setView,
showSlideshowControls,
slideshowActive,
])
return { goPrev, goNext }
}
@@ -0,0 +1,192 @@
import {
useCallback,
useEffect,
useRef,
useState,
type Dispatch,
type RefObject,
type SetStateAction,
} from 'react'
import { ImageRecord } from '../../store'
import { DragRect, ViewTransform } from './types'
import {
clampPanToViewport,
MIN_SELECTION_FRACTION,
normaliseRect,
rectToNormalisedCrop,
zoomViewAt,
} from './viewTransform'
interface UseRegionSelectionParams {
selectedImage: ImageRecord | null
slideshowActive: boolean
imageViewportRef: RefObject<HTMLDivElement | null>
imgRef: RefObject<HTMLImageElement | null>
view: ViewTransform
setView: Dispatch<SetStateAction<ViewTransform>>
findSimilarByRegion: (
imageId: number,
crop: { x: number; y: number; w: number; h: number },
sourceFolderId: number | null
) => Promise<void>
}
export function useRegionSelection({
selectedImage,
slideshowActive,
imageViewportRef,
imgRef,
view,
setView,
findSimilarByRegion,
}: UseRegionSelectionParams) {
const [regionSelectMode, setRegionSelectMode] = useState(false)
const [isPanning, setIsPanning] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [dragRect, setDragRect] = useState<DragRect | null>(null)
const [regionSearching, setRegionSearching] = useState(false)
const lastPanPointRef = useRef({ x: 0, y: 0 })
const clampPan = useCallback(
(nextView: ViewTransform): ViewTransform =>
clampPanToViewport(nextView, imgRef.current, imageViewportRef.current),
[imageViewportRef, imgRef]
)
const exitRegionMode = useCallback(() => {
setRegionSelectMode(false)
setIsDragging(false)
setDragRect(null)
}, [])
useEffect(() => {
const viewport = imageViewportRef.current
if (!viewport || !selectedImage || selectedImage.media_kind !== 'image' || slideshowActive)
return
const handleWheel = (event: WheelEvent) => {
if (regionSelectMode) return
if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return
event.preventDefault()
const bounds = viewport.getBoundingClientRect()
const anchorX = event.clientX - (bounds.left + bounds.width / 2)
const anchorY = event.clientY - (bounds.top + bounds.height / 2)
setView((currentView) => {
const delta = event.deltaY < 0 ? 0.15 : -0.15
const next = Math.min(4, Math.max(0.5, currentView.zoom + delta))
return clampPan(zoomViewAt(currentView, next, anchorX, anchorY))
})
}
viewport.addEventListener('wheel', handleWheel, { passive: false })
return () => viewport.removeEventListener('wheel', handleWheel)
}, [clampPan, imageViewportRef, regionSelectMode, selectedImage, setView, slideshowActive])
const handlePointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!regionSelectMode) {
if (view.zoom > 1 && event.button === 0 && selectedImage?.media_kind === 'image') {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
lastPanPointRef.current = { x: event.clientX, y: event.clientY }
setIsPanning(true)
}
return
}
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
setIsDragging(true)
setDragRect({
startX: event.clientX,
startY: event.clientY,
endX: event.clientX,
endY: event.clientY,
})
},
[regionSelectMode, selectedImage?.media_kind, view.zoom]
)
const handlePointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (isPanning) {
const dx = event.clientX - lastPanPointRef.current.x
const dy = event.clientY - lastPanPointRef.current.y
lastPanPointRef.current = { x: event.clientX, y: event.clientY }
setView((currentView) =>
clampPan({ ...currentView, panX: currentView.panX + dx, panY: currentView.panY + dy })
)
return
}
if (!isDragging) return
setDragRect((prev) => (prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null))
},
[clampPan, isDragging, isPanning, setView]
)
const handlePointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (isPanning) {
event.currentTarget.releasePointerCapture(event.pointerId)
setIsPanning(false)
return
}
if (!isDragging || !dragRect || !selectedImage || !imgRef.current) {
setIsDragging(false)
return
}
event.currentTarget.releasePointerCapture(event.pointerId)
const finalRect: DragRect = { ...dragRect, endX: event.clientX, endY: event.clientY }
const crop = rectToNormalisedCrop(finalRect, imgRef.current)
setIsDragging(false)
setDragRect(null)
const containerBounds = imageViewportRef.current?.getBoundingClientRect()
const containerSize = containerBounds
? Math.min(containerBounds.width, containerBounds.height)
: 500
const selW = Math.abs(finalRect.endX - finalRect.startX)
const selH = Math.abs(finalRect.endY - finalRect.startY)
if (
!crop ||
selW < containerSize * MIN_SELECTION_FRACTION ||
selH < containerSize * MIN_SELECTION_FRACTION
) {
exitRegionMode()
return
}
exitRegionMode()
setRegionSearching(true)
void findSimilarByRegion(selectedImage.id, crop, selectedImage.folder_id).finally(() =>
setRegionSearching(false)
)
},
[
dragRect,
exitRegionMode,
findSimilarByRegion,
imageViewportRef,
imgRef,
isDragging,
isPanning,
selectedImage,
]
)
return {
regionSelectMode,
setRegionSelectMode,
isPanning,
regionSearching,
setRegionSearching,
exitRegionMode,
clampPan,
selectionOverlay: isDragging && dragRect ? normaliseRect(dragRect) : null,
handlePointerDown,
handlePointerMove,
handlePointerUp,
}
}
+295
View File
@@ -0,0 +1,295 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type Dispatch,
type RefObject,
type SetStateAction,
} from 'react'
import type { Transition } from 'framer-motion'
import { ImageRecord, SlideshowOrder, SlideshowTransition } from '../../store'
import { IDENTITY_VIEW } from './viewTransform'
import { ViewTransform } from './types'
const SLIDESHOW_EASE: [number, number, number, number] = [0.22, 1, 0.36, 1]
const SLIDESHOW_CONTROLS_IDLE_MS = 5000
const SLIDESHOW_LOAD_MORE_THRESHOLD = 3
export interface SlideshowMotion {
imageInitial: { opacity: number; filter: string }
imageAnimate: { opacity: number; filter: string }
imageExit: { opacity: number; filter: string }
imageTransition: Transition
contentInitial: { scale: number; x?: number; y?: number }
contentAnimate: { scale: number; x?: number; y?: number }
contentTransition: Transition
}
interface UseSlideshowParams {
rootRef: RefObject<HTMLDivElement | null>
selectedImage: ImageRecord | null
images: ImageRecord[]
currentIndex: number
loadedCount: number
totalImages: number
intervalSeconds: number
order: SlideshowOrder
transition: SlideshowTransition
openImage: (image: ImageRecord) => void
loadMoreImages: () => Promise<void>
exitRegionMode: () => void
setView: Dispatch<SetStateAction<ViewTransform>>
}
export function useSlideshow({
rootRef,
selectedImage,
images,
currentIndex,
loadedCount,
totalImages,
intervalSeconds,
order,
transition,
openImage,
loadMoreImages,
exitRegionMode,
setView,
}: UseSlideshowParams) {
const [active, setActive] = useState(false)
const [paused, setPaused] = useState(false)
const [controlsVisible, setControlsVisible] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [motionStep, setMotionStep] = useState(0)
const controlsIdleTimeoutRef = useRef<number | null>(null)
const pointerRef = useRef<{ x: number; y: number } | null>(null)
const randomSeenRef = useRef<Set<number>>(new Set())
const slideshowImages = useMemo(
() => images.filter((image) => image.media_kind === 'image'),
[images]
)
const slideshowIndex = selectedImage
? slideshowImages.findIndex((image) => image.id === selectedImage.id)
: -1
const position = slideshowIndex >= 0 ? slideshowIndex + 1 : Math.min(slideshowImages.length, 1)
const canStart = slideshowImages.length > 0
const directions = [
{ x: -1, y: 0.45 },
{ x: 1, y: -0.45 },
{ x: -0.7, y: -0.55 },
{ x: 0.7, y: 0.55 },
]
const direction = directions[motionStep % directions.length]
const motionConfig: SlideshowMotion = {
imageInitial: { opacity: 0, filter: 'blur(8px)' },
imageAnimate: { opacity: 1, filter: 'blur(0px)' },
imageExit: { opacity: 0, filter: 'blur(4px)' },
imageTransition: { duration: 0.72, ease: SLIDESHOW_EASE },
contentInitial:
transition === 'gentle-motion'
? { scale: 1.012, x: -8 * direction.x, y: 8 * direction.y }
: { scale: 1.012 },
contentAnimate:
transition === 'gentle-motion'
? { scale: 1.026, x: 8 * direction.x, y: -8 * direction.y }
: { scale: 1 },
contentTransition:
transition === 'gentle-motion'
? { duration: intervalSeconds + 0.75, ease: 'linear' }
: { duration: 0.72, ease: SLIDESHOW_EASE },
}
const showControls = useCallback(() => {
if (controlsIdleTimeoutRef.current !== null) {
window.clearTimeout(controlsIdleTimeoutRef.current)
controlsIdleTimeoutRef.current = null
}
setControlsVisible(true)
if (active) {
controlsIdleTimeoutRef.current = window.setTimeout(() => {
setControlsVisible(false)
controlsIdleTimeoutRef.current = null
}, SLIDESHOW_CONTROLS_IDLE_MS)
}
}, [active])
const exit = useCallback(() => {
setActive(false)
setPaused(false)
setControlsVisible(true)
if (document.fullscreenElement === rootRef.current) {
void document.exitFullscreen().catch(() => undefined)
}
}, [rootRef])
const go = useCallback(
(direction: -1 | 1, revealControls = true) => {
if (slideshowImages.length === 0) return
if (slideshowImages.length === 1) {
openImage(slideshowImages[0])
return
}
const currentSlideshowIndex = slideshowIndex >= 0 ? slideshowIndex : 0
let nextIndex =
(currentSlideshowIndex + direction + slideshowImages.length) % slideshowImages.length
if (order === 'random') {
const currentId = slideshowImages[currentSlideshowIndex]?.id
let candidates = slideshowImages.filter(
(image) => image.id !== currentId && !randomSeenRef.current.has(image.id)
)
if (candidates.length === 0) {
randomSeenRef.current = new Set(currentId == null ? [] : [currentId])
candidates = slideshowImages.filter((image) => image.id !== currentId)
}
const nextImage = candidates[Math.floor(Math.random() * candidates.length)]
nextIndex = slideshowImages.findIndex((image) => image.id === nextImage.id)
randomSeenRef.current.add(nextImage.id)
}
setMotionStep((step) => step + 1)
openImage(slideshowImages[nextIndex])
if (revealControls) {
showControls()
}
},
[openImage, order, showControls, slideshowImages, slideshowIndex]
)
const handlePointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const previous = pointerRef.current
const next = { x: event.clientX, y: event.clientY }
pointerRef.current = next
if (!previous) {
if (!controlsVisible) {
showControls()
}
return
}
if (Math.abs(previous.x - next.x) > 2 || Math.abs(previous.y - next.y) > 2) {
showControls()
}
},
[controlsVisible, showControls]
)
const start = useCallback(() => {
if (!selectedImage || slideshowImages.length === 0) return
const nextImage =
selectedImage.media_kind === 'image'
? selectedImage
: (images
.slice(Math.max(0, currentIndex + 1))
.find((image) => image.media_kind === 'image') ?? slideshowImages[0])
if (nextImage.id !== selectedImage.id) {
openImage(nextImage)
}
randomSeenRef.current = new Set([nextImage.id])
setMotionStep(0)
exitRegionMode()
setView(IDENTITY_VIEW)
setPaused(false)
setControlsVisible(true)
pointerRef.current = null
setActive(true)
void rootRef.current?.requestFullscreen().catch(() => undefined)
}, [
currentIndex,
exitRegionMode,
images,
openImage,
rootRef,
selectedImage,
setView,
slideshowImages,
])
useEffect(() => {
if (selectedImage) return
setActive(false)
setPaused(false)
setControlsVisible(true)
}, [selectedImage])
useEffect(() => {
const handleFullscreenChange = () => {
if (!active) return
if (document.fullscreenElement !== rootRef.current) {
setActive(false)
setPaused(false)
setControlsVisible(true)
}
}
document.addEventListener('fullscreenchange', handleFullscreenChange)
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
}, [active, rootRef])
useEffect(() => {
showControls()
return () => {
if (controlsIdleTimeoutRef.current !== null) {
window.clearTimeout(controlsIdleTimeoutRef.current)
controlsIdleTimeoutRef.current = null
}
}
}, [active, paused, showControls])
useEffect(() => {
if (!active || paused || slideshowImages.length <= 1) return
const timeout = window.setTimeout(() => {
go(1, false)
}, intervalSeconds * 1000)
return () => window.clearTimeout(timeout)
}, [active, go, intervalSeconds, paused, selectedImage?.id, slideshowImages.length])
useEffect(() => {
if (
!active ||
loadingMore ||
loadedCount >= totalImages ||
slideshowImages.length === 0 ||
slideshowIndex < slideshowImages.length - SLIDESHOW_LOAD_MORE_THRESHOLD
) {
return
}
setLoadingMore(true)
void loadMoreImages().finally(() => setLoadingMore(false))
}, [
active,
loadedCount,
loadMoreImages,
loadingMore,
slideshowImages.length,
slideshowIndex,
totalImages,
])
return {
active,
paused,
setPaused,
controlsShown: controlsVisible,
loadingMore,
images: slideshowImages,
position,
canStart,
motionConfig,
start,
exit,
go,
showControls,
handlePointerMove,
}
}
+75
View File
@@ -0,0 +1,75 @@
import { DragRect, NormalizedCrop, SelectionOverlay, ViewTransform } from './types'
export const MIN_SELECTION_FRACTION = 0.02
export const MIN_ZOOM = 0.5
export const MAX_ZOOM = 4
export const IDENTITY_VIEW: ViewTransform = { zoom: 1, panX: 0, panY: 0 }
export function normaliseRect(r: DragRect): SelectionOverlay {
return {
left: Math.min(r.startX, r.endX),
top: Math.min(r.startY, r.endY),
width: Math.abs(r.endX - r.startX),
height: Math.abs(r.endY - r.startY),
}
}
export function rectToNormalisedCrop(
rect: DragRect,
imgEl: HTMLImageElement
): NormalizedCrop | null {
const imgBounds = imgEl.getBoundingClientRect()
if (imgBounds.width === 0 || imgBounds.height === 0) return null
const rawX = Math.min(rect.startX, rect.endX)
const rawY = Math.min(rect.startY, rect.endY)
const rawW = Math.abs(rect.endX - rect.startX)
const rawH = Math.abs(rect.endY - rect.startY)
const clampedX = Math.max(rawX, imgBounds.left)
const clampedY = Math.max(rawY, imgBounds.top)
const clampedRight = Math.min(rawX + rawW, imgBounds.right)
const clampedBottom = Math.min(rawY + rawH, imgBounds.bottom)
const croppedW = clampedRight - clampedX
const croppedH = clampedBottom - clampedY
if (croppedW <= 0 || croppedH <= 0) return null
return {
x: (clampedX - imgBounds.left) / imgBounds.width,
y: (clampedY - imgBounds.top) / imgBounds.height,
w: croppedW / imgBounds.width,
h: croppedH / imgBounds.height,
}
}
export function zoomViewAt(
view: ViewTransform,
newZoom: number,
anchorX: number,
anchorY: number
): ViewTransform {
if (newZoom <= 1) return { ...IDENTITY_VIEW, zoom: newZoom }
const ratio = newZoom / view.zoom
return {
zoom: newZoom,
panX: anchorX - (anchorX - view.panX) * ratio,
panY: anchorY - (anchorY - view.panY) * ratio,
}
}
export function clampPanToViewport(
view: ViewTransform,
img: HTMLImageElement | null,
viewport: HTMLDivElement | null
): ViewTransform {
if (!img || !viewport) return view
const maxX = Math.max(0, (img.offsetWidth * view.zoom - viewport.clientWidth) / 2)
const maxY = Math.max(0, (img.offsetHeight * view.zoom - viewport.clientHeight) / 2)
return {
...view,
panX: Math.min(maxX, Math.max(-maxX, view.panX)),
panY: Math.min(maxY, Math.max(-maxY, view.panY)),
}
}
+67
View File
@@ -0,0 +1,67 @@
import { ReactNode, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { MenuCloseContext, MenuPanel, MenuSize } from './Menu'
import { useDismissable } from './useDismissable'
/**
* Positioned floating menu. Renders through a portal so it is never caught
* inside transformed ancestors (framer-motion drag/layout items), and clamps
* itself to the viewport after measuring its real size.
*
* `align="start"` puts the panel's top-left at (x, y) — right-click menus.
* `align="end"` puts the top-right at (x, y) — menus anchored under a
* right-edge button.
*/
export function ContextMenu({
x,
y,
onClose,
size = 'md',
align = 'start',
className,
children,
}: {
x: number
y: number
onClose: () => void
size?: MenuSize
align?: 'start' | 'end'
className?: string
children: ReactNode
}) {
const ref = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState<{ left: number; top: number } | null>(null)
useDismissable(ref, onClose)
useLayoutEffect(() => {
const el = ref.current
if (!el) return
const margin = 8
const rect = el.getBoundingClientRect()
const desiredLeft = align === 'end' ? x - rect.width : x
setPos({
left: Math.max(margin, Math.min(desiredLeft, window.innerWidth - rect.width - margin)),
top: Math.max(margin, Math.min(y, window.innerHeight - rect.height - margin)),
})
}, [x, y, align])
return createPortal(
<div
ref={ref}
className="fixed z-50"
// Render hidden at the raw coordinates for the measuring pass; the
// layout effect swaps in the clamped position before paint.
style={pos ?? { left: x, top: y, visibility: 'hidden' }}
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()}
>
<MenuCloseContext.Provider value={onClose}>
<MenuPanel size={size} className={className}>
{children}
</MenuPanel>
</MenuCloseContext.Provider>
</div>,
document.body
)
}
+132
View File
@@ -0,0 +1,132 @@
import { ReactNode, useRef, useState } from 'react'
import { MenuCloseContext, MenuItem, MenuPanel, MenuSize } from './Menu'
import { useDismissable } from './useDismissable'
import { Tooltip } from '../Tooltip'
import { ChevronDownIcon } from '../icons'
export interface DropdownOption<T> {
value: T
label: string
/** Trailing detail on the option row (e.g. an item count). */
hint?: ReactNode
}
const TRIGGER_STYLES = {
// Filled, bordered select — settings forms and panel headers.
solid: {
base: 'min-w-40 gap-2 rounded-md border px-3 py-1.5 text-xs border-white/10 bg-white/[0.055] 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',
closed:
'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',
},
// Transparent until engaged — toolbar controls (sort, folder scope).
ghost: {
base: 'gap-1.5 rounded-lg border px-3 py-1.5 text-xs',
open: 'border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white',
closed:
'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',
},
// Tiny inline picker — sidebar section headers.
compact: {
base: 'gap-2 rounded-md border px-1.5 py-0.5 text-[10px] font-medium border-white/10 bg-white/[0.04] 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',
closed:
'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',
},
} as const
/**
* The app-wide select control: a trigger button plus a MenuPanel of options.
* Replaces the former ThemedDropdown / SortDropdown / FolderScopeDropdown
* trio. Options are compared to `value` with Object.is, so number and
* null values (folder scopes) work as well as string unions.
*/
export function Dropdown<T extends string | number | null>({
value,
options,
onChange,
ariaLabel,
align = 'right',
trigger = 'solid',
size = 'sm',
triggerIcon,
triggerTooltip,
triggerClassName = '',
panelClassName = '',
}: {
value: T
options: DropdownOption<T>[]
onChange: (value: T) => void
ariaLabel: string
align?: 'left' | 'right'
trigger?: keyof typeof TRIGGER_STYLES
size?: MenuSize
triggerIcon?: ReactNode
triggerTooltip?: string
triggerClassName?: string
panelClassName?: string
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const current = options.find((option) => Object.is(option.value, value)) ?? options[0]
const style = TRIGGER_STYLES[trigger]
useDismissable(ref, () => setOpen(false), open)
const button = (
<button
type="button"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((currentOpen) => !currentOpen)}
className={`dropdown-trigger flex items-center justify-between transition-colors ${style.base} ${
open ? style.open : style.closed
} ${triggerClassName}`}
>
{triggerIcon}
<span className="min-w-0 truncate">{current?.label}</span>
<ChevronDownIcon
className={`h-3 w-3 shrink-0 text-gray-500 transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
/>
</button>
)
return (
<div ref={ref} className="relative">
{triggerTooltip ? (
<Tooltip label={triggerTooltip} anchorToCursor>
{button}
</Tooltip>
) : (
button
)}
{open ? (
<div
role="listbox"
aria-label={ariaLabel}
className={`absolute top-full z-50 mt-1.5 min-w-full ${align === 'right' ? 'right-0' : 'left-0'}`}
>
<MenuCloseContext.Provider value={() => setOpen(false)}>
<MenuPanel size={size} className={panelClassName}>
{options.map((option) => {
const selected = Object.is(option.value, value)
return (
<MenuItem
key={String(option.value)}
role="option"
label={option.label}
hint={option.hint}
active={selected}
checked={selected}
onSelect={() => onChange(option.value)}
/>
)
})}
</MenuPanel>
</MenuCloseContext.Provider>
</div>
) : null}
</div>
)
}
+231
View File
@@ -0,0 +1,231 @@
import {
ReactNode,
createContext,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react'
import { CheckIcon, ChevronRightIcon } from '../icons'
export type MenuSize = 'sm' | 'md'
/** Provided by ContextMenu so any nested MenuItem (submenus included) can close the whole menu. */
export const MenuCloseContext = createContext<() => void>(() => {})
const MenuSizeContext = createContext<MenuSize>('md')
function itemClass(size: MenuSize, danger: boolean, disabled: boolean, active = false): string {
const base =
size === 'sm'
? 'w-full rounded-md px-3 py-1.5 text-[12px]'
: 'w-full rounded-lg px-3 py-2 text-sm'
const tone = disabled
? 'text-gray-600 cursor-not-allowed'
: danger
? 'text-red-400 hover:bg-red-500/15 hover:text-red-300'
: active
? 'bg-white/[0.08] text-white'
: 'text-gray-200 hover:bg-white/[0.06] hover:text-white'
// menu-item + data attributes are the stable hooks the subtle-light theme
// targets in index.css — keep them if the utility classes change.
return `menu-item ${base} ${tone} transition-colors`
}
function itemTone(danger: boolean, disabled: boolean): 'danger' | 'disabled' | 'default' {
return danger ? 'danger' : disabled ? 'disabled' : 'default'
}
/**
* The panel chrome shared by every menu surface. Size defaults to the
* enclosing menu's size so submenu panels match their parent automatically.
*/
export function MenuPanel({
size,
className = '',
children,
}: {
size?: MenuSize
className?: string
children: ReactNode
}) {
const inherited = useContext(MenuSizeContext)
const resolved = size ?? inherited
// Default min-width steps aside when the caller sets its own width class —
// Tailwind resolves competing min-w utilities by stylesheet order, not
// className order, so merging both would be unpredictable.
const widthClass = /(^|\s)(min-w-|w-)/.test(className)
? ''
: resolved === 'sm'
? 'min-w-40'
: 'min-w-52'
return (
<MenuSizeContext.Provider value={resolved}>
<div
className={`menu-panel ${widthClass} light-theme:border-gray-700/50 rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl shadow-black/40 backdrop-blur ${className}`}
>
{children}
</div>
</MenuSizeContext.Provider>
)
}
export function MenuItem({
label,
onSelect,
danger = false,
disabled = false,
active = false,
checked,
hint,
keepOpen = false,
role,
}: {
label: ReactNode
onSelect?: () => void
danger?: boolean
disabled?: boolean
/** Highlights the row as the current choice (dropdown selections, active view). */
active?: boolean
/** Renders a trailing check mark; use for exclusive-choice menus (themes, sort orders). */
checked?: boolean
hint?: ReactNode
/** Skip the automatic menu close after selecting. */
keepOpen?: boolean
role?: React.AriaRole
}) {
const size = useContext(MenuSizeContext)
const close = useContext(MenuCloseContext)
return (
<button
type="button"
role={role}
aria-selected={role === 'option' ? (checked ?? active) : undefined}
disabled={disabled}
data-tone={itemTone(danger, disabled)}
data-active={active || undefined}
className={`${itemClass(size, danger, disabled, active)} flex items-center justify-between gap-3`}
onClick={() => {
if (disabled) return
onSelect?.()
if (!keepOpen) close()
}}
>
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
{hint != null ? (
<span className={`shrink-0 ${size === 'sm' ? 'text-[10px]' : 'text-xs'} text-gray-500`}>
{hint}
</span>
) : null}
{checked ? <CheckIcon className="h-3.5 w-3.5 shrink-0 text-blue-400" /> : null}
</button>
)
}
export function MenuSeparator() {
return <div className="my-1 h-px bg-white/[0.06]" />
}
export function MenuLabel({ children }: { children: ReactNode }) {
return (
<div
className="px-3 py-1 text-[10px] tracking-[0.18em] text-gray-600 uppercase"
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
}}
>
{children}
</div>
)
}
/**
* A nested menu panel that opens beside its row on hover (or click, for
* touch). Flips to the left edge and shifts up when it would leave the
* viewport. Items inside close the entire menu tree via MenuCloseContext.
*/
export function SubMenu({
label,
disabled = false,
children,
panelClassName = '',
}: {
label: ReactNode
disabled?: boolean
children: ReactNode
panelClassName?: string
}) {
const size = useContext(MenuSizeContext)
const [open, setOpen] = useState(false)
const [placement, setPlacement] = useState({ flipX: false, shiftY: 0 })
const panelRef = useRef<HTMLDivElement>(null)
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(
() => () => {
if (closeTimer.current) clearTimeout(closeTimer.current)
},
[]
)
const openNow = () => {
if (disabled) return
if (closeTimer.current) {
clearTimeout(closeTimer.current)
closeTimer.current = null
}
setOpen(true)
}
// Grace delay so the pointer can cross the small gap to the panel.
const closeSoon = () => {
if (closeTimer.current) clearTimeout(closeTimer.current)
closeTimer.current = setTimeout(() => setOpen(false), 140)
}
useLayoutEffect(() => {
if (!open) {
setPlacement({ flipX: false, shiftY: 0 })
return
}
const panel = panelRef.current
if (!panel) return
const margin = 8
const rect = panel.getBoundingClientRect()
const overflowY = rect.bottom - (window.innerHeight - margin)
setPlacement({
flipX: rect.right > window.innerWidth - margin,
shiftY: overflowY > 0 ? -overflowY : 0,
})
}, [open])
return (
<div className="relative" onPointerEnter={openNow} onPointerLeave={closeSoon}>
<button
type="button"
disabled={disabled}
aria-haspopup="menu"
aria-expanded={open}
data-tone={itemTone(false, disabled)}
className={`${itemClass(size, false, disabled)} flex items-center justify-between gap-3`}
// Open-only (no toggle): hover already opened it for mouse users, so a
// toggle would close the panel on the very click meant to open it.
onClick={openNow}
>
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
<ChevronRightIcon className="h-3 w-3 shrink-0 text-gray-500" />
</button>
{open ? (
<div
ref={panelRef}
className={`absolute top-0 z-10 -mt-1 ${placement.flipX ? 'right-full mr-0.5' : 'left-full ml-0.5'}`}
style={
placement.shiftY !== 0 ? { transform: `translateY(${placement.shiftY}px)` } : undefined
}
>
<MenuPanel className={panelClassName}>{children}</MenuPanel>
</div>
) : null}
</div>
)
}
+6
View File
@@ -0,0 +1,6 @@
export { ContextMenu } from './ContextMenu'
export { Dropdown } from './Dropdown'
export type { DropdownOption } from './Dropdown'
export { MenuCloseContext, MenuItem, MenuLabel, MenuPanel, MenuSeparator, SubMenu } from './Menu'
export type { MenuSize } from './Menu'
export { useDismissable } from './useDismissable'
+28
View File
@@ -0,0 +1,28 @@
import { RefObject, useEffect } from 'react'
/**
* Closes a floating element on pointer-down outside `ref` or on Escape.
* The single shared dismissal behavior for menus, dropdowns, and popovers.
*/
export function useDismissable<T extends HTMLElement>(
ref: RefObject<T | null>,
onClose: () => void,
enabled = true
) {
useEffect(() => {
if (!enabled) return
const handlePointerDown = (event: PointerEvent) => {
const el = ref.current
if (el && !el.contains(event.target as Node)) onClose()
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('pointerdown', handlePointerDown)
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('pointerdown', handlePointerDown)
window.removeEventListener('keydown', handleKeyDown)
}
}, [ref, onClose, enabled])
}
+49 -47
View File
@@ -1,54 +1,54 @@
import { useEffect } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../../store";
import { StepWelcome } from "./StepWelcome";
import { StepAddLibrary } from "./StepAddLibrary";
import { StepPipeline } from "./StepPipeline";
import { StepGalleryPreview } from "./StepGalleryPreview";
import { StepSearchDemo } from "./StepSearchDemo";
import { StepViews } from "./StepViews";
import { StepAiFeatures } from "./StepAiFeatures";
import { StepUpdates } from "./StepUpdates";
import { useEffect } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { useGalleryStore } from '../../store'
import { StepWelcome } from './StepWelcome'
import { StepAddLibrary } from './StepAddLibrary'
import { StepPipeline } from './StepPipeline'
import { StepGalleryPreview } from './StepGalleryPreview'
import { StepSearchDemo } from './StepSearchDemo'
import { StepViews } from './StepViews'
import { StepAiFeatures } from './StepAiFeatures'
import { StepUpdates } from './StepUpdates'
const STEPS: { id: string; title: string; component: () => React.ReactNode }[] = [
{ id: "welcome", title: "Welcome", component: () => <StepWelcome /> },
{ id: "library", title: "Your library", component: () => <StepAddLibrary /> },
{ id: "pipeline", title: "Background work", component: () => <StepPipeline /> },
{ id: "gallery", title: "The gallery", component: () => <StepGalleryPreview /> },
{ id: "search", title: "Search", component: () => <StepSearchDemo /> },
{ id: "views", title: "Views", component: () => <StepViews /> },
{ id: "ai", title: "AI features", component: () => <StepAiFeatures /> },
{ id: "updates", title: "Staying current", component: () => <StepUpdates /> },
];
{ id: 'welcome', title: 'Welcome', component: () => <StepWelcome /> },
{ id: 'library', title: 'Your library', component: () => <StepAddLibrary /> },
{ id: 'pipeline', title: 'Background work', component: () => <StepPipeline /> },
{ id: 'gallery', title: 'The gallery', component: () => <StepGalleryPreview /> },
{ id: 'search', title: 'Search', component: () => <StepSearchDemo /> },
{ id: 'views', title: 'Views', component: () => <StepViews /> },
{ id: 'ai', title: 'AI features', component: () => <StepAiFeatures /> },
{ id: 'updates', title: 'Staying current', component: () => <StepUpdates /> },
]
export function OnboardingOverlay() {
const onboardingOpen = useGalleryStore((s) => s.onboardingOpen);
const onboardingStep = useGalleryStore((s) => s.onboardingStep);
const setOnboardingStep = useGalleryStore((s) => s.setOnboardingStep);
const completeOnboarding = useGalleryStore((s) => s.completeOnboarding);
const onboardingOpen = useGalleryStore((s) => s.onboardingOpen)
const onboardingStep = useGalleryStore((s) => s.onboardingStep)
const setOnboardingStep = useGalleryStore((s) => s.setOnboardingStep)
const completeOnboarding = useGalleryStore((s) => s.completeOnboarding)
useEffect(() => {
if (!onboardingOpen) return;
if (!onboardingOpen) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") completeOnboarding();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onboardingOpen, completeOnboarding]);
if (event.key === 'Escape') completeOnboarding()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onboardingOpen, completeOnboarding])
if (!onboardingOpen) return null;
if (!onboardingOpen) return null
const step = STEPS[Math.min(onboardingStep, STEPS.length - 1)];
const isFirst = onboardingStep === 0;
const isLast = onboardingStep >= STEPS.length - 1;
const step = STEPS[Math.min(onboardingStep, STEPS.length - 1)]
const isFirst = onboardingStep === 0
const isLast = onboardingStep >= STEPS.length - 1
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/65 backdrop-blur-sm light-theme:bg-black/40">
<div className="flex max-h-[min(85vh,760px)] w-[min(90vw,720px)] flex-col rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60 light-theme:border-gray-300/70">
<div className="light-theme:bg-black/40 fixed inset-0 z-[70] flex items-center justify-center bg-black/65 backdrop-blur-sm">
<div className="light-theme:border-gray-300/70 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">
{/* Header */}
<div className="flex items-center justify-between border-b border-white/[0.07] px-7 py-4 light-theme:border-gray-300/70">
<div className="light-theme:border-gray-300/70 flex items-center justify-between border-b border-white/[0.07] px-7 py-4">
<div>
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">
<p className="text-[11px] tracking-[0.14em] text-gray-600 uppercase">
Step {onboardingStep + 1} of {STEPS.length}
</p>
<h2 className="mt-0.5 text-base font-semibold text-white">{step.title}</h2>
@@ -60,10 +60,10 @@ export function OnboardingOverlay() {
aria-label={s.title}
className={`h-1.5 rounded-full transition-all ${
i === onboardingStep
? "w-5 bg-white/70 light-theme:bg-gray-700"
? 'light-theme:bg-gray-700 w-5 bg-white/70'
: i < onboardingStep
? "w-1.5 bg-white/35 light-theme:bg-gray-400"
: "w-1.5 bg-white/15 light-theme:bg-gray-300"
? 'light-theme:bg-gray-400 w-1.5 bg-white/35'
: 'light-theme:bg-gray-300 w-1.5 bg-white/15'
}`}
onClick={() => setOnboardingStep(i)}
/>
@@ -87,16 +87,16 @@ export function OnboardingOverlay() {
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-white/[0.07] px-7 py-4 light-theme:border-gray-300/70">
<div className="light-theme:border-gray-300/70 flex items-center justify-between border-t border-white/[0.07] px-7 py-4">
<button
className="rounded-md border border-transparent px-3 py-1.5 text-xs text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-gray-200 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
className="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 rounded-md border border-transparent px-3 py-1.5 text-xs text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-gray-200"
onClick={completeOnboarding}
>
Skip tour
</button>
<div className="flex items-center gap-2">
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
className="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 rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => setOnboardingStep(onboardingStep - 1)}
disabled={isFirst}
>
@@ -104,13 +104,15 @@ export function OnboardingOverlay() {
</button>
<button
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-4 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25"
onClick={() => (isLast ? completeOnboarding() : setOnboardingStep(onboardingStep + 1))}
onClick={() =>
isLast ? completeOnboarding() : setOnboardingStep(onboardingStep + 1)
}
>
{isLast ? "Finish" : "Next"}
{isLast ? 'Finish' : 'Next'}
</button>
</div>
</div>
</div>
</div>
);
)
}
+16 -13
View File
@@ -1,13 +1,13 @@
import { useGalleryStore } from "../../store";
import { FakeTile } from "./fakes";
import { useGalleryStore } from '../../store'
import { FakeTile } from './fakes'
export function StepAddLibrary() {
const folders = useGalleryStore((s) => s.folders);
const setFolderPickerOpen = useGalleryStore((s) => s.setFolderPickerOpen);
const folders = useGalleryStore((s) => s.folders)
const setFolderPickerOpen = useGalleryStore((s) => s.setFolderPickerOpen)
const handlePick = () => {
setFolderPickerOpen(true);
};
setFolderPickerOpen(true)
}
return (
<div>
@@ -16,15 +16,18 @@ 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 light-theme:border-gray-300/70">
<div className="light-theme:border-gray-300/70 mt-5 flex items-center justify-between gap-6 border-y border-white/[0.05] py-4">
<div className="min-w-0">
{folders.length > 0 ? (
<>
<p className="text-sm text-white">
{folders.length === 1 ? `${folders[0].name}” added` : `${folders.length} folders in your library`}
{folders.length === 1
? `${folders[0].name}” added`
: `${folders.length} folders in your library`}
</p>
<p className="mt-1 text-xs text-gray-500">
Indexing runs in the background you can add more folders from the sidebar any time.
Indexing runs in the background you can add more folders from the sidebar any
time.
</p>
</>
) : (
@@ -40,13 +43,13 @@ export function StepAddLibrary() {
className="shrink-0 rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 disabled:cursor-not-allowed disabled:opacity-45"
onClick={handlePick}
>
{folders.length > 0 ? "Add another folder" : "Choose a folder"}
{folders.length > 0 ? 'Add another folder' : 'Choose a folder'}
</button>
</div>
<p className="mt-5 text-xs leading-relaxed text-gray-500">
As indexing runs, the gallery fills in roughly like this tiles appear immediately and sharpen
as thumbnails are generated:
As indexing runs, the gallery fills in roughly like this tiles appear immediately and
sharpen as thumbnails are generated:
</p>
<div className="media-dark-surface mt-3 grid grid-cols-6 gap-1.5">
<FakeTile index={0} />
@@ -57,5 +60,5 @@ export function StepAddLibrary() {
<FakeTile index={5} loaded={false} />
</div>
</div>
);
)
}
+88 -58
View File
@@ -1,62 +1,75 @@
import { useEffect } from "react";
import { TaggerModel, TaggerModelProgress, useGalleryStore } from "../../store";
import { TAGGER_MODELS } from "../../taggerModels";
import { FakeProgressBar, formatBytes } from "./fakes";
import { useEffect } from 'react'
import { TaggerModel, TaggerModelProgress, useGalleryStore } from '../../store'
import { TAGGER_MODELS } from '../../taggerModels'
import { FakeProgressBar, formatBytes } from './fakes'
const FAKE_TAGS = ["landscape", "sunset", "outdoors", "no_humans", "ocean", "cloudy_sky"];
const FAKE_TAGS = ['landscape', 'sunset', 'outdoors', 'no_humans', 'ocean', 'cloudy_sky']
// Prefer the current file's byte fraction (the 1.3 GB model dominates); fall
// back to the coarse step count; null renders an indeterminate bar.
function taggerDownloadFraction(progress: TaggerModelProgress | null): number | null {
if (!progress) return null;
if (progress.downloaded_bytes != null && progress.total_bytes != null && progress.total_bytes > 0) {
return progress.downloaded_bytes / progress.total_bytes;
if (!progress) return null
if (
progress.downloaded_bytes != null &&
progress.total_bytes != null &&
progress.total_bytes > 0
) {
return progress.downloaded_bytes / progress.total_bytes
}
if (progress.total_files > 0) return progress.completed_files / progress.total_files;
return null;
if (progress.total_files > 0) return progress.completed_files / progress.total_files
return null
}
function TaggerModelChoice({ model, current, disabled, onSelect }: {
model: TaggerModel;
current: TaggerModel;
disabled: boolean;
onSelect: (model: TaggerModel) => void;
function TaggerModelChoice({
model,
current,
disabled,
onSelect,
}: {
model: TaggerModel
current: TaggerModel
disabled: boolean
onSelect: (model: TaggerModel) => void
}) {
const active = model === current;
const active = model === current
return (
<button
type="button"
className={`rounded-md border px-3 py-1.5 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-45 ${
active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700"
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
? 'light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700 border-emerald-400/35 bg-emerald-500/15 text-emerald-200'
: 'light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200'
}`}
disabled={disabled}
onClick={() => onSelect(model)}
>
{TAGGER_MODELS[model].tab}
</button>
);
)
}
export function StepAiFeatures() {
const taggerModelStatus = useGalleryStore((s) => s.taggerModelStatus);
const taggerModelPreparing = useGalleryStore((s) => s.taggerModelPreparing);
const taggerModelProgress = useGalleryStore((s) => s.taggerModelProgress);
const taggerModelError = useGalleryStore((s) => s.taggerModelError);
const taggerModel = useGalleryStore((s) => s.taggerModel);
const prepareTaggerModel = useGalleryStore((s) => s.prepareTaggerModel);
const loadTaggerModel = useGalleryStore((s) => s.loadTaggerModel);
const loadTaggerModelStatus = useGalleryStore((s) => s.loadTaggerModelStatus);
const setTaggerModel = useGalleryStore((s) => s.setTaggerModel);
const taggerModelStatus = useGalleryStore((s) => s.taggerModelStatus)
const taggerModelPreparing = useGalleryStore((s) => s.taggerModelPreparing)
const taggerModelProgress = useGalleryStore((s) => s.taggerModelProgress)
const taggerModelError = useGalleryStore((s) => s.taggerModelError)
const taggerModel = useGalleryStore((s) => s.taggerModel)
const prepareTaggerModel = useGalleryStore((s) => s.prepareTaggerModel)
const loadTaggerModel = useGalleryStore((s) => s.loadTaggerModel)
const loadTaggerModelStatus = useGalleryStore((s) => s.loadTaggerModelStatus)
const setTaggerModel = useGalleryStore((s) => s.setTaggerModel)
useEffect(() => {
void loadTaggerModel();
void loadTaggerModelStatus();
}, [loadTaggerModel, loadTaggerModelStatus]);
void loadTaggerModel()
void loadTaggerModelStatus()
}, [loadTaggerModel, loadTaggerModelStatus])
const taggerReady = taggerModelStatus?.ready ?? false;
const taggerStatusLabel = taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed";
const taggerReady = taggerModelStatus?.ready ?? false
const taggerStatusLabel = taggerReady
? 'Installed'
: taggerModelPreparing
? 'Preparing'
: 'Not installed'
return (
<div>
@@ -65,26 +78,31 @@ export function StepAiFeatures() {
itself up automatically; AI tagging is optional and only downloads if you want it.
</p>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">AI tagging — optional</h4>
<div className="mt-1 divide-y divide-white/[0.05] light-theme:divide-gray-300/70">
<h4 className="mt-6 text-[12px] font-semibold tracking-[0.08em] text-gray-400 uppercase">
AI tagging — optional
</h4>
<div className="light-theme:divide-gray-300/70 mt-1 divide-y divide-white/[0.05]">
<div className="py-4">
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<p className="text-sm text-white">Automatic tags for every image</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">
The AI tagger model labels images so you can search with{" "}
<code className="rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200 light-theme:bg-gray-800 light-theme:text-gray-100">/t</code> — tags look like:
The AI tagger model labels images so you can search with{' '}
<code className="light-theme:bg-gray-800 light-theme:text-gray-100 rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200">
/t
</code>{' '}
— tags look like:
</p>
<div className="mt-3 inline-flex max-w-full flex-wrap items-center gap-1 rounded-lg border border-white/[0.07] p-0.5 light-theme:border-gray-300/80">
{(["wd", "joytag"] as const).map((model) => (
<div className="light-theme:border-gray-300/80 mt-3 inline-flex max-w-full flex-wrap items-center gap-1 rounded-lg border border-white/[0.07] p-0.5">
{(['wd', 'joytag'] as const).map((model) => (
<TaggerModelChoice
key={model}
model={model}
current={taggerModel}
disabled={taggerModelPreparing}
onSelect={(nextModel) => {
if (nextModel === taggerModel) return;
void setTaggerModel(nextModel);
if (nextModel === taggerModel) return
void setTaggerModel(nextModel)
}}
/>
))}
@@ -97,7 +115,10 @@ export function StepAiFeatures() {
</p>
<span className="mt-2 flex flex-wrap gap-1.5">
{FAKE_TAGS.map((tag) => (
<span key={tag} className="rounded-md border border-white/10 bg-gray-900/50 px-2 py-0.5 text-[11px] text-gray-400 light-theme:border-gray-300/70 light-theme:bg-gray-900 light-theme:text-gray-600">
<span
key={tag}
className="light-theme:border-gray-300/70 light-theme:bg-gray-900 light-theme:text-gray-600 rounded-md border border-white/10 bg-gray-900/50 px-2 py-0.5 text-[11px] text-gray-400"
>
{tag}
</span>
))}
@@ -105,16 +126,16 @@ 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 light-theme:border-emerald-600/40 light-theme:bg-emerald-100 light-theme:text-emerald-700">
<span className="light-theme:border-emerald-600/40 light-theme:bg-emerald-100 light-theme:text-emerald-700 inline-flex rounded-md border border-emerald-400/25 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-300">
Installed
</span>
) : (
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
className="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 rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => void prepareTaggerModel()}
disabled={taggerModelPreparing}
>
{taggerModelPreparing ? "Downloading..." : "Download now"}
{taggerModelPreparing ? 'Downloading...' : 'Download now'}
</button>
)}
</div>
@@ -123,40 +144,49 @@ export function StepAiFeatures() {
<div className="mt-3">
<FakeProgressBar fraction={taggerDownloadFraction(taggerModelProgress)} />
<div className="mt-1.5 flex items-center justify-between gap-3 text-[11px] text-gray-600">
<span className="truncate">{taggerModelProgress?.current_file ?? "Preparing..."}</span>
{taggerModelProgress?.downloaded_bytes != null && taggerModelProgress.total_bytes != null ? (
<span className="truncate">
{taggerModelProgress?.current_file ?? 'Preparing...'}
</span>
{taggerModelProgress?.downloaded_bytes != null &&
taggerModelProgress.total_bytes != null ? (
<span className="shrink-0 tabular-nums">
{formatBytes(taggerModelProgress.downloaded_bytes)} / {formatBytes(taggerModelProgress.total_bytes)}
{formatBytes(taggerModelProgress.downloaded_bytes)} /{' '}
{formatBytes(taggerModelProgress.total_bytes)}
</span>
) : null}
</div>
</div>
) : null}
{!taggerModelPreparing && taggerModelError ? (
<p className="mt-2 text-xs leading-relaxed text-amber-300/90 light-theme:text-amber-700">
<p className="light-theme:text-amber-700 mt-2 text-xs leading-relaxed text-amber-300/90">
Download failed: {taggerModelError}
</p>
) : null}
</div>
</div>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">Semantic search & similarity — built in</h4>
<div className="mt-1 divide-y divide-white/[0.05] light-theme:divide-gray-300/70">
<h4 className="mt-6 text-[12px] font-semibold tracking-[0.08em] text-gray-400 uppercase">
Semantic search & similarity — built in
</h4>
<div className="light-theme:divide-gray-300/70 mt-1 divide-y divide-white/[0.05]">
<div className="py-4">
<p className="text-sm text-white">Search by meaning, find look-alikes</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">
Powers <code className="rounded bg-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.
Powers{' '}
<code className="light-theme:bg-gray-800 light-theme:text-gray-100 rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200">
/s
</code>{' '}
search, "find similar", and the Explore view, so it's part of the standard pipeline: the
CLIP model (~580 MB) downloads automatically the first time embeddings run. Nothing to
do you'll see it in the background-tasks bar.
</p>
</div>
</div>
<p className="mt-5 text-xs leading-relaxed text-gray-500">
Semantic search and similarity are part of the standard pipeline; AI tagging stays optional and
can be downloaded any time from Settings.
Semantic search and similarity are part of the standard pipeline; AI tagging stays optional
and can be downloaded any time from Settings.
</p>
</div>
);
)
}
@@ -1,40 +1,41 @@
import { useEffect, useState } from "react";
import { FakeTile, ReplayButton } from "./fakes";
import { useEffect, useState } from 'react'
import { FakeTile, ReplayButton } from './fakes'
const REVEAL_MS = 280;
const REVEAL_MS = 280
// Two rows (cols-4) keeps the explainer text visible without scrolling.
const TILE_PROPS: { favorite?: boolean; rating?: number; duration?: string }[] = [
{},
{ favorite: true },
{},
{ duration: "1:24" },
{ duration: '1:24' },
{ rating: 5 },
{ favorite: true, rating: 3 },
{ duration: "0:09" },
{ duration: '0:09' },
{},
];
]
const TILE_COUNT = TILE_PROPS.length;
const TILE_COUNT = TILE_PROPS.length
/// Placeholder tiles "loading in" once (skeleton → image), then stopping fully
/// revealed with a replay control.
export function StepGalleryPreview() {
const [revealed, setRevealed] = useState(0);
const [revealed, setRevealed] = useState(0)
useEffect(() => {
if (revealed >= TILE_COUNT) return;
const timer = setTimeout(() => setRevealed((n) => n + 1), REVEAL_MS);
return () => clearTimeout(timer);
}, [revealed]);
if (revealed >= TILE_COUNT) return
const timer = setTimeout(() => setRevealed((n) => n + 1), REVEAL_MS)
return () => clearTimeout(timer)
}, [revealed])
const finished = revealed >= TILE_COUNT;
const finished = revealed >= TILE_COUNT
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
The gallery is a virtualized grid it stays fast with hundreds of thousands of items. Tiles
carry your favorites, star ratings, and video durations; hover for filename and quick actions.
carry your favorites, star ratings, and video durations; hover for filename and quick
actions.
</p>
<div className="media-dark-surface mt-5 grid grid-cols-4 gap-1.5">
@@ -44,18 +45,18 @@ export function StepGalleryPreview() {
</div>
<div className="mt-6 flex items-start justify-between gap-4">
<div className="divide-y divide-white/[0.05] text-xs leading-relaxed text-gray-500 light-theme:divide-gray-300/70">
<div className="light-theme:divide-gray-300/70 divide-y divide-white/[0.05] text-xs leading-relaxed text-gray-500">
<p className="pb-2.5">
<span className="text-gray-300">Click any tile</span> to open the lightbox keyboard navigation,
zoom, inline tag editing, ratings, and a full video player.
<span className="text-gray-300">Click any tile</span> to open the lightbox keyboard
navigation, zoom, inline tag editing, ratings, and a full video player.
</p>
<p className="pt-2.5">
<span className="text-gray-300">The toolbar</span> filters by type, favorites, or rating, sorts by
date/name/size, and switches grid density.
<span className="text-gray-300">The toolbar</span> filters by type, favorites, or
rating, sorts by date/name/size, and switches grid density.
</p>
</div>
{finished ? <ReplayButton onClick={() => setRevealed(0)} /> : null}
</div>
</div>
);
)
}
+44 -34
View File
@@ -1,47 +1,47 @@
import { useEffect, useState } from "react";
import { FakeProgressBar, FakeStageTag, ReplayButton } from "./fakes";
import { useEffect, useState } from 'react'
import { FakeProgressBar, FakeStageTag, ReplayButton } from './fakes'
const STAGES = ["Thumbnails", "Metadata", "Embeddings", "Tags"] as const;
const STAGE_TOTAL = 128;
const TICK_MS = 80;
const STEP_PER_TICK = 8;
const STAGES = ['Thumbnails', 'Metadata', 'Embeddings', 'Tags'] as const
const STAGE_TOTAL = 128
const TICK_MS = 80
const STEP_PER_TICK = 8
/// A one-shot fake of the background-tasks bar: each stage drains in order,
/// then it stops at "all done" with a replay control.
export function StepPipeline() {
const [stageIndex, setStageIndex] = useState(0);
const [filled, setFilled] = useState(0);
const [stageIndex, setStageIndex] = useState(0)
const [filled, setFilled] = useState(0)
const finished = stageIndex >= STAGES.length;
const finished = stageIndex >= STAGES.length
useEffect(() => {
if (finished) return;
if (finished) return
const timer = setTimeout(() => {
// Read from the closure and call setters directly — nesting one setter
// inside another's updater double-advances under React StrictMode.
if (filled + STEP_PER_TICK < STAGE_TOTAL) {
setFilled(filled + STEP_PER_TICK);
setFilled(filled + STEP_PER_TICK)
} else {
setStageIndex(stageIndex + 1);
setFilled(0);
setStageIndex(stageIndex + 1)
setFilled(0)
}
}, TICK_MS);
return () => clearTimeout(timer);
}, [filled, stageIndex, finished]);
}, TICK_MS)
return () => clearTimeout(timer)
}, [filled, stageIndex, finished])
const replay = () => {
setStageIndex(0);
setFilled(0);
};
setStageIndex(0)
setFilled(0)
}
const remaining = STAGE_TOTAL - filled;
const remaining = STAGE_TOTAL - filled
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
After indexing, Phokus works through a strict pipeline: thumbnails first, then video metadata,
then visual embeddings (for similarity and semantic search), then AI tags. One stage at a time,
so each runs at full speed.
After indexing, Phokus works through a strict pipeline: thumbnails first, then video
metadata, then visual embeddings (for similarity and semantic search), then AI tags. One
stage at a time, so each runs at full speed.
</p>
<p className="mt-5 text-xs text-gray-500">
@@ -49,41 +49,51 @@ export function StepPipeline() {
</p>
{/* Fake BackgroundTasks slim bar */}
<div className="mt-3 rounded-lg border border-white/[0.07] bg-gray-900/30 px-4 py-3 light-theme:border-gray-300/70 light-theme:bg-gray-900">
<div className="light-theme:border-gray-300/70 light-theme:bg-gray-900 mt-3 rounded-lg border border-white/[0.07] bg-gray-900/30 px-4 py-3">
<div className="flex items-center gap-3">
<span className="relative flex h-1.5 w-1.5 shrink-0">
{!finished ? (
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-60" />
) : null}
<span className={`relative inline-flex h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-blue-400"}`} />
<span
className={`relative inline-flex h-1.5 w-1.5 rounded-full ${finished ? 'bg-emerald-400' : 'bg-blue-400'}`}
/>
</span>
<span className="light-theme:text-gray-500 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
key={stage}
label={!finished && i === stageIndex ? `${stage} · ${remaining}` : stage}
state={finished || i < stageIndex ? "done" : i === stageIndex ? "active" : "waiting"}
state={
finished || i < stageIndex ? 'done' : i === stageIndex ? 'active' : 'waiting'
}
/>
))}
</div>
<FakeProgressBar fraction={finished ? 1 : filled / STAGE_TOTAL} className="ml-auto w-24" />
<FakeProgressBar
fraction={finished ? 1 : filled / STAGE_TOTAL}
className="ml-auto w-24"
/>
</div>
</div>
<div className="mt-6 flex items-start justify-between gap-4">
<div className="divide-y divide-white/[0.05] text-xs leading-relaxed text-gray-500 light-theme:divide-gray-300/70">
<div className="light-theme:divide-gray-300/70 divide-y divide-white/[0.05] text-xs leading-relaxed text-gray-500">
<p className="pb-2.5">
<span className="text-gray-300">It's all interruptible.</span> Close the app whenever you like
the queue picks up where it left off next launch.
<span className="text-gray-300">It's all interruptible.</span> Close the app whenever
you like the queue picks up where it left off next launch.
</p>
<p className="pt-2.5">
<span className="text-gray-300">Per-folder control.</span> Right-click a folder in the sidebar to
pause its background work, and click the bar itself to expand a detailed per-folder view.
<span className="text-gray-300">Per-folder control.</span> Right-click a folder in the
sidebar to pause its background work, and click the bar itself to expand a detailed
per-folder view.
</p>
</div>
{finished ? <ReplayButton onClick={replay} /> : null}
</div>
</div>
);
)
}
+59 -41
View File
@@ -1,83 +1,101 @@
import { useEffect, useState } from "react";
import { ReplayButton, SEARCH_RESULTS } from "./fakes";
import { useEffect, useState } from 'react'
import { ReplayButton, SEARCH_RESULTS } from './fakes'
const DEMOS = [
{
query: "beach-day_042.jpg",
mode: "Filename",
description: "Plain text matches file names — the default, instant search. One name, one file.",
query: 'beach-day_042.jpg',
mode: 'Filename',
description: 'Plain text matches file names — the default, instant search. One name, one file.',
results: SEARCH_RESULTS.filename,
},
{
query: "/s golden sunset over water",
mode: "Semantic",
description: "Describe what's in the picture. Visual embeddings find matches even when filenames say nothing.",
query: '/s golden sunset over water',
mode: 'Semantic',
description:
"Describe what's in the picture. Visual embeddings find matches even when filenames say nothing.",
results: SEARCH_RESULTS.semantic,
},
{
query: "/t landscape",
mode: "Tags",
description: "Search by AI or manual tags. Autocomplete suggests tags as you type.",
query: '/t landscape',
mode: 'Tags',
description: 'Search by AI or manual tags. Autocomplete suggests tags as you type.',
results: SEARCH_RESULTS.tags,
},
] as const;
] as const
const TYPE_MS = 55;
const HOLD_MS = 2600;
const TYPE_MS = 55
const HOLD_MS = 2600
/// Types each demo query, shows matching results, advances through all three
/// modes once, then stops on the last with a replay control.
export function StepSearchDemo() {
const [demoIndex, setDemoIndex] = useState(0);
const [typed, setTyped] = useState(0);
const [finished, setFinished] = useState(false);
const [demoIndex, setDemoIndex] = useState(0)
const [typed, setTyped] = useState(0)
const [finished, setFinished] = useState(false)
const demo = DEMOS[demoIndex];
const isLastDemo = demoIndex === DEMOS.length - 1;
const demo = DEMOS[demoIndex]
const isLastDemo = demoIndex === DEMOS.length - 1
useEffect(() => {
if (finished) return;
if (finished) return
if (typed < demo.query.length) {
const timer = setTimeout(() => setTyped((n) => n + 1), TYPE_MS);
return () => clearTimeout(timer);
const timer = setTimeout(() => setTyped((n) => n + 1), TYPE_MS)
return () => clearTimeout(timer)
}
// Fully typed: hold, then advance — or finish on the last demo.
const timer = setTimeout(() => {
if (isLastDemo) {
setFinished(true);
setFinished(true)
} else {
setDemoIndex((i) => i + 1);
setTyped(0);
setDemoIndex((i) => i + 1)
setTyped(0)
}
}, HOLD_MS);
return () => clearTimeout(timer);
}, [typed, demo.query.length, isLastDemo, finished]);
}, HOLD_MS)
return () => clearTimeout(timer)
}, [typed, demo.query.length, isLastDemo, finished])
const replay = () => {
setDemoIndex(0);
setTyped(0);
setFinished(false);
};
setDemoIndex(0)
setTyped(0)
setFinished(false)
}
const fullyTyped = typed >= demo.query.length;
const fullyTyped = typed >= demo.query.length
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
One search bar, three modes picked by prefix. No prefix searches filenames, <code className="rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200 light-theme:bg-gray-800 light-theme:text-gray-100">/s</code> searches
by meaning, <code className="rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200 light-theme:bg-gray-800 light-theme:text-gray-100">/t</code> searches tags.
One search bar, three modes picked by prefix. No prefix searches filenames,{' '}
<code className="light-theme:bg-gray-800 light-theme:text-gray-100 rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200">
/s
</code>{' '}
searches by meaning,{' '}
<code className="light-theme:bg-gray-800 light-theme:text-gray-100 rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200">
/t
</code>{' '}
searches tags.
</p>
{/* Mock search bar */}
<div className="mt-5 flex items-center gap-2.5 rounded-lg border border-white/10 bg-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" />
<div className="light-theme:border-gray-300/70 light-theme:bg-gray-900 mt-5 flex items-center gap-2.5 rounded-lg border border-white/10 bg-gray-900/50 px-3.5 py-2.5">
<svg
className="h-4 w-4 shrink-0 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
<span className="text-sm text-white">
{demo.query.slice(0, typed)}
{!finished ? <span className="animate-pulse text-gray-500">|</span> : null}
</span>
<span className="ml-auto shrink-0 rounded-md border border-sky-400/25 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium text-sky-300 light-theme:border-sky-600/40 light-theme:bg-sky-100 light-theme:text-sky-700">
<span className="light-theme:border-sky-600/40 light-theme:bg-sky-100 light-theme:text-sky-700 ml-auto shrink-0 rounded-md border border-sky-400/25 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium text-sky-300">
{demo.mode}
</span>
</div>
@@ -90,7 +108,7 @@ export function StepSearchDemo() {
demo's visible state. */}
<div
key={demoIndex}
className={`media-dark-surface mt-3 flex flex-wrap justify-center gap-1.5 transition-opacity duration-300 ${fullyTyped ? "opacity-100" : "opacity-0"}`}
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]">
@@ -104,5 +122,5 @@ export function StepSearchDemo() {
{finished ? <ReplayButton onClick={replay} /> : null}
</div>
</div>
);
)
}
+24 -19
View File
@@ -1,4 +1,4 @@
import { PhokusMark } from "../PhokusMark";
import { PhokusMark } from '../PhokusMark'
// Closing step. Introduces the app mark (the aperture in the title bar) and,
// since that same mark doubles as the update indicator, explains how updates
@@ -12,23 +12,23 @@ export function StepUpdates() {
have to go looking for one.
</p>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">
<h4 className="mt-6 text-[12px] font-semibold tracking-[0.08em] text-gray-400 uppercase">
When an update is ready
</h4>
<div className="mt-1 py-4">
<p className="text-sm text-white">The mark in the title bar lights up</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">
That aperture in the top-left corner is Phokus. When a new version is waiting, its focal point
glows amber — click it to update and relaunch. Nothing installs on its own; you're always in
control.
That aperture in the top-left corner is Phokus. When a new version is waiting, its focal
point glows amber — click it to update and relaunch. Nothing installs on its own; you're
always in control.
</p>
{/* Mini app-window mockup: the lit mark shown in a real title bar. */}
<div className="mt-3 overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-lg light-theme:border-gray-300/70">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-3 py-2 light-theme:border-gray-300/70">
<div className="relative flex h-6 w-6 items-center justify-center rounded-md bg-gray-900 text-gray-300 light-theme:bg-gray-800">
<span className="pointer-events-none absolute left-1/2 top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/30 blur-[3px]" />
<span className="pointer-events-none absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/55 animate-ping" />
<div className="light-theme:border-gray-300/70 mt-3 overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-lg">
<div className="light-theme:border-gray-300/70 flex items-center gap-2 border-b border-white/[0.06] px-3 py-2">
<div className="light-theme:bg-gray-800 relative flex h-6 w-6 items-center justify-center rounded-md bg-gray-900 text-gray-300">
<span className="pointer-events-none absolute top-1/2 left-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 top-1/2 left-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 animate-ping rounded-full bg-amber-400/55" />
<PhokusMark className="relative h-[18px] w-[18px]" dotClassName="fill-amber-400" />
</div>
<span className="text-xs font-semibold tracking-wide text-gray-300">Phokus</span>
@@ -41,14 +41,19 @@ export function StepUpdates() {
<rect x="0.5" y="0.5" width="9" height="9" rx="1.5" stroke="currentColor" />
</svg>
<svg width="9" height="9" viewBox="0 0 10 10" fill="none">
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
<path
d="M1 1L9 9M9 1L1 9"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
/>
</svg>
</div>
</div>
{/* Ghosted window body, just enough to read as the app. */}
<div className="flex h-[72px] bg-gray-900/30">
<div className="w-14 shrink-0 border-r border-white/[0.05] p-2.5 light-theme:border-gray-300/70">
<div className="light-theme:border-gray-300/70 w-14 shrink-0 border-r border-white/[0.05] p-2.5">
<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" />
@@ -65,22 +70,22 @@ export function StepUpdates() {
</div>
</div>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">
<h4 className="mt-6 text-[12px] font-semibold tracking-[0.08em] text-gray-400 uppercase">
Prefer to manage it yourself?
</h4>
<div className="mt-1 py-4">
<p className="text-sm text-white">Check any time from Settings</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">
Settings General shows your current version with a manual{" "}
<span className="text-gray-300">Check for updates</span> button, so you can update on your own
schedule.
Settings General shows your current version with a manual{' '}
<span className="text-gray-300">Check for updates</span> button, so you can update on your
own schedule.
</p>
</div>
<p className="mt-5 text-xs leading-relaxed text-gray-500">
That's the tour. Add folders from the sidebar, and revisit any of this from Settings including
re-running this tour.
That's the tour. Add folders from the sidebar, and revisit any of this from Settings
including re-running this tour.
</p>
</div>
);
)
}
+27 -13
View File
@@ -1,6 +1,14 @@
import { FakeTile, tileGradient } from "./fakes";
import { FakeTile, tileGradient } from './fakes'
function ViewRow({ title, description, preview }: { title: string; description: string; preview: React.ReactNode }) {
function ViewRow({
title,
description,
preview,
}: {
title: string
description: string
preview: React.ReactNode
}) {
return (
<div className="flex items-center justify-between gap-6 py-4">
<div className="min-w-0">
@@ -9,7 +17,7 @@ function ViewRow({ title, description, preview }: { title: string; description:
</div>
<div className="shrink-0">{preview}</div>
</div>
);
)
}
function ExplorePreview() {
@@ -20,7 +28,7 @@ function ExplorePreview() {
{ size: 18, x: 86, y: 22, i: 4 },
{ size: 26, x: 30, y: 36, i: 1 },
{ size: 16, x: 70, y: 44, i: 6 },
];
]
return (
<div className="media-dark-surface relative h-[72px] w-32 overflow-hidden rounded-lg border border-white/[0.07] bg-white/[0.02]">
{blobs.map((blob, idx) => (
@@ -31,7 +39,7 @@ function ExplorePreview() {
/>
))}
</div>
);
)
}
function TimelinePreview() {
@@ -39,14 +47,17 @@ function TimelinePreview() {
<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>
<span className="w-7 text-[9px] text-gray-600 tabular-nums">{year}</span>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={`h-4 w-4 rounded-sm bg-gradient-to-br ${tileGradient(row * 3 + i)}`} />
<div
key={i}
className={`h-4 w-4 rounded-sm bg-gradient-to-br ${tileGradient(row * 3 + i)}`}
/>
))}
</div>
))}
</div>
);
)
}
function DuplicatesPreview() {
@@ -57,10 +68,12 @@ function DuplicatesPreview() {
</div>
<div className="relative w-10">
<FakeTile index={3} className="rounded-md" />
<span className="absolute -right-1 -top-1 rounded-full bg-amber-500/90 px-1 text-[8px] font-semibold text-black">2×</span>
<span className="absolute -top-1 -right-1 rounded-full bg-amber-500/90 px-1 text-[8px] font-semibold text-black">
2×
</span>
</div>
</div>
);
)
}
export function StepViews() {
@@ -69,7 +82,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] light-theme:divide-gray-300/70">
<div className="light-theme:divide-gray-300/70 mt-3 divide-y divide-white/[0.05]">
<ViewRow
title="Explore"
description="A visual cluster map and tag cloud — browse by theme instead of folder, and jump into any cluster."
@@ -87,8 +100,9 @@ export function StepViews() {
/>
</div>
<p className="mt-4 text-xs leading-relaxed text-gray-500">
Each view can be scoped to a single folder from its header no need to bounce through the sidebar.
Each view can be scoped to a single folder from its header no need to bounce through the
sidebar.
</p>
</div>
);
)
}
+113 -74
View File
@@ -1,149 +1,171 @@
import { AppTheme, useGalleryStore } from "../../store";
import { FakeProgressBar, formatBytes } from "./fakes";
import { AppTheme, useGalleryStore } from '../../store'
import { FakeProgressBar, formatBytes } from './fakes'
const THEME_OPTIONS: {
value: AppTheme;
name: string;
value: AppTheme
name: string
colors: {
background: string;
surface: string;
text: string;
muted: string;
accent: string;
};
background: string
surface: string
text: string
muted: string
accent: string
}
}[] = [
{
value: "phokus",
name: "Phokus",
value: 'phokus',
name: 'Phokus',
colors: {
background: "#030712",
surface: "#111827",
text: "#f9fafb",
muted: "#6b7280",
accent: "#10b981",
background: '#030712',
surface: '#111827',
text: '#f9fafb',
muted: '#6b7280',
accent: '#10b981',
},
},
{
value: "conventional-dark",
name: "Conventional Dark",
value: 'conventional-dark',
name: 'Conventional Dark',
colors: {
background: "#1f1f1f",
surface: "#303030",
text: "#a3a3a3",
muted: "#666666",
accent: "#10b981",
background: '#1f1f1f',
surface: '#303030',
text: '#a3a3a3',
muted: '#666666',
accent: '#10b981',
},
},
{
value: "subtle-light",
name: "Subtle Light",
value: 'subtle-light',
name: 'Subtle Light',
colors: {
background: "#e9e7e2",
surface: "#dedbd4",
text: "#18202c",
muted: "#817b72",
accent: "#059669",
background: '#e9e7e2',
surface: '#dedbd4',
text: '#18202c',
muted: '#817b72',
accent: '#059669',
},
},
];
]
export function FfmpegStatusRow() {
const ffmpegStatus = useGalleryStore((s) => s.ffmpegStatus);
const ffmpegProgress = useGalleryStore((s) => s.ffmpegProgress);
const ffmpegError = useGalleryStore((s) => s.ffmpegError);
const retryFfmpegDownload = useGalleryStore((s) => s.retryFfmpegDownload);
const ffmpegStatus = useGalleryStore((s) => s.ffmpegStatus)
const ffmpegProgress = useGalleryStore((s) => s.ffmpegProgress)
const ffmpegError = useGalleryStore((s) => s.ffmpegError)
const retryFfmpegDownload = useGalleryStore((s) => s.retryFfmpegDownload)
if (ffmpegStatus === "installed") {
if (ffmpegStatus === 'installed') {
return (
<div className="flex items-center gap-2.5 py-3">
<svg className="h-4 w-4 shrink-0 text-emerald-400 light-theme:text-emerald-700" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
className="light-theme:text-emerald-700 h-4 w-4 shrink-0 text-emerald-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<p className="text-sm text-white">Media engine ready</p>
<p className="text-xs text-gray-500">FFmpeg is installed video thumbnails and metadata are available.</p>
<p className="text-xs text-gray-500">
FFmpeg is installed video thumbnails and metadata are available.
</p>
</div>
</div>
);
)
}
if (ffmpegStatus === "error") {
if (ffmpegStatus === 'error') {
return (
<div className="py-3">
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<p className="text-sm text-white">Media engine download failed</p>
<p className="mt-1 text-xs leading-relaxed text-amber-300/90 light-theme:text-amber-700">{ffmpegError}</p>
<p className="light-theme:text-amber-700 mt-1 text-xs leading-relaxed text-amber-300/90">
{ffmpegError}
</p>
<p className="mt-1.5 text-xs leading-relaxed text-gray-500">
You can keep going photos work without it. Video thumbnails and metadata will start
automatically once the download succeeds.
</p>
</div>
<button
className="shrink-0 rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
className="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 shrink-0 rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => void retryFfmpegDownload()}
>
Retry download
</button>
</div>
</div>
);
)
}
const fraction =
ffmpegStatus === "downloading" && ffmpegProgress && ffmpegProgress.total_bytes > 0
ffmpegStatus === 'downloading' && ffmpegProgress && ffmpegProgress.total_bytes > 0
? ffmpegProgress.downloaded_bytes / ffmpegProgress.total_bytes
: null;
: null
return (
<div className="py-3">
<div className="flex items-baseline justify-between gap-6">
<p className="text-sm text-white">
{ffmpegStatus === "unpacking" ? "Unpacking media engine..." : "Downloading media engine (FFmpeg)..."}
{ffmpegStatus === 'unpacking'
? 'Unpacking media engine...'
: 'Downloading media engine (FFmpeg)...'}
</p>
{ffmpegProgress ? (
<span className="text-[11px] tabular-nums text-gray-500">
{formatBytes(ffmpegProgress.downloaded_bytes)} / {formatBytes(ffmpegProgress.total_bytes)}
<span className="text-[11px] text-gray-500 tabular-nums">
{formatBytes(ffmpegProgress.downloaded_bytes)} /{' '}
{formatBytes(ffmpegProgress.total_bytes)}
</span>
) : null}
</div>
<FakeProgressBar fraction={ffmpegStatus === "unpacking" ? null : fraction} className="mt-2.5" />
<FakeProgressBar
fraction={ffmpegStatus === 'unpacking' ? null : fraction}
className="mt-2.5"
/>
<p className="mt-2 text-xs leading-relaxed text-gray-500">
FFmpeg powers video thumbnails and metadata. It downloads once in the background you can keep
using the tour and the app while it finishes.
FFmpeg powers video thumbnails and metadata. It downloads once in the background you can
keep using the tour and the app while it finishes.
</p>
</div>
);
)
}
export function StepWelcome() {
const theme = useGalleryStore((s) => s.theme);
const setTheme = useGalleryStore((s) => s.setTheme);
const theme = useGalleryStore((s) => s.theme)
const setTheme = useGalleryStore((s) => s.setTheme)
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
Phokus is a local-first media library: point it at your folders and it builds a fast, searchable
gallery with thumbnails, AI tags, and visual search. Everything is processed on this machine
your photos and videos never leave it.
Phokus is a local-first media library: point it at your folders and it builds a fast,
searchable gallery with thumbnails, AI tags, and visual search. Everything is processed on
this machine your photos and videos never leave it.
</p>
<p className="mt-2.5 text-sm leading-relaxed text-gray-500">
This short tour shows what to expect. Every step is skippable, and you can re-run it any time
from Settings.
This short tour shows what to expect. Every step is skippable, and you can re-run it any
time from Settings.
</p>
<h4 className="mt-7 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">Choose your look</h4>
<h4 className="mt-7 text-[12px] font-semibold tracking-[0.08em] text-gray-400 uppercase">
Choose your look
</h4>
<div className="mt-3 grid grid-cols-3 gap-2">
{THEME_OPTIONS.map((option) => {
const selected = option.value === theme;
const selected = option.value === theme
return (
<button
key={option.value}
type="button"
className={`rounded-md border p-2 text-left transition-colors ${
selected
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 ring-1 ring-emerald-400/40"
: "border-white/10 bg-white/[0.055] text-gray-300 hover:bg-white/10 hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
? 'border-emerald-400/35 bg-emerald-500/15 text-emerald-200 ring-1 ring-emerald-400/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 border-white/10 bg-white/[0.055] text-gray-300 hover:bg-white/10 hover:text-white'
}`}
onClick={() => setTheme(option.value)}
>
@@ -151,23 +173,40 @@ export function StepWelcome() {
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
className="mb-1 block h-2 w-8 rounded-sm"
style={{ backgroundColor: option.colors.accent }}
/>
<span
className="block rounded-sm p-1.5"
style={{ backgroundColor: option.colors.surface }}
>
<span
className="block h-1.5 w-9 rounded-sm"
style={{ backgroundColor: option.colors.text }}
/>
<span
className="mt-1 block h-1.5 w-14 rounded-sm"
style={{ backgroundColor: option.colors.muted }}
/>
<span
className="mt-1 block h-1.5 w-10 rounded-sm"
style={{ backgroundColor: option.colors.muted }}
/>
</span>
</span>
<span className="mt-2 block text-[11px] font-medium">{option.name}</span>
</button>
);
)
})}
</div>
<h4 className="mt-7 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">First-time setup</h4>
<div className="mt-1 divide-y divide-white/[0.05] light-theme:divide-gray-300/70">
<h4 className="mt-7 text-[12px] font-semibold tracking-[0.08em] text-gray-400 uppercase">
First-time setup
</h4>
<div className="light-theme:divide-gray-300/70 mt-1 divide-y divide-white/[0.05]">
<FfmpegStatusRow />
</div>
</div>
);
)
}
+96 -59
View File
@@ -2,21 +2,21 @@
// deliberately fake — rights-clean demo stills (generated, owned), looping
// demo progress — so new users see the real UI's shapes before their own
// library exists.
import sunset from "../../assets/onboarding/sunset.webp";
import sunsetcoast from "../../assets/onboarding/sunsetcoast.webp";
import sunsetlake from "../../assets/onboarding/sunsetlake.webp";
import beach from "../../assets/onboarding/beach.webp";
import landscape1 from "../../assets/onboarding/landscape1.webp";
import landscape2 from "../../assets/onboarding/landscape2.webp";
import forest from "../../assets/onboarding/forest.webp";
import citynight from "../../assets/onboarding/citynight.webp";
import flower from "../../assets/onboarding/flower.webp";
import alpinelake from "../../assets/onboarding/alpinelake.webp";
import dunes from "../../assets/onboarding/dunes.webp";
import cozy from "../../assets/onboarding/cozy.webp";
import cat from "../../assets/onboarding/cat.webp";
import balloon from "../../assets/onboarding/balloon.webp";
import architecture from "../../assets/onboarding/architecture.webp";
import sunset from '../../assets/onboarding/sunset.webp'
import sunsetcoast from '../../assets/onboarding/sunsetcoast.webp'
import sunsetlake from '../../assets/onboarding/sunsetlake.webp'
import beach from '../../assets/onboarding/beach.webp'
import landscape1 from '../../assets/onboarding/landscape1.webp'
import landscape2 from '../../assets/onboarding/landscape2.webp'
import forest from '../../assets/onboarding/forest.webp'
import citynight from '../../assets/onboarding/citynight.webp'
import flower from '../../assets/onboarding/flower.webp'
import alpinelake from '../../assets/onboarding/alpinelake.webp'
import dunes from '../../assets/onboarding/dunes.webp'
import cozy from '../../assets/onboarding/cozy.webp'
import cat from '../../assets/onboarding/cat.webp'
import balloon from '../../assets/onboarding/balloon.webp'
import architecture from '../../assets/onboarding/architecture.webp'
// Ordered for grid variety: adjacent indices are visually distinct.
const FAKE_IMAGES = [
@@ -33,10 +33,10 @@ const FAKE_IMAGES = [
landscape2,
beach,
architecture,
];
]
export function fakeImage(index: number): string {
return FAKE_IMAGES[index % FAKE_IMAGES.length];
return FAKE_IMAGES[index % FAKE_IMAGES.length]
}
// Result sets matched to what each query would genuinely return.
@@ -47,23 +47,23 @@ export const SEARCH_RESULTS = {
filename: [beach],
semantic: [sunsetcoast, sunset, sunsetlake],
tags: [landscape1, landscape2, alpinelake],
} as const;
} as const
// Abstract gradient blobs/ticks for the Explore & Timeline mini-previews,
// where small non-photographic shapes read more clearly than tiny stills.
const TILE_GRADIENTS = [
"from-sky-900/70 via-slate-800 to-slate-900",
"from-amber-900/60 via-stone-800 to-stone-900",
"from-emerald-900/60 via-slate-800 to-gray-900",
"from-rose-900/50 via-slate-800 to-slate-900",
"from-indigo-900/60 via-slate-800 to-gray-900",
"from-cyan-900/60 via-slate-800 to-slate-900",
"from-fuchsia-900/40 via-slate-800 to-gray-900",
"from-orange-900/50 via-stone-800 to-stone-900",
];
'from-sky-900/70 via-slate-800 to-slate-900',
'from-amber-900/60 via-stone-800 to-stone-900',
'from-emerald-900/60 via-slate-800 to-gray-900',
'from-rose-900/50 via-slate-800 to-slate-900',
'from-indigo-900/60 via-slate-800 to-gray-900',
'from-cyan-900/60 via-slate-800 to-slate-900',
'from-fuchsia-900/40 via-slate-800 to-gray-900',
'from-orange-900/50 via-stone-800 to-stone-900',
]
export function tileGradient(index: number): string {
return TILE_GRADIENTS[index % TILE_GRADIENTS.length];
return TILE_GRADIENTS[index % TILE_GRADIENTS.length]
}
export function FakeTile({
@@ -72,24 +72,31 @@ export function FakeTile({
favorite = false,
rating = 0,
duration,
className = "",
className = '',
}: {
index: number;
loaded?: boolean;
favorite?: boolean;
rating?: number;
duration?: string;
className?: string;
index: number
loaded?: boolean
favorite?: boolean
rating?: number
duration?: string
className?: string
}) {
return (
<div className={`media-dark-surface group relative aspect-square overflow-hidden rounded-xl bg-white/[0.04] ${className}`}>
<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" />
<img
src={fakeImage(index)}
alt=""
loading="lazy"
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 animate-pulse bg-white/[0.04]" />
)}
{loaded && favorite ? (
<div className="absolute right-1.5 top-1.5 rounded-full bg-black/50 p-1 text-rose-400 backdrop-blur-sm">
<div className="absolute top-1.5 right-1.5 rounded-full bg-black/50 p-1 text-rose-400 backdrop-blur-sm">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg>
@@ -105,27 +112,41 @@ export function FakeTile({
</div>
) : null}
{loaded && duration ? (
<div className="absolute bottom-1.5 right-1.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80">
<div className="absolute right-1.5 bottom-1.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80">
{duration}
</div>
) : null}
</div>
);
)
}
export function FakeStageTag({ label, state }: { label: string; state: "active" | "done" | "waiting" }) {
export function FakeStageTag({
label,
state,
}: {
label: string
state: 'active' | 'done' | 'waiting'
}) {
const className =
state === "active"
? "bg-gray-900 text-gray-300 light-theme:bg-gray-900 light-theme:text-gray-300"
: state === "done"
? "bg-emerald-500/10 text-emerald-400 light-theme:bg-emerald-100 light-theme:text-emerald-700"
: "bg-gray-900/60 text-gray-600 light-theme:bg-gray-800 light-theme:text-gray-500";
return <span className={`rounded-md px-2 py-0.5 text-[11px] ${className}`}>{label}</span>;
state === 'active'
? 'bg-gray-900 text-gray-300 light-theme:bg-gray-900 light-theme:text-gray-300'
: state === 'done'
? 'bg-emerald-500/10 text-emerald-400 light-theme:bg-emerald-100 light-theme:text-emerald-700'
: 'bg-gray-900/60 text-gray-600 light-theme:bg-gray-800 light-theme:text-gray-500'
return <span className={`rounded-md px-2 py-0.5 text-[11px] ${className}`}>{label}</span>
}
export function FakeProgressBar({ fraction, className = "" }: { fraction: number | null; className?: string }) {
export function FakeProgressBar({
fraction,
className = '',
}: {
fraction: number | null
className?: string
}) {
return (
<div className={`h-px overflow-hidden rounded-full bg-gray-200/60 light-theme:bg-gray-300 ${className}`}>
<div
className={`light-theme:bg-gray-300 h-px overflow-hidden rounded-full bg-gray-200/60 ${className}`}
>
{fraction === null ? (
<div className="h-full w-full animate-pulse bg-blue-500/40" />
) : (
@@ -135,27 +156,43 @@ export function FakeProgressBar({ fraction, className = "" }: { fraction: number
/>
)}
</div>
);
)
}
export function ReplayButton({ onClick, label = "Replay" }: { onClick: () => void; label?: string }) {
export function ReplayButton({
onClick,
label = 'Replay',
}: {
onClick: () => void
label?: string
}) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-200 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
className="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 inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-200"
>
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12a7.5 7.5 0 0012.9 5.3M19.5 12A7.5 7.5 0 006.6 6.7M4.5 6.5v3h3M19.5 17.5v-3h-3" />
<svg
className="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12a7.5 7.5 0 0012.9 5.3M19.5 12A7.5 7.5 0 006.6 6.7M4.5 6.5v3h3M19.5 17.5v-3h-3"
/>
</svg>
{label}
</button>
);
)
}
export function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${bytes} B`;
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`
return `${bytes} B`
}
@@ -0,0 +1,622 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useGalleryStore } from '../../store'
import { TAGGER_MODELS } from '../../taggerModels'
import {
formatBytesShort,
ScopeButton,
SettingsGroup,
SettingsItem,
settingsButtonClass,
StatusPill,
TaggerAccelerationButton,
TaggerModelButton,
} from './shared'
export function AiWorkspaceSettingsSection() {
const folders = useGalleryStore((state) => state.folders)
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress)
const taggingQueueScope = useGalleryStore((state) => state.taggingQueueScope)
const taggingQueueFolderIds = useGalleryStore((state) => state.taggingQueueFolderIds)
const setTaggingQueueScope = useGalleryStore((state) => state.setTaggingQueueScope)
const toggleTaggingQueueFolder = useGalleryStore((state) => state.toggleTaggingQueueFolder)
const setTaggingQueueFolderIds = useGalleryStore((state) => state.setTaggingQueueFolderIds)
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus)
const taggerModelPreparing = useGalleryStore((state) => state.taggerModelPreparing)
const taggerModelProgress = useGalleryStore((state) => state.taggerModelProgress)
const taggerModelError = useGalleryStore((state) => state.taggerModelError)
const taggerAcceleration = useGalleryStore((state) => state.taggerAcceleration)
const taggerThreshold = useGalleryStore((state) => state.taggerThreshold)
const taggerBatchSize = useGalleryStore((state) => state.taggerBatchSize)
const taggerRuntimeProbe = useGalleryStore((state) => state.taggerRuntimeProbe)
const taggerRuntimeChecking = useGalleryStore((state) => state.taggerRuntimeChecking)
const prepareTaggerModel = useGalleryStore((state) => state.prepareTaggerModel)
const deleteTaggerModel = useGalleryStore((state) => state.deleteTaggerModel)
const setTaggerAcceleration = useGalleryStore((state) => state.setTaggerAcceleration)
const taggerModel = useGalleryStore((state) => state.taggerModel)
const setTaggerModel = useGalleryStore((state) => state.setTaggerModel)
const setTaggerThreshold = useGalleryStore((state) => state.setTaggerThreshold)
const setTaggerBatchSize = useGalleryStore((state) => state.setTaggerBatchSize)
const probeTaggerRuntime = useGalleryStore((state) => state.probeTaggerRuntime)
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs)
const queueTaggingJobsForFolders = useGalleryStore((state) => state.queueTaggingJobsForFolders)
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs)
const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders)
const resetAiTags = useGalleryStore((state) => state.resetAiTags)
const resetAiTagsForFolders = useGalleryStore((state) => state.resetAiTagsForFolders)
const openTagManager = useGalleryStore((state) => state.openTagManager)
const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null)
const [taggerQueueing, setTaggerQueueing] = useState(false)
const [taggerClearing, setTaggerClearing] = useState(false)
const [taggerResetConfirming, setTaggerResetConfirming] = useState(false)
const [taggerResetting, setTaggerResetting] = useState(false)
const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false)
const [taggerAccelerationError, setTaggerAccelerationError] = useState<string | null>(null)
const [taggerModelSwitching, setTaggerModelSwitching] = useState(false)
const [taggerModelSwitchError, setTaggerModelSwitchError] = useState<string | null>(null)
const [taggerThresholdDraft, setTaggerThresholdDraft] = useState<string | null>(null)
const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false)
const [taggerThresholdError, setTaggerThresholdError] = useState<string | null>(null)
const [taggerBatchSizeDraft, setTaggerBatchSizeDraft] = useState<string | null>(null)
const [taggerBatchSizeSaving, setTaggerBatchSizeSaving] = useState(false)
const [taggerBatchSizeError, setTaggerBatchSizeError] = useState<string | null>(null)
const thresholdErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const batchSizeErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
return () => {
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current)
if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current)
}
}, [])
const selectedFolders = useMemo(
() => folders.filter((folder) => taggingQueueFolderIds.includes(folder.id)),
[folders, taggingQueueFolderIds]
)
const taggerReady = taggerModelStatus?.ready ?? false
const queueScopeLabel =
taggingQueueScope === 'all'
? 'all media'
: selectedFolders.length > 0
? `${selectedFolders.length} selected folder${selectedFolders.length === 1 ? '' : 's'}`
: 'no folders selected'
const thresholdDisplay = taggerThresholdDraft ?? String(taggerThreshold)
const batchSizeDisplay = taggerBatchSizeDraft ?? String(taggerBatchSize)
const taggerBytesKnown =
taggerModelProgress?.downloaded_bytes != null &&
taggerModelProgress.total_bytes != null &&
taggerModelProgress.total_bytes > 0
const taggerDownloadLabel = taggerBytesKnown
? `Downloading ${formatBytesShort(taggerModelProgress!.downloaded_bytes!)} / ${formatBytesShort(taggerModelProgress!.total_bytes!)}`
: taggerModelProgress
? `Downloading ${taggerModelProgress.completed_files}/${taggerModelProgress.total_files}`
: taggerModelPreparing
? 'Preparing AI tagger...'
: taggerReady
? 'Installed'
: 'Install model'
const taggerDownloadPercent = taggerBytesKnown
? Math.round((taggerModelProgress!.downloaded_bytes! / taggerModelProgress!.total_bytes!) * 100)
: taggerModelProgress
? Math.round(
(taggerModelProgress.completed_files / Math.max(taggerModelProgress.total_files, 1)) * 100
)
: 0
const runQueueAction = (action: 'queue' | 'clear') => {
const selectedIds = taggingQueueFolderIds
const perform =
taggingQueueScope === 'all'
? action === 'queue'
? queueTaggingJobs(null)
: clearTaggingJobs(null)
: selectedIds.length > 0
? action === 'queue'
? queueTaggingJobsForFolders(selectedIds)
: clearTaggingJobsForFolders(selectedIds)
: Promise.resolve(0)
if (action === 'queue') {
setTaggerQueueing(true)
} else {
setTaggerClearing(true)
}
setTaggerQueueStatus(null)
setTaggerResetConfirming(false)
void perform
.then((count) => {
if (taggingQueueScope === 'selected' && selectedIds.length === 0) {
setTaggerQueueStatus('Choose at least one folder before running tagging jobs.')
return
}
setTaggerQueueStatus(
count === 0
? action === 'queue'
? 'No missing tags found for the current target.'
: 'No queued tagging jobs to clear for the current target.'
: action === 'queue'
? `Queued ${count.toLocaleString()} image${count === 1 ? '' : 's'} for tagging.`
: `Cleared ${count.toLocaleString()} queued tagging job${count === 1 ? '' : 's'}.`
)
})
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => {
if (action === 'queue') {
setTaggerQueueing(false)
} else {
setTaggerClearing(false)
}
})
}
const runResetAiTags = () => {
if (!taggerResetConfirming) {
setTaggerResetConfirming(true)
setTaggerQueueStatus(
`Reset AI tags for ${queueScopeLabel}? User tags are preserved, and retagging is not queued automatically.`
)
return
}
const selectedIds = taggingQueueFolderIds
const perform =
taggingQueueScope === 'all'
? resetAiTags(null)
: selectedIds.length > 0
? resetAiTagsForFolders(selectedIds)
: Promise.resolve(0)
setTaggerResetting(true)
setTaggerQueueStatus(null)
void perform
.then((count) => {
if (taggingQueueScope === 'selected' && selectedIds.length === 0) {
setTaggerQueueStatus('Choose at least one folder before resetting AI tags.')
return
}
setTaggerQueueStatus(
count === 0
? 'No AI tag data found for the current target.'
: `Reset AI tags for ${count.toLocaleString()} image${count === 1 ? '' : 's'}. Queue tagging when you're ready to retag.`
)
})
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => {
setTaggerResetting(false)
setTaggerResetConfirming(false)
})
}
return (
<div className="mt-8 space-y-9">
<SettingsGroup title="Model">
<SettingsItem
label="Tagging model"
description="Choose which model generates tags. JoyTag suits photos and NSFW; WD is anime-focused. Switching may require a one-time download."
>
<div className="flex flex-col items-end gap-1.5">
<div className="light-theme:border-gray-300/80 flex rounded-lg border border-white/[0.07] p-0.5">
{(['wd', 'joytag'] as const).map((model) => (
<TaggerModelButton
key={model}
model={model}
current={taggerModel}
onSelect={(nextModel) => {
if (nextModel === taggerModel) return
setTaggerThresholdDraft(null)
setTaggerThresholdError(null)
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current)
setTaggerModelSwitching(true)
setTaggerModelSwitchError(null)
void setTaggerModel(nextModel)
.catch((error: unknown) => setTaggerModelSwitchError(String(error)))
.finally(() => setTaggerModelSwitching(false))
}}
>
{TAGGER_MODELS[model].tab}
</TaggerModelButton>
))}
</div>
{taggerModelSwitchError ? (
<p className="text-[11px] text-amber-300">{taggerModelSwitchError}</p>
) : (
<p className="text-[11px] text-gray-600">
{taggerModelSwitching
? 'Switching...'
: `Current: ${TAGGER_MODELS[taggerModel].name}`}
</p>
)}
</div>
</SettingsItem>
<SettingsItem
label={
<>
{TAGGER_MODELS[taggerModel].name}{' '}
<span className="ml-1.5 align-middle">
<StatusPill tone={taggerReady ? 'ready' : taggerModelPreparing ? 'busy' : 'muted'}>
{taggerReady ? 'Installed' : taggerModelPreparing ? 'Preparing' : 'Not installed'}
</StatusPill>
</span>
</>
}
description={TAGGER_MODELS[taggerModel].description}
>
<div className="flex flex-col items-end gap-1.5">
<div className="flex items-center gap-2">
{taggerReady ? (
<>
<button
className={settingsButtonClass}
onClick={() => void probeTaggerRuntime()}
disabled={taggerRuntimeChecking}
>
{taggerRuntimeChecking ? 'Checking runtime...' : 'Check runtime'}
</button>
<button
className="light-theme:border-red-400/50 light-theme:bg-red-50 light-theme:text-red-700 light-theme:hover:bg-red-100 rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => void deleteTaggerModel()}
disabled={taggerModelPreparing}
>
Delete model files
</button>
</>
) : (
<button
className={`relative overflow-hidden ${settingsButtonClass}`}
onClick={() => void prepareTaggerModel()}
disabled={taggerModelPreparing}
>
{taggerModelProgress ? (
<span
className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200"
style={{ width: `${taggerDownloadPercent}%` }}
/>
) : null}
<span className="relative">{taggerDownloadLabel}</span>
</button>
)}
</div>
{taggerRuntimeProbe ? (
<div className="text-right">
<p className="text-xs text-gray-400">
Runtime check{' '}
<span className="ml-1.5 align-middle">
<StatusPill tone="ready">Ready</StatusPill>
</span>{' '}
<span className="ml-2 text-gray-600">
· acceleration: {taggerRuntimeProbe.acceleration}
</span>
</p>
<p className="mt-1 font-mono text-xs break-all text-gray-600">
{taggerRuntimeProbe.session.file}
</p>
</div>
) : null}
</div>
</SettingsItem>
<SettingsItem
label="Tagger acceleration"
description="Use DirectML when available, or fall back to CPU for reliability."
>
<div className="flex flex-col items-end gap-1.5">
<div className="light-theme:border-gray-300/80 flex rounded-lg border border-white/[0.07] p-0.5">
{(['auto', 'directml', 'cpu'] as const).map((acceleration) => (
<TaggerAccelerationButton
key={acceleration}
acceleration={acceleration}
current={taggerAcceleration}
onSelect={(nextAcceleration) => {
setTaggerAccelerationSaving(true)
setTaggerAccelerationError(null)
void setTaggerAcceleration(nextAcceleration)
.catch((error: unknown) => setTaggerAccelerationError(String(error)))
.finally(() => setTaggerAccelerationSaving(false))
}}
>
{acceleration === 'directml'
? 'DirectML'
: acceleration === 'cpu'
? 'CPU'
: 'Auto'}
</TaggerAccelerationButton>
))}
</div>
{taggerAccelerationError ? (
<p className="text-[11px] text-amber-300">{taggerAccelerationError}</p>
) : (
<p className="text-[11px] text-gray-600">
{taggerAccelerationSaving ? 'Saving...' : `Current: ${taggerAcceleration}`}
</p>
)}
</div>
</SettingsItem>
<SettingsItem
label="Confidence threshold"
description="Lower values keep more tags. Higher values are stricter and usually cleaner."
>
<div className="flex flex-col items-end gap-1.5">
<input
type="number"
min="0.05"
max="0.99"
step="0.05"
className="w-20 rounded-md border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-white focus:border-white/20 focus:outline-none"
value={thresholdDisplay}
onChange={(event) => setTaggerThresholdDraft(event.target.value)}
onBlur={(event) => {
const value = parseFloat(event.currentTarget.value)
if (!isNaN(value) && value >= 0.05 && value <= 0.99) {
setTaggerThresholdError(null)
setTaggerThresholdSaving(true)
void setTaggerThreshold(value, taggerModel)
.catch((error: unknown) => setTaggerThresholdError(String(error)))
.finally(() => {
setTaggerThresholdDraft(null)
setTaggerThresholdSaving(false)
})
} else {
setTaggerThresholdDraft(null)
setTaggerThresholdError('Must be 0.05 0.99')
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current)
thresholdErrorTimerRef.current = setTimeout(
() => setTaggerThresholdError(null),
2000
)
}
}}
/>
{taggerThresholdError ? (
<p className="text-[11px] text-amber-300">{taggerThresholdError}</p>
) : (
<p className="text-[11px] text-gray-600">
{taggerThresholdSaving
? 'Saving...'
: `Default: ${TAGGER_MODELS[taggerModel].defaultThreshold}`}
</p>
)}
</div>
</SettingsItem>
<SettingsItem
label="Tagging batch size"
description="Number of images processed concurrently during tag generation."
>
<div className="flex flex-col items-end gap-1.5">
<input
type="number"
min="1"
max="100"
step="1"
className="w-20 rounded-md border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-white focus:border-white/20 focus:outline-none"
value={batchSizeDisplay}
onChange={(event) => setTaggerBatchSizeDraft(event.target.value)}
onBlur={() => {
const value = parseInt(batchSizeDisplay, 10)
if (!isNaN(value) && value >= 1 && value <= 100) {
setTaggerBatchSizeError(null)
setTaggerBatchSizeSaving(true)
void setTaggerBatchSize(value)
.catch((error: unknown) => setTaggerQueueStatus(String(error)))
.finally(() => {
setTaggerBatchSizeDraft(null)
setTaggerBatchSizeSaving(false)
})
} else {
setTaggerBatchSizeDraft(null)
setTaggerBatchSizeError('Must be 1 100')
if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current)
batchSizeErrorTimerRef.current = setTimeout(
() => setTaggerBatchSizeError(null),
2000
)
}
}}
/>
{taggerBatchSizeError ? (
<p className="text-[11px] text-amber-300">{taggerBatchSizeError}</p>
) : (
<p className="text-[11px] text-gray-600">
{taggerBatchSizeSaving ? 'Saving...' : 'Default: 8'}
</p>
)}
</div>
</SettingsItem>
<SettingsItem label="Model location" vertical>
<div>
<p className="font-mono text-xs break-all text-gray-600">
{taggerReady ? taggerModelStatus?.local_dir : 'Not downloaded'}
</p>
{taggerModelProgress?.current_file ? (
<p className="mt-2 text-xs break-all text-gray-500">
{taggerModelProgress.current_file}
</p>
) : null}
{taggerModelError ? (
<p className="mt-2 text-xs text-amber-300">{taggerModelError}</p>
) : null}
</div>
</SettingsItem>
</SettingsGroup>
<SettingsGroup
title="Queue targets"
description="Choose which folders to include when queuing tagging jobs."
>
<SettingsItem
label="Target scope"
description="Queue across the full library, or choose a specific folder set and keep the modal open while you work through it."
>
<div className="light-theme:border-gray-300/80 flex rounded-lg border border-white/[0.07] p-0.5">
<ScopeButton scope="all" current={taggingQueueScope} onSelect={setTaggingQueueScope}>
All media
</ScopeButton>
<ScopeButton
scope="selected"
current={taggingQueueScope}
onSelect={setTaggingQueueScope}
>
Selected folders
</ScopeButton>
</div>
</SettingsItem>
<div className="py-4">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm text-white">Folder selection</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">
Current target: {queueScopeLabel}.
</p>
</div>
<div className="flex gap-2">
<button
className="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 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:cursor-not-allowed disabled:opacity-40"
onClick={() => setTaggingQueueFolderIds(folders.map((folder) => folder.id))}
disabled={taggingQueueScope === 'all' || folders.length === 0}
>
Select all
</button>
<button
className="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 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:cursor-not-allowed disabled:opacity-40"
onClick={() => setTaggingQueueFolderIds([])}
disabled={taggingQueueScope === 'all' || taggingQueueFolderIds.length === 0}
>
Clear
</button>
</div>
</div>
<div
className={`mt-2 max-h-64 divide-y divide-white/[0.04] overflow-y-auto pr-1 ${taggingQueueScope === 'selected' ? 'opacity-100' : 'opacity-60'}`}
>
{folders.map((folder) => {
const active = taggingQueueFolderIds.includes(folder.id)
const progress = mediaJobProgress[folder.id]
return (
<button
key={folder.id}
type="button"
className={`flex w-full items-center justify-between gap-3 px-1 py-2 text-left transition-colors disabled:cursor-not-allowed ${
active ? 'text-white' : 'text-gray-400 hover:text-gray-200'
}`}
onClick={() => toggleTaggingQueueFolder(folder.id)}
disabled={taggingQueueScope === 'all'}
>
<p className="min-w-0 truncate text-sm">{folder.name}</p>
<div className="flex shrink-0 items-center gap-3">
{(progress?.tagging_pending ?? 0) > 0 ? (
<StatusPill tone="busy">{progress?.tagging_pending} queued</StatusPill>
) : null}
<span className="text-[11px] text-gray-600 tabular-nums">
{folder.image_count.toLocaleString()} items
</span>
<span
className={`h-4 w-4 rounded-full border ${active ? 'border-emerald-300 bg-emerald-300' : 'border-white/15 bg-transparent'}`}
/>
</div>
</button>
)
})}
{folders.length === 0 ? (
<p className="py-2 text-sm text-gray-500">
Add a folder first to enable targeted tagging queues.
</p>
) : null}
</div>
</div>
<SettingsItem
label="Queue tagging jobs"
description="Generate missing AI tags for the current target. Results flow back into the library as the background worker finishes."
>
<div className="flex items-center gap-2">
<button
className={settingsButtonClass}
onClick={() => runQueueAction('queue')}
disabled={
!taggerReady ||
taggerQueueing ||
taggerClearing ||
taggerResetting ||
(taggingQueueScope === 'selected' && taggingQueueFolderIds.length === 0)
}
>
{taggerQueueing ? 'Queueing...' : 'Queue tagging'}
</button>
<button
className="light-theme:border-amber-500/50 light-theme:bg-amber-50 light-theme:text-amber-700 light-theme:hover:bg-amber-100 rounded-md border border-amber-400/20 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 transition-colors hover:bg-amber-500/15 disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => runQueueAction('clear')}
disabled={
taggerQueueing ||
taggerClearing ||
taggerResetting ||
(taggingQueueScope === 'selected' && taggingQueueFolderIds.length === 0)
}
>
{taggerClearing ? 'Clearing...' : 'Clear queued jobs'}
</button>
<button
className={`rounded-md border px-3 py-1.5 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-45 ${
taggerResetConfirming
? 'light-theme:border-red-500/50 light-theme:bg-red-50 light-theme:text-red-700 light-theme:hover:bg-red-100 border-red-400/30 bg-red-500/15 text-red-200 hover:bg-red-500/25'
: '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 border-white/10 bg-white/[0.055] text-gray-300 hover:bg-white/10 hover:text-white'
}`}
onClick={runResetAiTags}
disabled={
taggerQueueing ||
taggerClearing ||
taggerResetting ||
(taggingQueueScope === 'selected' && taggingQueueFolderIds.length === 0)
}
>
{taggerResetting
? 'Resetting...'
: taggerResetConfirming
? 'Confirm reset'
: 'Reset AI tags'}
</button>
{taggerResetConfirming ? (
<button
className="light-theme:border-gray-700/50 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white rounded-md border border-white/10 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={() => {
setTaggerResetConfirming(false)
setTaggerQueueStatus(null)
}}
disabled={taggerResetting}
>
Cancel
</button>
) : null}
</div>
</SettingsItem>
{taggerQueueStatus ? (
<p className="pt-3 text-xs text-gray-500">{taggerQueueStatus}</p>
) : null}
</SettingsGroup>
<SettingsGroup
title="Tag library"
description="Review and clean up the tags across your library."
>
<SettingsItem
label="Manage tags"
description="Open the tag manager in Explore to search, rename, and delete tags."
>
<button className={settingsButtonClass} onClick={openTagManager}>
Open tag manager
</button>
</SettingsItem>
</SettingsGroup>
</div>
)
}

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