Compare commits
11 Commits
c1ab651131
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| e1e89b0f87 | |||
| 0909b58110 | |||
| 4f9ab0b821 | |||
| a06e76c7a7 | |||
| 1e008244ae | |||
| ebed194f17 | |||
| 3684b98d55 | |||
| 74a4134f2f | |||
| f66fbe7931 | |||
| 5870205047 | |||
| 3db95a4489 |
+30
-1
@@ -5,10 +5,23 @@ All notable changes to Phokus are documented here. The format is based on
|
|||||||
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
||||||
(0.x: anything may change between minor versions).
|
(0.x: anything may change between minor versions).
|
||||||
|
|
||||||
## [Unreleased]
|
## [0.1.1] — 2026-06-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Custom multi-folder picker** — replaces the native OS dialog with an
|
||||||
|
in-app folder browser that lets you navigate, stage folders from multiple
|
||||||
|
locations, and add them all in one go. Duplicate roots are skipped
|
||||||
|
automatically; partially-failed batches remove successfully-added entries
|
||||||
|
from the staging panel so only failed folders remain to retry.
|
||||||
|
- **Rebuild semantic index** maintenance action in Settings — drops and
|
||||||
|
recreates the vector tables at the current model dimension, then re-queues
|
||||||
|
every image for embedding. Fixes "dimension mismatch" search errors that
|
||||||
|
occur after switching between CLIP models with different output sizes.
|
||||||
|
- **Video playback settings** — new Video Playback group in Settings with two
|
||||||
|
persisted toggles: "Autoplay in lightbox" (default on) and "Start muted"
|
||||||
|
(default off). Settings apply to the next opened video rather than the
|
||||||
|
current one.
|
||||||
- **Timeline scrubber** — a year/month rail on the Timeline view that jumps to
|
- **Timeline scrubber** — a year/month rail on the Timeline view that jumps to
|
||||||
any period in the library. Timeline now loads the full filtered set so the
|
any period in the library. Timeline now loads the full filtered set so the
|
||||||
scrubber spans the whole library instead of just the first page.
|
scrubber spans the whole library instead of just the first page.
|
||||||
@@ -25,6 +38,11 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Settings sections are reordered — General is now the first and default
|
||||||
|
section instead of AI Workspace.
|
||||||
|
- The Duplicate Finder group list is now virtualised — only on-screen cards
|
||||||
|
mount, so large result sets (e.g. 5,000+ pairs) scroll without lag rather
|
||||||
|
than mounting every thumbnail at once.
|
||||||
- The gallery grid is now row-virtualised, so very large libraries scroll
|
- The gallery grid is now row-virtualised, so very large libraries scroll
|
||||||
smoothly and only on-screen thumbnails are rendered.
|
smoothly and only on-screen thumbnails are rendered.
|
||||||
- Polished the new theme surfaces before release, including readable
|
- Polished the new theme surfaces before release, including readable
|
||||||
@@ -35,6 +53,16 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **AVIF thumbnails** — AVIF files are now processed correctly by routing
|
||||||
|
thumbnail generation through the bundled FFmpeg path instead of the Rust
|
||||||
|
image decoder (which has no dav1d dependency). Previously-failed AVIF jobs
|
||||||
|
are requeued on startup; JPEG derivatives are fed to the embedding and
|
||||||
|
tagging pipeline while the lightbox continues to display the original file.
|
||||||
|
- Accent text is now readable in the Subtle Light theme.
|
||||||
|
- Folder picker chevron tooltip now correctly shows "No subfolders" for leaf
|
||||||
|
entries instead of "Open folder" in both branches.
|
||||||
|
- Folder picker Unix breadcrumb root now shows "/" instead of always "Home"
|
||||||
|
for non-home paths such as `/mnt/data`.
|
||||||
- Video embedding jobs are no longer claimed before their thumbnail exists, and
|
- Video embedding jobs are no longer claimed before their thumbnail exists, and
|
||||||
any that previously failed for that reason are requeued on startup — videos no
|
any that previously failed for that reason are requeued on startup — videos no
|
||||||
longer churn through failed embeddings.
|
longer churn through failed embeddings.
|
||||||
@@ -93,4 +121,5 @@ installer with a built-in updater.
|
|||||||
Settings, with live size/reclaimable stats.
|
Settings, with live size/reclaimable stats.
|
||||||
- **Window state** persistence and single-instance handling.
|
- **Window state** persistence and single-instance handling.
|
||||||
|
|
||||||
|
[0.1.1]: https://github.com/JezzWTF/phokus/releases/tag/v0.1.1
|
||||||
[0.1.0]: https://github.com/JezzWTF/phokus/releases/tag/v0.1.0
|
[0.1.0]: https://github.com/JezzWTF/phokus/releases/tag/v0.1.0
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Phokus v0.1.1
|
||||||
|
|
||||||
|
> Draft for the GitHub Release body. Fill in the checksums and trim as needed.
|
||||||
|
|
||||||
|
**Phokus is a local-first desktop media library for Windows** — point it at
|
||||||
|
your image and video folders and it builds a fast, searchable gallery with
|
||||||
|
thumbnails, semantic search, visual discovery, AI tagging, and duplicate
|
||||||
|
cleanup. Everything is processed on your machine; nothing is uploaded.
|
||||||
|
|
||||||
|
This is a quality-of-life release on top of 0.1.0: a new in-app folder
|
||||||
|
picker, themes, smoother scrolling on large libraries, and a batch of fixes.
|
||||||
|
If you're updating from 0.1.0, the built-in updater will fetch this for you.
|
||||||
|
|
||||||
|
## Install / update
|
||||||
|
|
||||||
|
- **Updating from 0.1.0:** the in-app updater will offer 0.1.1 on launch — one
|
||||||
|
click downloads, installs, and relaunches.
|
||||||
|
- **Fresh install:** download `Phokus_0.1.1_x64-setup.exe` below and run it.
|
||||||
|
**Windows SmartScreen will warn** that the publisher is unrecognized — this
|
||||||
|
build is **not code-signed**. Click **More info → Run anyway**.
|
||||||
|
- Requires **Windows 10/11**. WebView2 is fetched automatically if missing.
|
||||||
|
|
||||||
|
NVIDIA users wanting GPU embedding speed can use the
|
||||||
|
`Phokus_0.1.1_x64-cuda-setup.exe` variant instead (larger download; bundles the
|
||||||
|
CUDA runtime DLLs).
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Custom multi-folder picker** — navigate and stage folders from multiple
|
||||||
|
locations and add them in one go, replacing the native OS dialog.
|
||||||
|
- **Themes** — Phokus, Subtle Light, and Conventional Dark chrome, with an
|
||||||
|
inline theme picker in first-run onboarding.
|
||||||
|
- **Timeline scrubber** — a year/month rail to jump anywhere in the library.
|
||||||
|
- **Folder reordering** in the sidebar (drag-and-drop or keyboard), persisted,
|
||||||
|
plus A–Z / Z–A / Custom sort for the Libraries list.
|
||||||
|
- **Video playback settings** — autoplay-in-lightbox and start-muted toggles.
|
||||||
|
- **Rebuild semantic index** maintenance action — fixes "dimension mismatch"
|
||||||
|
search errors after switching CLIP models.
|
||||||
|
- Locate and filter images with failed AI-tagging jobs.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Gallery grid and Duplicate Finder list are now row-virtualised — large
|
||||||
|
libraries and large result sets (5,000+ pairs) scroll without lag.
|
||||||
|
- Settings now open on General by default.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **AVIF thumbnails** now generate correctly (routed through bundled FFmpeg);
|
||||||
|
previously-failed AVIF jobs are requeued on startup.
|
||||||
|
- Video embedding jobs no longer churn through failed states before their
|
||||||
|
thumbnail exists; previously-failed ones are requeued.
|
||||||
|
- Timeline scrolling is smooth on large libraries (per-row virtualisation);
|
||||||
|
background batches no longer re-sort the whole loaded set.
|
||||||
|
- Numerous Subtle Light theme readability/parity fixes.
|
||||||
|
|
||||||
|
See the [changelog](https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md)
|
||||||
|
for the full list.
|
||||||
|
|
||||||
|
## Checksums
|
||||||
|
|
||||||
|
```
|
||||||
|
SHA-256 (Phokus_0.1.1_x64-setup.exe) = <fill in>
|
||||||
|
SHA-256 (Phokus_0.1.1_x64-cuda-setup.exe) = <fill in>
|
||||||
|
```
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "phokus",
|
"name": "phokus",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+1
-1
@@ -4595,7 +4595,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phokus"
|
name = "phokus"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"candle-core",
|
"candle-core",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "phokus"
|
name = "phokus"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
description = "Local-first desktop media library"
|
description = "Local-first desktop media library"
|
||||||
authors = ["JezzWTF"]
|
authors = ["JezzWTF"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -78,9 +78,10 @@ static ORT_RUNTIME_INIT: Mutex<bool> = Mutex::new(false);
|
|||||||
/// knows to drop its cached `FlorenceCaptioner` and reload with the new EP.
|
/// knows to drop its cached `FlorenceCaptioner` and reload with the new EP.
|
||||||
pub static CAPTION_SESSION_DIRTY: AtomicBool = AtomicBool::new(false);
|
pub static CAPTION_SESSION_DIRTY: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum CaptionAcceleration {
|
pub enum CaptionAcceleration {
|
||||||
|
#[default]
|
||||||
Auto,
|
Auto,
|
||||||
Cpu,
|
Cpu,
|
||||||
Directml,
|
Directml,
|
||||||
@@ -96,17 +97,12 @@ impl CaptionAcceleration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CaptionAcceleration {
|
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
fn default() -> Self {
|
|
||||||
Self::Auto
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum CaptionDetail {
|
pub enum CaptionDetail {
|
||||||
Short,
|
Short,
|
||||||
Detailed,
|
Detailed,
|
||||||
|
#[default]
|
||||||
Paragraph,
|
Paragraph,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,12 +132,6 @@ impl CaptionDetail {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CaptionDetail {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Paragraph
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct CaptionModelStatus {
|
pub struct CaptionModelStatus {
|
||||||
pub model_id: &'static str,
|
pub model_id: &'static str,
|
||||||
|
|||||||
+246
-12
@@ -9,7 +9,8 @@ use crate::indexer::{self, WatcherHandle};
|
|||||||
use crate::tagger::{self, TaggerAcceleration, TaggerModelStatus, TaggerRuntimeProbe};
|
use crate::tagger::{self, TaggerAcceleration, TaggerModelStatus, TaggerRuntimeProbe};
|
||||||
use crate::vector;
|
use crate::vector;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::collections::HashSet;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use tauri::{AppHandle, Emitter, Manager, State};
|
use tauri::{AppHandle, Emitter, Manager, State};
|
||||||
|
|
||||||
pub type DbState = DbPool;
|
pub type DbState = DbPool;
|
||||||
@@ -215,19 +216,63 @@ pub struct GetImagesByIdsParams {
|
|||||||
pub image_ids: Vec<i64>,
|
pub image_ids: Vec<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[derive(Serialize)]
|
||||||
pub async fn add_folder(
|
#[serde(tag = "status", content = "data", rename_all = "camelCase")]
|
||||||
|
pub enum FolderAddResult {
|
||||||
|
Added(Folder),
|
||||||
|
Skipped(String),
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AddOutcome {
|
||||||
|
Added(Folder),
|
||||||
|
Skipped(Folder),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paths_match(left: &str, right: &str) -> bool {
|
||||||
|
let left_path = PathBuf::from(left);
|
||||||
|
let right_path = PathBuf::from(right);
|
||||||
|
if let (Ok(left_canonical), Ok(right_canonical)) =
|
||||||
|
(left_path.canonicalize(), right_path.canonicalize())
|
||||||
|
{
|
||||||
|
return left_canonical == right_canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
left.trim_end_matches(['\\', '/'])
|
||||||
|
.eq_ignore_ascii_case(right.trim_end_matches(['\\', '/']))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
left.trim_end_matches('/').eq(right.trim_end_matches('/'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_one_folder(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
db: State<'_, DbState>,
|
db: DbPool,
|
||||||
watcher: State<'_, WatcherHandle>,
|
watcher: WatcherHandle,
|
||||||
path: String,
|
path: String,
|
||||||
) -> Result<Folder, String> {
|
) -> Result<AddOutcome, String> {
|
||||||
let folder_path = PathBuf::from(&path);
|
let folder_path = PathBuf::from(&path);
|
||||||
|
|
||||||
if !folder_path.exists() || !folder_path.is_dir() {
|
if !folder_path.exists() || !folder_path.is_dir() {
|
||||||
return Err("Path is not a valid directory".into());
|
return Err("Path is not a valid directory".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = db.get().map_err(|e| e.to_string())?;
|
||||||
|
if let Some(folder) = db::get_folders(&conn)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.into_iter()
|
||||||
|
.find(|folder| paths_match(&folder.path, &path))
|
||||||
|
{
|
||||||
|
return Ok(AddOutcome::Skipped(folder));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Let the webview load media from this folder via the asset protocol.
|
// Let the webview load media from this folder via the asset protocol.
|
||||||
if let Err(error) = app
|
if let Err(error) = app
|
||||||
.asset_protocol_scope()
|
.asset_protocol_scope()
|
||||||
@@ -257,9 +302,46 @@ pub async fn add_folder(
|
|||||||
.ok_or("Folder not found after insert")?;
|
.ok_or("Folder not found after insert")?;
|
||||||
|
|
||||||
watcher.add_folder(folder_path.clone(), folder_id);
|
watcher.add_folder(folder_path.clone(), folder_id);
|
||||||
indexer::index_folder(app, db.inner().clone(), folder_id, folder_path);
|
indexer::index_folder(app, db, folder_id, folder_path);
|
||||||
|
|
||||||
Ok(folder)
|
Ok(AddOutcome::Added(folder))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn add_folder(
|
||||||
|
app: AppHandle,
|
||||||
|
db: State<'_, DbState>,
|
||||||
|
watcher: State<'_, WatcherHandle>,
|
||||||
|
path: String,
|
||||||
|
) -> Result<Folder, String> {
|
||||||
|
match add_one_folder(app, db.inner().clone(), watcher.inner().clone(), path)? {
|
||||||
|
AddOutcome::Added(folder) => Ok(folder),
|
||||||
|
AddOutcome::Skipped(folder) => Ok(folder),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn add_folders(
|
||||||
|
app: AppHandle,
|
||||||
|
db: State<'_, DbState>,
|
||||||
|
watcher: State<'_, WatcherHandle>,
|
||||||
|
paths: Vec<String>,
|
||||||
|
) -> Result<Vec<FolderAddResult>, String> {
|
||||||
|
Ok(paths
|
||||||
|
.into_iter()
|
||||||
|
.map(|path| {
|
||||||
|
match add_one_folder(
|
||||||
|
app.clone(),
|
||||||
|
db.inner().clone(),
|
||||||
|
watcher.inner().clone(),
|
||||||
|
path,
|
||||||
|
) {
|
||||||
|
Ok(AddOutcome::Added(folder)) => FolderAddResult::Added(folder),
|
||||||
|
Ok(AddOutcome::Skipped(folder)) => FolderAddResult::Skipped(folder.path),
|
||||||
|
Err(error) => FolderAddResult::Error(error),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -268,6 +350,150 @@ pub async fn get_folders(db: State<'_, DbState>) -> Result<Vec<Folder>, String>
|
|||||||
db::get_folders(&conn).map_err(|e| e.to_string())
|
db::get_folders(&conn).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DirListing {
|
||||||
|
pub current: Option<String>,
|
||||||
|
pub parent: Option<String>,
|
||||||
|
pub entries: Vec<DirEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DirEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub has_children: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_to_string(path: &Path) -> String {
|
||||||
|
path.to_string_lossy().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn directory_has_children(path: &Path) -> bool {
|
||||||
|
std::fs::read_dir(path)
|
||||||
|
.map(|mut entries| {
|
||||||
|
entries.any(|entry| {
|
||||||
|
entry
|
||||||
|
.ok()
|
||||||
|
.and_then(|entry| entry.file_type().ok())
|
||||||
|
.map(|file_type| file_type.is_dir())
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn common_root_candidates() -> Vec<PathBuf> {
|
||||||
|
let mut roots = Vec::new();
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
for letter in b'A'..=b'Z' {
|
||||||
|
let drive = format!("{}:\\", letter as char);
|
||||||
|
let path = PathBuf::from(&drive);
|
||||||
|
if path.exists() {
|
||||||
|
roots.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(profile) = std::env::var("USERPROFILE") {
|
||||||
|
let home = PathBuf::from(profile);
|
||||||
|
roots.push(home.clone());
|
||||||
|
for child in ["Pictures", "Videos", "Desktop", "Downloads"] {
|
||||||
|
roots.push(home.join(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
|
roots.push(PathBuf::from(home));
|
||||||
|
}
|
||||||
|
roots.push(PathBuf::from("/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
roots
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_roots() -> Vec<DirEntry> {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let mut entries: Vec<DirEntry> = common_root_candidates()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|path| path.exists() && path.is_dir())
|
||||||
|
.filter_map(|path| {
|
||||||
|
let path_string = path_to_string(&path);
|
||||||
|
if !seen.insert(path_string.clone()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|name| name.to_string_lossy().to_string())
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.unwrap_or_else(|| path_string.clone());
|
||||||
|
Some(DirEntry {
|
||||||
|
name,
|
||||||
|
path: path_string,
|
||||||
|
has_children: directory_has_children(&path),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
entries.sort_by_key(|entry| entry.name.to_lowercase());
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_child_directories(path: &Path) -> Result<Vec<DirEntry>, String> {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
let read_dir = std::fs::read_dir(path).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
for entry in read_dir.flatten() {
|
||||||
|
let file_name = entry.file_name();
|
||||||
|
let name = file_name.to_string_lossy().to_string();
|
||||||
|
if name.starts_with('.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let child_path = entry.path();
|
||||||
|
let is_dir = entry
|
||||||
|
.file_type()
|
||||||
|
.map(|file_type| file_type.is_dir())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !is_dir {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push(DirEntry {
|
||||||
|
name,
|
||||||
|
path: path_to_string(&child_path),
|
||||||
|
has_children: directory_has_children(&child_path),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort_by_key(|entry| entry.name.to_lowercase());
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_directories(path: Option<String>) -> Result<DirListing, String> {
|
||||||
|
let trimmed = path.as_deref().map(str::trim).unwrap_or("");
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(DirListing {
|
||||||
|
current: None,
|
||||||
|
parent: None,
|
||||||
|
entries: list_roots(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_path = PathBuf::from(trimmed);
|
||||||
|
let parent = current_path.parent().map(path_to_string);
|
||||||
|
let entries = list_child_directories(¤t_path)?;
|
||||||
|
|
||||||
|
Ok(DirListing {
|
||||||
|
current: Some(path_to_string(¤t_path)),
|
||||||
|
parent,
|
||||||
|
entries,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ReorderFoldersParams {
|
pub struct ReorderFoldersParams {
|
||||||
pub folder_ids: Vec<i64>,
|
pub folder_ids: Vec<i64>,
|
||||||
@@ -1126,7 +1352,7 @@ pub async fn find_duplicates(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let done = stat_counter.fetch_add(1, Ordering::Relaxed) + 1;
|
let done = stat_counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
if done % 100 == 0 || done == total {
|
if done.is_multiple_of(100) || done == total {
|
||||||
let _ = app_hash.emit(
|
let _ = app_hash.emit(
|
||||||
"duplicate_scan_progress",
|
"duplicate_scan_progress",
|
||||||
DuplicateScanProgress {
|
DuplicateScanProgress {
|
||||||
@@ -1187,7 +1413,7 @@ pub async fn find_duplicates(
|
|||||||
hash_skipped.fetch_add(1, Ordering::Relaxed);
|
hash_skipped.fetch_add(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
let done = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
let done = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
if done % 100 == 0 || done == c_total {
|
if done.is_multiple_of(100) || done == c_total {
|
||||||
let _ = app_hash.emit(
|
let _ = app_hash.emit(
|
||||||
"duplicate_scan_progress",
|
"duplicate_scan_progress",
|
||||||
DuplicateScanProgress {
|
DuplicateScanProgress {
|
||||||
@@ -1276,7 +1502,7 @@ pub async fn find_duplicates(
|
|||||||
confirm_skipped.fetch_add(1, Ordering::Relaxed);
|
confirm_skipped.fetch_add(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
let done = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
let done = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
if done % 100 == 0 || done == confirming_total {
|
if done.is_multiple_of(100) || done == confirming_total {
|
||||||
let _ = app_confirm.emit(
|
let _ = app_confirm.emit(
|
||||||
"duplicate_scan_progress",
|
"duplicate_scan_progress",
|
||||||
DuplicateScanProgress {
|
DuplicateScanProgress {
|
||||||
@@ -1329,7 +1555,7 @@ pub async fn find_duplicates(
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Largest duplicates first — wastes the most space
|
// Largest duplicates first — wastes the most space
|
||||||
groups.sort_by(|a, b| b.file_size.cmp(&a.file_size));
|
groups.sort_by_key(|group| std::cmp::Reverse(group.file_size));
|
||||||
|
|
||||||
// Persist results so they survive restart — best-effort, ignore errors.
|
// Persist results so they survive restart — best-effort, ignore errors.
|
||||||
let folder_scope = match folder_id {
|
let folder_scope = match folder_id {
|
||||||
@@ -1987,6 +2213,14 @@ pub async fn vacuum_database(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn rebuild_semantic_index(db: State<'_, DbState>) -> Result<usize, String> {
|
||||||
|
let conn = db.get().map_err(|e| e.to_string())?;
|
||||||
|
// Serialize against the embedding worker (which writes under the same lock)
|
||||||
|
// so the rebuild can't interleave with an in-flight embedding batch.
|
||||||
|
indexer::with_db_write_lock(|| db::reset_all_embeddings(&conn)).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
pub struct OrphanedThumbnailsInfo {
|
pub struct OrphanedThumbnailsInfo {
|
||||||
pub count: u64,
|
pub count: u64,
|
||||||
|
|||||||
+90
-7
@@ -114,6 +114,7 @@ pub struct TaggingJob {
|
|||||||
pub image_id: i64,
|
pub image_id: i64,
|
||||||
pub folder_id: i64,
|
pub folder_id: i64,
|
||||||
pub path: String,
|
pub path: String,
|
||||||
|
pub thumbnail_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -496,6 +497,41 @@ pub fn repair_embedding_consistency(conn: &Connection) -> Result<(usize, usize)>
|
|||||||
Ok((orphaned_vectors, missing_vector_ids.len()))
|
Ok((orphaned_vectors, missing_vector_ids.len()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Full semantic-index rebuild: recreate the vector tables at the current model
|
||||||
|
/// dimension and re-queue every image for embedding. Used by the maintenance
|
||||||
|
/// action when the index is stale or its dimension no longer matches the model
|
||||||
|
/// (which otherwise surfaces as a "dimension mismatch" search error). Returns the
|
||||||
|
/// number of images re-queued.
|
||||||
|
pub fn reset_all_embeddings(conn: &Connection) -> Result<usize> {
|
||||||
|
// Recreate the vector tables at the current model dimension first (DDL, kept
|
||||||
|
// out of the transaction below). The caller holds the DB write lock, so the
|
||||||
|
// embedding worker can't interleave a write between this and the queue reset.
|
||||||
|
vector::rebuild_tables(conn)?;
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE images
|
||||||
|
SET embedding_status = 'pending',
|
||||||
|
embedding_model = NULL,
|
||||||
|
embedding_updated_at = NULL,
|
||||||
|
embedding_error = NULL",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tx.execute("DELETE FROM embedding_jobs", [])?;
|
||||||
|
let queued = tx.execute(
|
||||||
|
"INSERT INTO embedding_jobs (image_id, status, attempts, last_error, created_at, updated_at)
|
||||||
|
SELECT id, 'pending', 0, NULL, datetime('now'), datetime('now')
|
||||||
|
FROM images",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO app_kv (key, value) VALUES ('embedding_revision', 1)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = value + 1",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(queued)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn retry_failed_embedding_jobs(conn: &Connection, folder_id: i64) -> Result<usize> {
|
pub fn retry_failed_embedding_jobs(conn: &Connection, folder_id: i64) -> Result<usize> {
|
||||||
// Only re-queue images that are actually embeddable right now.
|
// Only re-queue images that are actually embeddable right now.
|
||||||
// Videos without a thumbnail would just fail again immediately, so skip them —
|
// Videos without a thumbnail would just fail again immediately, so skip them —
|
||||||
@@ -1167,14 +1203,14 @@ fn get_pending_thumbnail_jobs_excluding(
|
|||||||
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Video jobs need FFmpeg; while it isn't provisioned they must be invisible
|
/// Video and AVIF thumbnail jobs need FFmpeg; while it isn't provisioned they
|
||||||
/// to both claiming and the tier-priority checks, or pending video jobs would
|
/// must be invisible to both claiming and tier-priority checks, or pending
|
||||||
/// stall every lower tier indefinitely.
|
/// FFmpeg-backed jobs would stall every lower tier indefinitely.
|
||||||
fn media_kind_clause(include_videos: bool) -> &'static str {
|
fn media_kind_clause(include_videos: bool) -> &'static str {
|
||||||
if include_videos {
|
if include_videos {
|
||||||
""
|
""
|
||||||
} else {
|
} else {
|
||||||
"AND i.media_kind = 'image'"
|
"AND i.media_kind = 'image' AND lower(i.path) NOT GLOB '*.avif'"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1558,7 +1594,7 @@ pub fn repair_deferred_embedding_jobs(conn: &Connection) -> Result<usize> {
|
|||||||
WHERE status = 'failed'
|
WHERE status = 'failed'
|
||||||
AND last_error LIKE ?1
|
AND last_error LIKE ?1
|
||||||
AND image_id IN (
|
AND image_id IN (
|
||||||
SELECT id FROM images WHERE media_kind = 'video'
|
SELECT id FROM images WHERE media_kind = 'video' OR lower(path) GLOB '*.avif'
|
||||||
)",
|
)",
|
||||||
[pattern],
|
[pattern],
|
||||||
)?;
|
)?;
|
||||||
@@ -1567,12 +1603,56 @@ pub fn repair_deferred_embedding_jobs(conn: &Connection) -> Result<usize> {
|
|||||||
SET embedding_status = 'pending', embedding_error = NULL
|
SET embedding_status = 'pending', embedding_error = NULL
|
||||||
WHERE embedding_status = 'failed'
|
WHERE embedding_status = 'failed'
|
||||||
AND embedding_error LIKE ?1
|
AND embedding_error LIKE ?1
|
||||||
AND media_kind = 'video'",
|
AND (media_kind = 'video' OR lower(path) GLOB '*.avif')",
|
||||||
[pattern],
|
[pattern],
|
||||||
)?;
|
)?;
|
||||||
Ok(repaired)
|
Ok(repaired)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn repair_avif_jobs(conn: &Connection) -> Result<usize> {
|
||||||
|
let unsupported_pattern = "%Avif%not supported%";
|
||||||
|
let thumbnail_repaired = conn.execute(
|
||||||
|
"UPDATE thumbnail_jobs
|
||||||
|
SET status = 'pending', last_error = NULL, updated_at = datetime('now')
|
||||||
|
WHERE status = 'failed'
|
||||||
|
AND image_id IN (SELECT id FROM images WHERE lower(path) GLOB '*.avif')
|
||||||
|
AND last_error LIKE ?1",
|
||||||
|
[unsupported_pattern],
|
||||||
|
)?;
|
||||||
|
let embedding_repaired = conn.execute(
|
||||||
|
"UPDATE embedding_jobs
|
||||||
|
SET status = 'pending', last_error = NULL, updated_at = datetime('now')
|
||||||
|
WHERE status = 'failed'
|
||||||
|
AND image_id IN (SELECT id FROM images WHERE lower(path) GLOB '*.avif')
|
||||||
|
AND last_error LIKE ?1",
|
||||||
|
[unsupported_pattern],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE images
|
||||||
|
SET embedding_status = 'pending', embedding_error = NULL
|
||||||
|
WHERE embedding_status = 'failed'
|
||||||
|
AND lower(path) GLOB '*.avif'
|
||||||
|
AND embedding_error LIKE ?1",
|
||||||
|
[unsupported_pattern],
|
||||||
|
)?;
|
||||||
|
let tagging_repaired = conn.execute(
|
||||||
|
"UPDATE tagging_jobs
|
||||||
|
SET status = 'pending', last_error = NULL, updated_at = datetime('now')
|
||||||
|
WHERE status = 'failed'
|
||||||
|
AND image_id IN (SELECT id FROM images WHERE lower(path) GLOB '*.avif')
|
||||||
|
AND last_error LIKE ?1",
|
||||||
|
[unsupported_pattern],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE images
|
||||||
|
SET ai_tagger_error = NULL
|
||||||
|
WHERE lower(path) GLOB '*.avif'
|
||||||
|
AND ai_tagger_error LIKE ?1",
|
||||||
|
[unsupported_pattern],
|
||||||
|
)?;
|
||||||
|
Ok(thumbnail_repaired + embedding_repaired + tagging_repaired)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn rename_folder(conn: &Connection, folder_id: i64, new_name: &str) -> Result<()> {
|
pub fn rename_folder(conn: &Connection, folder_id: i64, new_name: &str) -> Result<()> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE folders SET name = ?2 WHERE id = ?1",
|
"UPDATE folders SET name = ?2 WHERE id = ?1",
|
||||||
@@ -1692,6 +1772,7 @@ pub fn get_images(
|
|||||||
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn count_images(
|
pub fn count_images(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
folder_id: Option<i64>,
|
folder_id: Option<i64>,
|
||||||
@@ -2066,10 +2147,11 @@ fn get_pending_tagging_jobs_excluding(
|
|||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<TaggingJob>> {
|
) -> Result<Vec<TaggingJob>> {
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT j.image_id, i.folder_id, i.path
|
"SELECT j.image_id, i.folder_id, i.path, i.thumbnail_path
|
||||||
FROM tagging_jobs j
|
FROM tagging_jobs j
|
||||||
JOIN images i ON i.id = j.image_id
|
JOIN images i ON i.id = j.image_id
|
||||||
WHERE j.status = 'pending'
|
WHERE j.status = 'pending'
|
||||||
|
AND NOT (lower(i.path) GLOB '*.avif' AND i.thumbnail_path IS NULL)
|
||||||
{}
|
{}
|
||||||
ORDER BY j.created_at ASC
|
ORDER BY j.created_at ASC
|
||||||
LIMIT ?1",
|
LIMIT ?1",
|
||||||
@@ -2082,6 +2164,7 @@ fn get_pending_tagging_jobs_excluding(
|
|||||||
image_id: row.get(0)?,
|
image_id: row.get(0)?,
|
||||||
folder_id: row.get(1)?,
|
folder_id: row.get(1)?,
|
||||||
path: row.get(2)?,
|
path: row.get(2)?,
|
||||||
|
thumbnail_path: row.get(3)?,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
|||||||
@@ -220,19 +220,19 @@ fn load_images(paths: &[PathBuf], image_size: usize) -> Result<Tensor> {
|
|||||||
|
|
||||||
/// Returns the path that should be fed to the CLIP image embedder for a given media file.
|
/// Returns the path that should be fed to the CLIP image embedder for a given media file.
|
||||||
///
|
///
|
||||||
/// For videos the thumbnail image is used (because CLIP only understands still images).
|
/// For videos and AVIFs the thumbnail image is used because CLIP preprocessing
|
||||||
/// Video jobs without thumbnails are excluded when jobs are claimed. The error remains
|
/// only uses decoders from the `image` crate, while AVIF is decoded through
|
||||||
/// as a guard against a race where a thumbnail disappears after a job is claimed.
|
/// FFmpeg into a JPEG thumbnail.
|
||||||
pub fn embedding_source_path(
|
pub fn embedding_source_path(
|
||||||
path: &str,
|
path: &str,
|
||||||
thumbnail_path: Option<&str>,
|
thumbnail_path: Option<&str>,
|
||||||
media_kind: &str,
|
media_kind: &str,
|
||||||
) -> Result<PathBuf> {
|
) -> Result<PathBuf> {
|
||||||
if media_kind == "video" {
|
if media_kind == "video" || is_avif_path(path) {
|
||||||
match thumbnail_path {
|
match thumbnail_path {
|
||||||
Some(thumb) => Ok(PathBuf::from(thumb)),
|
Some(thumb) => Ok(PathBuf::from(thumb)),
|
||||||
None => Err(anyhow::anyhow!(
|
None => Err(anyhow::anyhow!(
|
||||||
"No thumbnail available yet for video '{}' — embedding deferred until thumbnail is generated",
|
"No thumbnail available yet for '{}' — embedding deferred until thumbnail is generated",
|
||||||
std::path::Path::new(path)
|
std::path::Path::new(path)
|
||||||
.file_name()
|
.file_name()
|
||||||
.map(|n| n.to_string_lossy())
|
.map(|n| n.to_string_lossy())
|
||||||
@@ -243,3 +243,10 @@ pub fn embedding_source_path(
|
|||||||
Ok(PathBuf::from(path))
|
Ok(PathBuf::from(path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_avif_path(path: &str) -> bool {
|
||||||
|
std::path::Path::new(path)
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("avif"))
|
||||||
|
}
|
||||||
|
|||||||
+30
-10
@@ -663,10 +663,13 @@ fn process_thumbnail_batch(
|
|||||||
|
|
||||||
let (image_jobs, video_jobs): (Vec<_>, Vec<_>) =
|
let (image_jobs, video_jobs): (Vec<_>, Vec<_>) =
|
||||||
jobs.into_iter().partition(|job| job.media_kind == "image");
|
jobs.into_iter().partition(|job| job.media_kind == "image");
|
||||||
|
let (avif_jobs, raster_jobs): (Vec<_>, Vec<_>) = image_jobs
|
||||||
|
.into_iter()
|
||||||
|
.partition(|job| is_avif_path(Path::new(&job.path)));
|
||||||
|
|
||||||
// Images: parallel decode, committed as one batch.
|
// Images: parallel decode, committed as one batch.
|
||||||
if !image_jobs.is_empty() {
|
if !raster_jobs.is_empty() {
|
||||||
let results = image_jobs
|
let results = raster_jobs
|
||||||
.par_iter()
|
.par_iter()
|
||||||
.map(|job| {
|
.map(|job| {
|
||||||
(
|
(
|
||||||
@@ -678,6 +681,15 @@ fn process_thumbnail_batch(
|
|||||||
persist_thumbnail_results(app, pool, results)?;
|
persist_thumbnail_results(app, pool, results)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AVIF: FFmpeg-backed decode, like videos. Keep this off the shared rayon
|
||||||
|
// pool because each subprocess blocks its worker thread.
|
||||||
|
for job in &avif_jobs {
|
||||||
|
let result =
|
||||||
|
thumbnail::generate_avif_thumbnail(media_tools, Path::new(&job.path), cache_dir)
|
||||||
|
.map(Some);
|
||||||
|
persist_thumbnail_results(app, pool, vec![(job.image_id, result)])?;
|
||||||
|
}
|
||||||
|
|
||||||
// Videos: sequential, off the rayon pool — each ffmpeg call blocks its
|
// Videos: sequential, off the rayon pool — each ffmpeg call blocks its
|
||||||
// thread, and a video-heavy batch on the shared pool would starve image
|
// thread, and a video-heavy batch on the shared pool would starve image
|
||||||
// decoding across all workers. Committed per item so progress keeps
|
// decoding across all workers. Committed per item so progress keeps
|
||||||
@@ -749,6 +761,12 @@ fn persist_thumbnail_results(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_avif_path(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("avif"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns `Ok(true)` if a batch was claimed and processed, `Ok(false)` if
|
/// Returns `Ok(true)` if a batch was claimed and processed, `Ok(false)` if
|
||||||
/// the queue was empty.
|
/// the queue was empty.
|
||||||
fn process_metadata_batch(
|
fn process_metadata_batch(
|
||||||
@@ -885,7 +903,7 @@ fn process_embedding_batch(
|
|||||||
// image_id -> early error message for jobs that cannot be embedded yet
|
// image_id -> early error message for jobs that cannot be embedded yet
|
||||||
let mut pre_failed: HashMap<i64, String> = HashMap::new();
|
let mut pre_failed: HashMap<i64, String> = HashMap::new();
|
||||||
|
|
||||||
for (i, (job, result)) in jobs.iter().zip(source_results.into_iter()).enumerate() {
|
for (i, (job, result)) in jobs.iter().zip(source_results).enumerate() {
|
||||||
match result {
|
match result {
|
||||||
Ok(path) => {
|
Ok(path) => {
|
||||||
embeddable_indices.push(i);
|
embeddable_indices.push(i);
|
||||||
@@ -907,16 +925,13 @@ fn process_embedding_batch(
|
|||||||
|
|
||||||
match embedder.embed_images(&embeddable_paths) {
|
match embedder.embed_images(&embeddable_paths) {
|
||||||
Ok(embeddings) => {
|
Ok(embeddings) => {
|
||||||
for (job, embedding) in embeddable_jobs.iter().zip(embeddings.into_iter()) {
|
for (job, embedding) in embeddable_jobs.iter().zip(embeddings) {
|
||||||
embed_results.insert(job.image_id, Ok(embedding));
|
embed_results.insert(job.image_id, Ok(embedding));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(batch_error) => {
|
Err(batch_error) => {
|
||||||
log::error!("Embedding batch fallback to per-image mode: {batch_error}");
|
log::error!("Embedding batch fallback to per-image mode: {batch_error}");
|
||||||
for (job, source_path) in embeddable_jobs
|
for (job, source_path) in embeddable_jobs.into_iter().zip(embeddable_paths) {
|
||||||
.into_iter()
|
|
||||||
.zip(embeddable_paths.into_iter())
|
|
||||||
{
|
|
||||||
embed_results.insert(job.image_id, embedder.embed_image(&source_path));
|
embed_results.insert(job.image_id, embedder.embed_image(&source_path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1145,9 +1160,14 @@ fn process_tagging_batch(
|
|||||||
let tag_results = jobs
|
let tag_results = jobs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|job| {
|
.map(|job| {
|
||||||
|
let source_path = if is_avif_path(Path::new(&job.path)) {
|
||||||
|
job.thumbnail_path.as_deref().unwrap_or(&job.path)
|
||||||
|
} else {
|
||||||
|
&job.path
|
||||||
|
};
|
||||||
(
|
(
|
||||||
job.clone(),
|
job.clone(),
|
||||||
tagger_ref.run(Path::new(&job.path), tagger::DEFAULT_MAX_TAGS),
|
tagger_ref.run(Path::new(source_path), tagger::DEFAULT_MAX_TAGS),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -1288,7 +1308,7 @@ fn max_worker_fetch_size(active_folders: &HashSet<i64>) -> usize {
|
|||||||
.unwrap_or(StorageProfile::Balanced.worker_fetch_size())
|
.unwrap_or(StorageProfile::Balanced.worker_fetch_size())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with_db_write_lock<T>(operation: impl FnOnce() -> Result<T>) -> Result<T> {
|
pub fn with_db_write_lock<T>(operation: impl FnOnce() -> Result<T>) -> Result<T> {
|
||||||
let lock = DB_WRITE_LOCK.get_or_init(|| Mutex::new(()));
|
let lock = DB_WRITE_LOCK.get_or_init(|| Mutex::new(()));
|
||||||
let _guard = lock.lock().unwrap();
|
let _guard = lock.lock().unwrap();
|
||||||
operation()
|
operation()
|
||||||
|
|||||||
+10
-2
@@ -53,7 +53,7 @@ pub fn run() {
|
|||||||
std::fs::create_dir_all(&app_dir).expect("Failed to create app data dir");
|
std::fs::create_dir_all(&app_dir).expect("Failed to create app data dir");
|
||||||
|
|
||||||
// FFmpeg provisioning happens in the background so the window
|
// FFmpeg provisioning happens in the background so the window
|
||||||
// appears immediately; workers gate video jobs on readiness and
|
// appears immediately; workers gate video/AVIF jobs on readiness and
|
||||||
// the onboarding/Settings UI shows progress and retry.
|
// the onboarding/Settings UI shows progress and retry.
|
||||||
media::spawn_ffmpeg_provision(app.handle().clone());
|
media::spawn_ffmpeg_provision(app.handle().clone());
|
||||||
|
|
||||||
@@ -68,7 +68,12 @@ pub fn run() {
|
|||||||
let repaired_deferred = db::repair_deferred_embedding_jobs(&conn)
|
let repaired_deferred = db::repair_deferred_embedding_jobs(&conn)
|
||||||
.expect("Failed to repair deferred embedding jobs");
|
.expect("Failed to repair deferred embedding jobs");
|
||||||
if repaired_deferred > 0 {
|
if repaired_deferred > 0 {
|
||||||
log::info!("Requeued {repaired_deferred} deferred video embedding jobs.");
|
log::info!("Requeued {repaired_deferred} deferred embedding jobs.");
|
||||||
|
}
|
||||||
|
let repaired_avif =
|
||||||
|
db::repair_avif_jobs(&conn).expect("Failed to repair AVIF jobs");
|
||||||
|
if repaired_avif > 0 {
|
||||||
|
log::info!("Requeued {repaired_avif} AVIF jobs.");
|
||||||
}
|
}
|
||||||
let backfilled =
|
let backfilled =
|
||||||
db::backfill_embedding_jobs(&conn).expect("Failed to backfill embedding jobs");
|
db::backfill_embedding_jobs(&conn).expect("Failed to backfill embedding jobs");
|
||||||
@@ -128,6 +133,8 @@ pub fn run() {
|
|||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::add_folder,
|
commands::add_folder,
|
||||||
|
commands::add_folders,
|
||||||
|
commands::list_directories,
|
||||||
commands::get_folders,
|
commands::get_folders,
|
||||||
commands::reorder_folders,
|
commands::reorder_folders,
|
||||||
commands::get_background_job_progress,
|
commands::get_background_job_progress,
|
||||||
@@ -192,6 +199,7 @@ pub fn run() {
|
|||||||
commands::open_app_data_folder,
|
commands::open_app_data_folder,
|
||||||
commands::get_database_info,
|
commands::get_database_info,
|
||||||
commands::vacuum_database,
|
commands::vacuum_database,
|
||||||
|
commands::rebuild_semantic_index,
|
||||||
commands::get_orphaned_thumbnails_info,
|
commands::get_orphaned_thumbnails_info,
|
||||||
commands::cleanup_orphaned_thumbnails,
|
commands::cleanup_orphaned_thumbnails,
|
||||||
commands::get_muted_folder_ids,
|
commands::get_muted_folder_ids,
|
||||||
|
|||||||
@@ -45,9 +45,10 @@ pub static TAGGER_SESSION_DIRTY: AtomicBool = AtomicBool::new(false);
|
|||||||
// Settings types
|
// Settings types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum TaggerAcceleration {
|
pub enum TaggerAcceleration {
|
||||||
|
#[default]
|
||||||
Auto,
|
Auto,
|
||||||
Cpu,
|
Cpu,
|
||||||
Directml,
|
Directml,
|
||||||
@@ -63,12 +64,6 @@ impl TaggerAcceleration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TaggerAcceleration {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Auto
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Status / probe types exposed to the frontend
|
// Status / probe types exposed to the frontend
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -241,6 +241,90 @@ pub fn generate_video_thumbnail(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn generate_avif_thumbnail(
|
||||||
|
tools: &MediaTools,
|
||||||
|
image_path: &Path,
|
||||||
|
cache_dir: &Path,
|
||||||
|
) -> Result<GeneratedThumbnail> {
|
||||||
|
let path_str = image_path.to_string_lossy();
|
||||||
|
let out_path = thumb_path(cache_dir, &path_str);
|
||||||
|
let original_dimensions = ffprobe_dimensions(tools, image_path);
|
||||||
|
|
||||||
|
if out_path.exists() {
|
||||||
|
return Ok(GeneratedThumbnail {
|
||||||
|
path: out_path,
|
||||||
|
width: original_dimensions.0,
|
||||||
|
height: original_dimensions.1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parent) = out_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let output_path = out_path.to_string_lossy().into_owned();
|
||||||
|
let output = tools
|
||||||
|
.ffmpeg_command()
|
||||||
|
.args([
|
||||||
|
"-y",
|
||||||
|
"-threads",
|
||||||
|
"2",
|
||||||
|
"-i",
|
||||||
|
path_str.as_ref(),
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-vf",
|
||||||
|
"scale=320:-1:force_original_aspect_ratio=decrease",
|
||||||
|
"-q:v",
|
||||||
|
"4",
|
||||||
|
&output_path,
|
||||||
|
])
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
if output.status.success() && out_path.exists() {
|
||||||
|
return Ok(GeneratedThumbnail {
|
||||||
|
path: out_path,
|
||||||
|
width: original_dimensions.0,
|
||||||
|
height: original_dimensions.1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow!(
|
||||||
|
"ffmpeg failed generating AVIF thumbnail for {path_str}: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ffprobe_dimensions(tools: &MediaTools, image_path: &Path) -> (Option<i64>, Option<i64>) {
|
||||||
|
let output = tools
|
||||||
|
.ffprobe_command()
|
||||||
|
.args([
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-select_streams",
|
||||||
|
"v:0",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=width,height",
|
||||||
|
"-of",
|
||||||
|
"csv=p=0:s=x",
|
||||||
|
&image_path.to_string_lossy(),
|
||||||
|
])
|
||||||
|
.output();
|
||||||
|
let Ok(output) = output else {
|
||||||
|
return (None, None);
|
||||||
|
};
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return (None, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let mut parts = stdout.trim().split('x');
|
||||||
|
let width = parts.next().and_then(|part| part.parse::<i64>().ok());
|
||||||
|
let height = parts.next().and_then(|part| part.parse::<i64>().ok());
|
||||||
|
(width, height)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn thumb_path(cache_dir: &Path, image_path: &str) -> PathBuf {
|
pub fn thumb_path(cache_dir: &Path, image_path: &str) -> PathBuf {
|
||||||
thumb_path_with_ext(cache_dir, image_path, "jpg")
|
thumb_path_with_ext(cache_dir, image_path, "jpg")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ pub fn migrate(conn: &Connection) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop and recreate the vector tables at the current `CLIP_VECTOR_DIM`. Used by
|
||||||
|
/// the "Rebuild semantic index" maintenance action when stored vectors no longer
|
||||||
|
/// match the active model's dimension (e.g. after switching embedding models),
|
||||||
|
/// so the columns are rebuilt to the right size before embeddings regenerate.
|
||||||
|
pub fn rebuild_tables(conn: &Connection) -> Result<()> {
|
||||||
|
conn.execute_batch(
|
||||||
|
"DROP TABLE IF EXISTS image_vec;
|
||||||
|
DROP TABLE IF EXISTS caption_vec;",
|
||||||
|
)?;
|
||||||
|
migrate(conn)
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn delete_embedding(conn: &Connection, image_id: i64) -> Result<()> {
|
pub fn delete_embedding(conn: &Connection, image_id: i64) -> Result<()> {
|
||||||
conn.execute("DELETE FROM image_vec WHERE image_id = ?1", [image_id])?;
|
conn.execute("DELETE FROM image_vec WHERE image_id = ?1", [image_id])?;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { DuplicateFinder } from "./components/DuplicateFinder";
|
|||||||
import { Timeline } from "./components/Timeline";
|
import { Timeline } from "./components/Timeline";
|
||||||
import { TitleBar } from "./components/TitleBar";
|
import { TitleBar } from "./components/TitleBar";
|
||||||
import { SettingsModal } from "./components/SettingsModal";
|
import { SettingsModal } from "./components/SettingsModal";
|
||||||
|
import { FolderPickerModal } from "./components/FolderPickerModal";
|
||||||
import { UpdateToast } from "./components/UpdateToast";
|
import { UpdateToast } from "./components/UpdateToast";
|
||||||
import { OnboardingOverlay } from "./components/onboarding/OnboardingOverlay";
|
import { OnboardingOverlay } from "./components/onboarding/OnboardingOverlay";
|
||||||
import { DemoPanel } from "./components/DemoPanel";
|
import { DemoPanel } from "./components/DemoPanel";
|
||||||
@@ -93,6 +94,7 @@ export default function App() {
|
|||||||
|
|
||||||
<Lightbox />
|
<Lightbox />
|
||||||
<SettingsModal />
|
<SettingsModal />
|
||||||
|
<FolderPickerModal />
|
||||||
<UpdateToast />
|
<UpdateToast />
|
||||||
<OnboardingOverlay />
|
<OnboardingOverlay />
|
||||||
{import.meta.env.DEV && <DemoPanel />}
|
{import.meta.env.DEV && <DemoPanel />}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
import { DuplicateGroup, useGalleryStore } from "../store";
|
import { DuplicateGroup, useGalleryStore } from "../store";
|
||||||
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
||||||
@@ -130,6 +131,17 @@ export function DuplicateFinder() {
|
|||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [deleteResult, setDeleteResult] = useState<string | null>(null);
|
const [deleteResult, setDeleteResult] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Virtualize the group list so a large result set (e.g. thousands of pairs)
|
||||||
|
// only mounts the on-screen cards. Group cards vary in height (number of
|
||||||
|
// copies wraps across rows), so heights are measured dynamically.
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: duplicateGroups.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => 220,
|
||||||
|
overscan: 4,
|
||||||
|
});
|
||||||
|
|
||||||
const selectedCount = duplicateSelectedIds.size;
|
const selectedCount = duplicateSelectedIds.size;
|
||||||
const hasResults = duplicateGroups.length > 0;
|
const hasResults = duplicateGroups.length > 0;
|
||||||
const hasScanned = hasResults || duplicateLastScanned !== null || (!duplicateScanning && duplicateScanProgress !== null);
|
const hasScanned = hasResults || duplicateLastScanned !== null || (!duplicateScanning && duplicateScanProgress !== null);
|
||||||
@@ -277,11 +289,29 @@ export function DuplicateFinder() {
|
|||||||
<p className="text-sm text-white/25">No duplicate files found.</p>
|
<p className="text-sm text-white/25">No duplicate files found.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-y-auto px-6 py-5">
|
<div ref={scrollRef} className="overflow-y-auto px-6 py-5">
|
||||||
<div className="space-y-4">
|
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||||
{duplicateGroups.map((group) => (
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
<DuplicateGroupCard key={group.file_hash} group={group} />
|
const group = duplicateGroups[virtualItem.index];
|
||||||
))}
|
if (!group) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.file_hash}
|
||||||
|
data-index={virtualItem.index}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
transform: `translateY(${virtualItem.start}px)`,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DuplicateGroupCard group={group} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,449 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
import { DirEntry, DirListing, FolderAddResult, useGalleryStore } from "../store";
|
||||||
|
|
||||||
|
function normalizePath(path: string): string {
|
||||||
|
return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderName(path: string): string {
|
||||||
|
const trimmed = path.replace(/[\\/]+$/, "");
|
||||||
|
const parts = trimmed.split(/[\\/]+/).filter(Boolean);
|
||||||
|
return parts.length > 0 ? parts[parts.length - 1] : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBreadcrumbs(path: string | null): { label: string; path: string | null }[] {
|
||||||
|
if (!path) return [{ label: "This PC / Home", path: null }];
|
||||||
|
|
||||||
|
const separator = path.includes("\\") ? "\\" : "/";
|
||||||
|
const normalized = path.replace(/[\\/]+$/, "");
|
||||||
|
const windowsDrive = normalized.match(/^[A-Za-z]:/);
|
||||||
|
|
||||||
|
if (windowsDrive) {
|
||||||
|
const drive = windowsDrive[0] + "\\";
|
||||||
|
const rest = normalized.slice(2).split(/[\\/]+/).filter(Boolean);
|
||||||
|
let current = drive;
|
||||||
|
return [
|
||||||
|
{ label: "This PC", path: null },
|
||||||
|
{ label: drive, path: drive },
|
||||||
|
...rest.map((part) => {
|
||||||
|
current = current.endsWith("\\") ? `${current}${part}` : `${current}\\${part}`;
|
||||||
|
return { label: part, path: current };
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = normalized.split(/[\\/]+/).filter(Boolean);
|
||||||
|
let current = separator === "/" ? "" : "";
|
||||||
|
return [
|
||||||
|
{ label: "/", path: null },
|
||||||
|
...parts.map((part) => {
|
||||||
|
current = `${current}/${part}`;
|
||||||
|
return { label: part, path: current };
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusLine({ results }: { results: FolderAddResult[] | null }) {
|
||||||
|
if (!results) return null;
|
||||||
|
const added = results.filter((result) => result.status === "added").length;
|
||||||
|
const skipped = results.filter((result) => result.status === "skipped").length;
|
||||||
|
const failed = results.filter((result) => result.status === "error").length;
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-gray-500 light-theme:text-gray-600">
|
||||||
|
Added {added}, skipped {skipped}, failed {failed}.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderRow({
|
||||||
|
entry,
|
||||||
|
selected,
|
||||||
|
alreadyAdded,
|
||||||
|
onToggle,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
entry: DirEntry;
|
||||||
|
selected: boolean;
|
||||||
|
alreadyAdded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onNavigate: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group flex h-11 items-center gap-3 rounded-md border px-3 transition-colors ${
|
||||||
|
alreadyAdded
|
||||||
|
? "border-transparent bg-white/[0.025] text-gray-600 light-theme:bg-gray-900 light-theme:text-gray-500"
|
||||||
|
: selected
|
||||||
|
? "border-white/15 bg-white/[0.085] text-white shadow-[inset_0_0_0_1px_rgb(255_255_255_/_0.035)] light-theme:border-gray-700/45 light-theme:bg-gray-800/55 light-theme:text-white"
|
||||||
|
: "border-transparent text-gray-300 hover:bg-white/[0.055] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`grid h-5 w-5 shrink-0 place-items-center rounded border transition-colors ${
|
||||||
|
selected
|
||||||
|
? "border-white/30 bg-gray-200 text-gray-950 light-theme:border-gray-700/55 light-theme:bg-gray-700 light-theme:text-white"
|
||||||
|
: "border-white/15 bg-white/[0.035] text-transparent hover:border-white/30 light-theme:border-gray-700/50 light-theme:bg-gray-950"
|
||||||
|
} ${alreadyAdded ? "cursor-not-allowed opacity-40" : ""}`}
|
||||||
|
onClick={onToggle}
|
||||||
|
disabled={alreadyAdded}
|
||||||
|
aria-label={selected ? `Remove ${entry.name} from folders to add` : `Choose ${entry.name}`}
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||||
|
onClick={onNavigate}
|
||||||
|
title={entry.path}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" />
|
||||||
|
</svg>
|
||||||
|
<span className="truncate text-sm">{entry.name}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{alreadyAdded ? (
|
||||||
|
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-500 light-theme:border-gray-700/40 light-theme:bg-gray-950">
|
||||||
|
Added
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:hover:bg-gray-900 light-theme:hover:text-white"
|
||||||
|
onClick={onNavigate}
|
||||||
|
title={entry.has_children ? "Open folder" : "No subfolders"}
|
||||||
|
>
|
||||||
|
<svg className={`h-4 w-4 ${entry.has_children ? "" : "opacity-45"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StagedFoldersPanel({
|
||||||
|
stagedPaths,
|
||||||
|
onRemove,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
stagedPaths: string[];
|
||||||
|
onRemove: (path: string) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<aside className="flex min-h-0 w-full flex-col border-t border-white/[0.07] bg-white/[0.018] light-theme:border-gray-300/70 light-theme:bg-gray-900/35 lg:w-80 lg:border-l lg:border-t-0">
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-white/[0.07] px-5 py-4 light-theme:border-gray-300/70">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">
|
||||||
|
Folders to add ({stagedPaths.length})
|
||||||
|
</p>
|
||||||
|
{stagedPaths.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white"
|
||||||
|
onClick={onClear}
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
|
{stagedPaths.length === 0 ? (
|
||||||
|
<div className="flex h-full min-h-40 flex-col items-center justify-center rounded-md border border-dashed border-white/[0.08] px-5 text-center light-theme:border-gray-700/35">
|
||||||
|
<p className="text-sm text-gray-500">No folders selected.</p>
|
||||||
|
<p className="mt-1 max-w-52 text-xs leading-relaxed text-gray-600 light-theme:text-gray-500">
|
||||||
|
Choose folders on the left and they will collect here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{stagedPaths.map((path) => (
|
||||||
|
<div
|
||||||
|
key={path}
|
||||||
|
className="group flex min-h-12 items-center gap-2 rounded-md border border-white/[0.07] bg-white/[0.045] px-3 py-2 text-gray-200 transition-colors hover:border-white/15 hover:bg-white/[0.07] light-theme:border-gray-700/40 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
|
||||||
|
title={path}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" />
|
||||||
|
</svg>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{folderName(path)}</p>
|
||||||
|
<p className="mt-0.5 truncate text-[11px] text-gray-600 light-theme:text-gray-500">{path}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md p-1 text-gray-500 opacity-80 transition-colors hover:bg-white/[0.08] hover:text-white group-hover:opacity-100 light-theme:hover:bg-gray-700"
|
||||||
|
onClick={() => onRemove(path)}
|
||||||
|
aria-label={`Remove ${path} from folders to add`}
|
||||||
|
title="Remove from folders to add"
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FolderPickerModal() {
|
||||||
|
const folderPickerOpen = useGalleryStore((state) => state.folderPickerOpen);
|
||||||
|
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen);
|
||||||
|
const folders = useGalleryStore((state) => state.folders);
|
||||||
|
const listDirectories = useGalleryStore((state) => state.listDirectories);
|
||||||
|
const addFolders = useGalleryStore((state) => state.addFolders);
|
||||||
|
|
||||||
|
const [listing, setListing] = useState<DirListing | null>(null);
|
||||||
|
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||||
|
const [stagedPaths, setStagedPaths] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [results, setResults] = useState<FolderAddResult[] | null>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const libraryPaths = useMemo(() => new Set(folders.map((folder) => normalizePath(folder.path))), [folders]);
|
||||||
|
const stagedSet = useMemo(() => new Set(stagedPaths.map(normalizePath)), [stagedPaths]);
|
||||||
|
const breadcrumbs = useMemo(() => buildBreadcrumbs(listing?.current ?? null), [listing?.current]);
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: listing?.entries.length ?? 0,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => 48,
|
||||||
|
overscan: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!folderPickerOpen) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
void listDirectories(currentPath)
|
||||||
|
.then((nextListing) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setListing(nextListing);
|
||||||
|
scrollRef.current?.scrollTo({ top: 0, left: 0 });
|
||||||
|
})
|
||||||
|
.catch((loadError) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setListing({ current: currentPath, parent: null, entries: [] });
|
||||||
|
setError(loadError instanceof Error ? loadError.message : String(loadError));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [currentPath, folderPickerOpen, listDirectories]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!folderPickerOpen) return;
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setFolderPickerOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [folderPickerOpen, setFolderPickerOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (folderPickerOpen) return;
|
||||||
|
setCurrentPath(null);
|
||||||
|
setListing(null);
|
||||||
|
setStagedPaths([]);
|
||||||
|
setError(null);
|
||||||
|
setResults(null);
|
||||||
|
setAdding(false);
|
||||||
|
}, [folderPickerOpen]);
|
||||||
|
|
||||||
|
if (!folderPickerOpen) return null;
|
||||||
|
|
||||||
|
const entries = listing?.entries ?? [];
|
||||||
|
|
||||||
|
const togglePath = (path: string) => {
|
||||||
|
const normalized = normalizePath(path);
|
||||||
|
if (libraryPaths.has(normalized)) return;
|
||||||
|
setResults(null);
|
||||||
|
setStagedPaths((current) => {
|
||||||
|
const exists = current.some((staged) => normalizePath(staged) === normalized);
|
||||||
|
return exists ? current.filter((staged) => normalizePath(staged) !== normalized) : [...current, path];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeStagedPath = (path: string) => {
|
||||||
|
const normalized = normalizePath(path);
|
||||||
|
setResults(null);
|
||||||
|
setStagedPaths((current) => current.filter((staged) => normalizePath(staged) !== normalized));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearStagedPaths = () => {
|
||||||
|
setResults(null);
|
||||||
|
setStagedPaths([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmAdd = async () => {
|
||||||
|
if (stagedPaths.length === 0 || adding) return;
|
||||||
|
setAdding(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const addResults = await addFolders(stagedPaths);
|
||||||
|
const failed = addResults.filter((result) => result.status === "error");
|
||||||
|
setResults(addResults);
|
||||||
|
if (failed.length > 0) {
|
||||||
|
setStagedPaths(stagedPaths.filter((_, i) => addResults[i]?.status === "error"));
|
||||||
|
setError(failed.map((failure) => failure.data).join("; "));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFolderPickerOpen(false);
|
||||||
|
} catch (addError) {
|
||||||
|
setError(addError instanceof Error ? addError.message : String(addError));
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm"
|
||||||
|
onClick={() => setFolderPickerOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative flex h-[min(82vh,760px)] w-[min(90vw,1180px)] flex-col overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60 light-theme:border-gray-300/70"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<header className="border-b border-white/[0.07] px-5 py-4 light-theme:border-gray-200">
|
||||||
|
<div className="flex items-start justify-between gap-6">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-base font-semibold text-white">Add media folders</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 light-theme:text-gray-600">Choose folders from any location, then add them together.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:hover:bg-gray-900 light-theme:hover:text-white"
|
||||||
|
onClick={() => setFolderPickerOpen(false)}
|
||||||
|
title="Close folder picker"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||||
|
<main className="flex min-h-0 flex-1 flex-col px-5 py-4">
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md border border-white/10 bg-white/[0.035] px-2.5 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
|
||||||
|
onClick={() => setCurrentPath(listing?.parent ?? null)}
|
||||||
|
disabled={!listing?.current}
|
||||||
|
>
|
||||||
|
Up
|
||||||
|
</button>
|
||||||
|
<nav className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden rounded-md border border-white/10 bg-white/[0.025] px-2 py-1.5 light-theme:border-gray-300/70 light-theme:bg-gray-900">
|
||||||
|
{breadcrumbs.map((crumb, index) => (
|
||||||
|
<span key={`${crumb.path ?? "root"}-${index}`} className="flex min-w-0 items-center gap-1">
|
||||||
|
{index > 0 ? <span className="text-gray-700 light-theme:text-gray-400">/</span> : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="max-w-40 truncate rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white"
|
||||||
|
onClick={() => setCurrentPath(crumb.path)}
|
||||||
|
title={crumb.path ?? "Roots"}
|
||||||
|
>
|
||||||
|
{crumb.label}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="mb-3 rounded-md border border-amber-400/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-200 light-theme:border-amber-600/40 light-theme:bg-amber-100 light-theme:text-amber-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div ref={scrollRef} className="min-h-0 flex-1 overflow-auto rounded-md border border-white/[0.07] bg-white/[0.018] p-2 light-theme:border-gray-300/70 light-theme:bg-gray-900/50">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-gray-500">Loading folders...</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-gray-500">No folders found here.</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="relative w-full"
|
||||||
|
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||||
|
>
|
||||||
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
|
const entry = entries[virtualItem.index];
|
||||||
|
const normalized = normalizePath(entry.path);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={virtualItem.key}
|
||||||
|
className="absolute left-0 top-0 w-full px-0.5"
|
||||||
|
style={{
|
||||||
|
height: `${virtualItem.size}px`,
|
||||||
|
transform: `translateY(${virtualItem.start}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderRow
|
||||||
|
entry={entry}
|
||||||
|
selected={stagedSet.has(normalized)}
|
||||||
|
alreadyAdded={libraryPaths.has(normalized)}
|
||||||
|
onToggle={() => togglePath(entry.path)}
|
||||||
|
onNavigate={() => setCurrentPath(entry.path)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<StagedFoldersPanel
|
||||||
|
stagedPaths={stagedPaths}
|
||||||
|
onRemove={removeStagedPath}
|
||||||
|
onClear={clearStagedPaths}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="border-t border-white/[0.07] px-5 py-4 light-theme:border-gray-200">
|
||||||
|
<div className="flex items-end justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<StatusLine results={results} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md border border-white/10 bg-white/[0.035] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
|
||||||
|
onClick={() => setFolderPickerOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md border border-white/15 bg-white/[0.08] px-3 py-1.5 text-xs text-white transition-colors hover:bg-white/[0.12] disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
|
||||||
|
onClick={() => void confirmAdd()}
|
||||||
|
disabled={stagedPaths.length === 0 || adding}
|
||||||
|
>
|
||||||
|
{adding ? "Adding..." : `Add ${stagedPaths.length}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { MediaFilter, ZoomPreset, useGalleryStore } from "../store";
|
import { MediaFilter, ZoomPreset, useGalleryStore } from "../store";
|
||||||
|
|
||||||
type MenuKey = "library" | "view" | "filter";
|
type MenuKey = "library" | "view" | "filter";
|
||||||
@@ -72,7 +71,7 @@ const FILTER_OPTIONS: { value: MediaFilter; label: string }[] = [
|
|||||||
export function MenuBar() {
|
export function MenuBar() {
|
||||||
const [openMenu, setOpenMenu] = useState<MenuKey | null>(null);
|
const [openMenu, setOpenMenu] = useState<MenuKey | null>(null);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const addFolder = useGalleryStore((state) => state.addFolder);
|
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen);
|
||||||
const reindexFolder = useGalleryStore((state) => state.reindexFolder);
|
const reindexFolder = useGalleryStore((state) => state.reindexFolder);
|
||||||
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
||||||
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
||||||
@@ -93,11 +92,8 @@ export function MenuBar() {
|
|||||||
return () => window.removeEventListener("pointerdown", handlePointerDown);
|
return () => window.removeEventListener("pointerdown", handlePointerDown);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleAddFolder = async () => {
|
const handleAddFolder = () => {
|
||||||
const selected = await open({ directory: true, multiple: false, title: "Select Media Folder" });
|
setFolderPickerOpen(true);
|
||||||
if (selected && typeof selected === "string") {
|
|
||||||
await addFolder(selected);
|
|
||||||
}
|
|
||||||
setOpenMenu(null);
|
setOpenMenu(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { ThemedDropdown } from "./ThemedDropdown";
|
|||||||
type SettingsSection = "workspace" | "general";
|
type SettingsSection = "workspace" | "general";
|
||||||
|
|
||||||
const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [
|
const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [
|
||||||
{ id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" },
|
|
||||||
{ id: "general", label: "General", detail: "App data and diagnostics" },
|
{ id: "general", label: "General", detail: "App data and diagnostics" },
|
||||||
|
{ id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function formatBytesShort(bytes: number): string {
|
function formatBytesShort(bytes: number): string {
|
||||||
@@ -122,7 +122,7 @@ function TaggerAccelerationButton({ acceleration, current, onSelect, children }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsModal() {
|
export function SettingsModal() {
|
||||||
const [activeSection, setActiveSection] = useState<SettingsSection>("workspace");
|
const [activeSection, setActiveSection] = useState<SettingsSection>("general");
|
||||||
const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null);
|
const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null);
|
||||||
const [taggerQueueing, setTaggerQueueing] = useState(false);
|
const [taggerQueueing, setTaggerQueueing] = useState(false);
|
||||||
const [taggerClearing, setTaggerClearing] = useState(false);
|
const [taggerClearing, setTaggerClearing] = useState(false);
|
||||||
@@ -138,6 +138,8 @@ export function SettingsModal() {
|
|||||||
const [dbInfo, setDbInfo] = useState<DatabaseInfo | null>(null);
|
const [dbInfo, setDbInfo] = useState<DatabaseInfo | null>(null);
|
||||||
const [vacuuming, setVacuuming] = useState(false);
|
const [vacuuming, setVacuuming] = useState(false);
|
||||||
const [vacuumResult, setVacuumResult] = useState<VacuumResult | null>(null);
|
const [vacuumResult, setVacuumResult] = useState<VacuumResult | null>(null);
|
||||||
|
const [rebuildingIndex, setRebuildingIndex] = useState(false);
|
||||||
|
const [rebuildIndexResult, setRebuildIndexResult] = useState<string | null>(null);
|
||||||
const [thumbnailInfo, setThumbnailInfo] = useState<OrphanedThumbnailsInfo | null>(null);
|
const [thumbnailInfo, setThumbnailInfo] = useState<OrphanedThumbnailsInfo | null>(null);
|
||||||
const [cleaningThumbnails, setCleaningThumbnails] = useState(false);
|
const [cleaningThumbnails, setCleaningThumbnails] = useState(false);
|
||||||
const [thumbnailCleanupResult, setThumbnailCleanupResult] = useState<CleanupOrphanedThumbnailsResult | null>(null);
|
const [thumbnailCleanupResult, setThumbnailCleanupResult] = useState<CleanupOrphanedThumbnailsResult | null>(null);
|
||||||
@@ -184,6 +186,7 @@ export function SettingsModal() {
|
|||||||
const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused);
|
const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused);
|
||||||
const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo);
|
const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo);
|
||||||
const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase);
|
const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase);
|
||||||
|
const rebuildSemanticIndex = useGalleryStore((state) => state.rebuildSemanticIndex);
|
||||||
const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo);
|
const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo);
|
||||||
const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails);
|
const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails);
|
||||||
const appVersion = useGalleryStore((state) => state.appVersion);
|
const appVersion = useGalleryStore((state) => state.appVersion);
|
||||||
@@ -195,6 +198,10 @@ export function SettingsModal() {
|
|||||||
const openOnboarding = useGalleryStore((state) => state.openOnboarding);
|
const openOnboarding = useGalleryStore((state) => state.openOnboarding);
|
||||||
const theme = useGalleryStore((state) => state.theme);
|
const theme = useGalleryStore((state) => state.theme);
|
||||||
const setTheme = useGalleryStore((state) => state.setTheme);
|
const setTheme = useGalleryStore((state) => state.setTheme);
|
||||||
|
const lightboxAutoplay = useGalleryStore((state) => state.lightboxAutoplay);
|
||||||
|
const setLightboxAutoplay = useGalleryStore((state) => state.setLightboxAutoplay);
|
||||||
|
const lightboxAutoMute = useGalleryStore((state) => state.lightboxAutoMute);
|
||||||
|
const setLightboxAutoMute = useGalleryStore((state) => state.setLightboxAutoMute);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settingsOpen) return;
|
if (!settingsOpen) return;
|
||||||
@@ -618,6 +625,35 @@ export function SettingsModal() {
|
|||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SettingsGroup title="Video playback">
|
||||||
|
<SettingsItem
|
||||||
|
label="Autoplay in lightbox"
|
||||||
|
description="Start playing videos automatically when opened in the lightbox."
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={lightboxAutoplay}
|
||||||
|
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${lightboxAutoplay ? "bg-sky-500" : "bg-white/15"}`}
|
||||||
|
onClick={() => setLightboxAutoplay(!lightboxAutoplay)}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${lightboxAutoplay ? "translate-x-4" : "translate-x-0"}`} />
|
||||||
|
</button>
|
||||||
|
</SettingsItem>
|
||||||
|
<SettingsItem
|
||||||
|
label="Start muted"
|
||||||
|
description="Open videos with their audio muted — unmute from the player controls."
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={lightboxAutoMute}
|
||||||
|
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${lightboxAutoMute ? "bg-sky-500" : "bg-white/15"}`}
|
||||||
|
onClick={() => setLightboxAutoMute(!lightboxAutoMute)}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${lightboxAutoMute ? "translate-x-4" : "translate-x-0"}`} />
|
||||||
|
</button>
|
||||||
|
</SettingsItem>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
<SettingsGroup title="Updates">
|
<SettingsGroup title="Updates">
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
label={
|
label={
|
||||||
@@ -766,6 +802,42 @@ export function SettingsModal() {
|
|||||||
</button>
|
</button>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
label="Rebuild semantic index"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
Recreates the visual-embedding index and re-embeds every image in the
|
||||||
|
background. Use this if semantic or similar-image search reports a
|
||||||
|
dimension-mismatch error (for example after experimenting with a
|
||||||
|
different embedding model).
|
||||||
|
</span>
|
||||||
|
{rebuildIndexResult !== null ? (
|
||||||
|
<span className="mt-2 block text-gray-600">{rebuildIndexResult}</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
|
||||||
|
onClick={() => {
|
||||||
|
setRebuildingIndex(true);
|
||||||
|
setRebuildIndexResult(null);
|
||||||
|
void rebuildSemanticIndex()
|
||||||
|
.then((count) =>
|
||||||
|
setRebuildIndexResult(
|
||||||
|
`Re-queued ${count.toLocaleString()} image${count === 1 ? "" : "s"} for embedding.`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch((error) => setRebuildIndexResult(String(error)))
|
||||||
|
.finally(() => setRebuildingIndex(false));
|
||||||
|
}}
|
||||||
|
disabled={rebuildingIndex}
|
||||||
|
>
|
||||||
|
{rebuildingIndex ? "Rebuilding…" : "Rebuild index"}
|
||||||
|
</button>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
label="Thumbnail cache"
|
label="Thumbnail cache"
|
||||||
description={
|
description={
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ function FolderItem({
|
|||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const folders = useGalleryStore((state) => state.folders);
|
const folders = useGalleryStore((state) => state.folders);
|
||||||
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
||||||
const addFolder = useGalleryStore((state) => state.addFolder);
|
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen);
|
||||||
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
||||||
const selectFolder = useGalleryStore((state) => state.selectFolder);
|
const selectFolder = useGalleryStore((state) => state.selectFolder);
|
||||||
const activeView = useGalleryStore((state) => state.activeView);
|
const activeView = useGalleryStore((state) => state.activeView);
|
||||||
@@ -459,11 +459,8 @@ export function Sidebar() {
|
|||||||
}, 400);
|
}, 400);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddFolder = async () => {
|
const handleAddFolder = () => {
|
||||||
const selected = await open({ directory: true, multiple: false, title: "Select Media Folder" });
|
setFolderPickerOpen(true);
|
||||||
if (selected && typeof selected === "string") {
|
|
||||||
await addFolder(selected);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { useGalleryStore } from "../store";
|
||||||
|
|
||||||
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
|
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||||
const CONTROLS_HIDE_DELAY_MS = 2500;
|
const CONTROLS_HIDE_DELAY_MS = 2500;
|
||||||
@@ -57,7 +58,9 @@ export function VideoPlayer({ src }: { src: string }) {
|
|||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [buffered, setBuffered] = useState<BufferedRange[]>([]);
|
const [buffered, setBuffered] = useState<BufferedRange[]>([]);
|
||||||
const [volume, setVolume] = useState(persistedVolume);
|
const [volume, setVolume] = useState(persistedVolume);
|
||||||
const [muted, setMuted] = useState(persistedMuted);
|
const [muted, setMuted] = useState(
|
||||||
|
() => useGalleryStore.getState().lightboxAutoMute || persistedMuted,
|
||||||
|
);
|
||||||
const [playbackRate, setPlaybackRate] = useState(1);
|
const [playbackRate, setPlaybackRate] = useState(1);
|
||||||
const [loop, setLoop] = useState(false);
|
const [loop, setLoop] = useState(false);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
@@ -89,11 +92,16 @@ export function VideoPlayer({ src }: { src: string }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
// Read playback prefs fresh for each opened video — not reactive, so a
|
||||||
|
// settings change applies to the next video rather than the current one.
|
||||||
|
const { lightboxAutoplay, lightboxAutoMute } = useGalleryStore.getState();
|
||||||
|
const startMuted = lightboxAutoMute || persistedMuted;
|
||||||
video.volume = persistedVolume;
|
video.volume = persistedVolume;
|
||||||
video.muted = persistedMuted;
|
video.muted = startMuted;
|
||||||
// Autoplay; if the webview blocks it the catch leaves us paused with
|
setMuted(startMuted);
|
||||||
// controls visible, which is a fine fallback.
|
// Autoplay when enabled; if the webview blocks it the catch leaves us paused
|
||||||
video.play().catch(() => {});
|
// with controls visible, which is a fine fallback.
|
||||||
|
if (lightboxAutoplay) video.play().catch(() => {});
|
||||||
}, [src]);
|
}, [src]);
|
||||||
|
|
||||||
const readBuffered = useCallback(() => {
|
const readBuffered = useCallback(() => {
|
||||||
|
|||||||
@@ -1,26 +1,12 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { useGalleryStore } from "../../store";
|
import { useGalleryStore } from "../../store";
|
||||||
import { FakeTile } from "./fakes";
|
import { FakeTile } from "./fakes";
|
||||||
|
|
||||||
export function StepAddLibrary() {
|
export function StepAddLibrary() {
|
||||||
const folders = useGalleryStore((s) => s.folders);
|
const folders = useGalleryStore((s) => s.folders);
|
||||||
const addFolder = useGalleryStore((s) => s.addFolder);
|
const setFolderPickerOpen = useGalleryStore((s) => s.setFolderPickerOpen);
|
||||||
const [adding, setAdding] = useState(false);
|
|
||||||
const [addError, setAddError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handlePick = async () => {
|
const handlePick = () => {
|
||||||
setAddError(null);
|
setFolderPickerOpen(true);
|
||||||
const selected = await open({ directory: true, multiple: false, title: "Select Media Folder" });
|
|
||||||
if (typeof selected !== "string") return;
|
|
||||||
setAdding(true);
|
|
||||||
try {
|
|
||||||
await addFolder(selected);
|
|
||||||
} catch (error) {
|
|
||||||
setAddError(error instanceof Error ? error.message : String(error));
|
|
||||||
} finally {
|
|
||||||
setAdding(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -49,14 +35,12 @@ export function StepAddLibrary() {
|
|||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{addError ? <p className="mt-1.5 text-xs text-amber-300/90 light-theme:text-amber-700">{addError}</p> : null}
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="shrink-0 rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700 light-theme:hover:bg-emerald-200"
|
className="shrink-0 rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700 light-theme:hover:bg-emerald-200"
|
||||||
onClick={() => void handlePick()}
|
onClick={handlePick}
|
||||||
disabled={adding}
|
|
||||||
>
|
>
|
||||||
{adding ? "Adding..." : folders.length > 0 ? "Add another folder" : "Choose a folder"}
|
{folders.length > 0 ? "Add another folder" : "Choose a folder"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { notifyTaskComplete } from "./notifications";
|
|||||||
const notificationTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const notificationTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
const NOTIFICATION_DEBOUNCE_MS = 6000;
|
const NOTIFICATION_DEBOUNCE_MS = 6000;
|
||||||
const THEME_KEY = "phokus-theme";
|
const THEME_KEY = "phokus-theme";
|
||||||
|
const LIGHTBOX_AUTOPLAY_KEY = "phokus.lightboxAutoplay";
|
||||||
|
const LIGHTBOX_AUTO_MUTE_KEY = "phokus.lightboxAutoMute";
|
||||||
|
|
||||||
export interface Folder {
|
export interface Folder {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -23,6 +25,23 @@ export interface Folder {
|
|||||||
sort_order: number;
|
sort_order: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DirEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
has_children: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirListing {
|
||||||
|
current: string | null;
|
||||||
|
parent: string | null;
|
||||||
|
entries: DirEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FolderAddResult =
|
||||||
|
| { status: "added"; data: Folder }
|
||||||
|
| { status: "skipped"; data: string }
|
||||||
|
| { status: "error"; data: string };
|
||||||
|
|
||||||
export type MediaKind = "image" | "video";
|
export type MediaKind = "image" | "video";
|
||||||
export type MediaFilter = "all" | MediaKind;
|
export type MediaFilter = "all" | MediaKind;
|
||||||
export type ZoomPreset = "compact" | "comfortable" | "detail";
|
export type ZoomPreset = "compact" | "comfortable" | "detail";
|
||||||
@@ -341,11 +360,14 @@ interface GalleryState {
|
|||||||
captionDetail: CaptionDetail;
|
captionDetail: CaptionDetail;
|
||||||
aiCaptionsEnabled: boolean;
|
aiCaptionsEnabled: boolean;
|
||||||
settingsOpen: boolean;
|
settingsOpen: boolean;
|
||||||
|
folderPickerOpen: boolean;
|
||||||
taggingQueueScope: TaggingQueueScope;
|
taggingQueueScope: TaggingQueueScope;
|
||||||
taggingQueueFolderIds: number[];
|
taggingQueueFolderIds: number[];
|
||||||
mutedFolderIds: number[];
|
mutedFolderIds: number[];
|
||||||
notificationsPaused: boolean;
|
notificationsPaused: boolean;
|
||||||
theme: AppTheme;
|
theme: AppTheme;
|
||||||
|
lightboxAutoplay: boolean;
|
||||||
|
lightboxAutoMute: boolean;
|
||||||
// Per-folder background-worker pause flags, shared by the BackgroundTasks
|
// Per-folder background-worker pause flags, shared by the BackgroundTasks
|
||||||
// bar and the sidebar folder context menu.
|
// bar and the sidebar folder context menu.
|
||||||
workerPaused: Record<number, Record<WorkerKey, boolean>>;
|
workerPaused: Record<number, Record<WorkerKey, boolean>>;
|
||||||
@@ -386,6 +408,8 @@ interface GalleryState {
|
|||||||
loadFolders: () => Promise<void>;
|
loadFolders: () => Promise<void>;
|
||||||
loadBackgroundJobProgress: () => Promise<void>;
|
loadBackgroundJobProgress: () => Promise<void>;
|
||||||
addFolder: (path: string) => Promise<void>;
|
addFolder: (path: string) => Promise<void>;
|
||||||
|
addFolders: (paths: string[]) => Promise<FolderAddResult[]>;
|
||||||
|
listDirectories: (path: string | null) => Promise<DirListing>;
|
||||||
removeFolder: (folderId: number) => Promise<void>;
|
removeFolder: (folderId: number) => Promise<void>;
|
||||||
reindexFolder: (folderId: number) => Promise<void>;
|
reindexFolder: (folderId: number) => Promise<void>;
|
||||||
renameFolder: (folderId: number, newName: string) => Promise<void>;
|
renameFolder: (folderId: number, newName: string) => Promise<void>;
|
||||||
@@ -435,6 +459,7 @@ interface GalleryState {
|
|||||||
setCaptionDetail: (detail: CaptionDetail) => Promise<void>;
|
setCaptionDetail: (detail: CaptionDetail) => Promise<void>;
|
||||||
setAiCaptionsEnabled: (enabled: boolean) => void;
|
setAiCaptionsEnabled: (enabled: boolean) => void;
|
||||||
setSettingsOpen: (open: boolean) => void;
|
setSettingsOpen: (open: boolean) => void;
|
||||||
|
setFolderPickerOpen: (open: boolean) => void;
|
||||||
loadTaggingQueueScope: () => Promise<void>;
|
loadTaggingQueueScope: () => Promise<void>;
|
||||||
setTaggingQueueScope: (scope: TaggingQueueScope) => void;
|
setTaggingQueueScope: (scope: TaggingQueueScope) => void;
|
||||||
loadTaggingQueueFolderIds: () => Promise<void>;
|
loadTaggingQueueFolderIds: () => Promise<void>;
|
||||||
@@ -445,6 +470,8 @@ interface GalleryState {
|
|||||||
loadNotificationsPaused: () => Promise<void>;
|
loadNotificationsPaused: () => Promise<void>;
|
||||||
setNotificationsPaused: (paused: boolean) => void;
|
setNotificationsPaused: (paused: boolean) => void;
|
||||||
setTheme: (theme: AppTheme) => void;
|
setTheme: (theme: AppTheme) => void;
|
||||||
|
setLightboxAutoplay: (enabled: boolean) => void;
|
||||||
|
setLightboxAutoMute: (enabled: boolean) => void;
|
||||||
loadWorkerStates: () => Promise<void>;
|
loadWorkerStates: () => Promise<void>;
|
||||||
setWorkerPaused: (folderId: number, worker: WorkerKey, paused: boolean) => void;
|
setWorkerPaused: (folderId: number, worker: WorkerKey, paused: boolean) => void;
|
||||||
setAllWorkersPaused: (folderId: number, paused: boolean) => void;
|
setAllWorkersPaused: (folderId: number, paused: boolean) => void;
|
||||||
@@ -461,6 +488,7 @@ interface GalleryState {
|
|||||||
openAppDataFolder: () => Promise<void>;
|
openAppDataFolder: () => Promise<void>;
|
||||||
getDatabaseInfo: () => Promise<DatabaseInfo>;
|
getDatabaseInfo: () => Promise<DatabaseInfo>;
|
||||||
vacuumDatabase: () => Promise<VacuumResult>;
|
vacuumDatabase: () => Promise<VacuumResult>;
|
||||||
|
rebuildSemanticIndex: () => Promise<number>;
|
||||||
getOrphanedThumbnailsInfo: () => Promise<OrphanedThumbnailsInfo>;
|
getOrphanedThumbnailsInfo: () => Promise<OrphanedThumbnailsInfo>;
|
||||||
cleanupOrphanedThumbnails: () => Promise<CleanupOrphanedThumbnailsResult>;
|
cleanupOrphanedThumbnails: () => Promise<CleanupOrphanedThumbnailsResult>;
|
||||||
retryFailedEmbeddings: (folderId: number) => Promise<void>;
|
retryFailedEmbeddings: (folderId: number) => Promise<void>;
|
||||||
@@ -515,6 +543,12 @@ function initialAiCaptionsEnabled(): boolean {
|
|||||||
return window.localStorage.getItem(AI_CAPTIONS_ENABLED_KEY) === "true";
|
return window.localStorage.getItem(AI_CAPTIONS_ENABLED_KEY) === "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initialBoolSetting(key: string, fallback: boolean): boolean {
|
||||||
|
if (typeof window === "undefined") return fallback;
|
||||||
|
const stored = window.localStorage.getItem(key);
|
||||||
|
return stored === null ? fallback : stored === "true";
|
||||||
|
}
|
||||||
|
|
||||||
function initialTheme(): AppTheme {
|
function initialTheme(): AppTheme {
|
||||||
if (typeof window === "undefined") return "phokus";
|
if (typeof window === "undefined") return "phokus";
|
||||||
const saved = window.localStorage.getItem(THEME_KEY);
|
const saved = window.localStorage.getItem(THEME_KEY);
|
||||||
@@ -760,11 +794,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
captionDetail: "paragraph",
|
captionDetail: "paragraph",
|
||||||
aiCaptionsEnabled: initialAiCaptionsEnabled(),
|
aiCaptionsEnabled: initialAiCaptionsEnabled(),
|
||||||
settingsOpen: false,
|
settingsOpen: false,
|
||||||
|
folderPickerOpen: false,
|
||||||
taggingQueueScope: "all",
|
taggingQueueScope: "all",
|
||||||
taggingQueueFolderIds: [],
|
taggingQueueFolderIds: [],
|
||||||
mutedFolderIds: [],
|
mutedFolderIds: [],
|
||||||
notificationsPaused: false,
|
notificationsPaused: false,
|
||||||
theme: initialTheme(),
|
theme: initialTheme(),
|
||||||
|
lightboxAutoplay: initialBoolSetting(LIGHTBOX_AUTOPLAY_KEY, true),
|
||||||
|
lightboxAutoMute: initialBoolSetting(LIGHTBOX_AUTO_MUTE_KEY, false),
|
||||||
workerPaused: {},
|
workerPaused: {},
|
||||||
|
|
||||||
appVersion: null,
|
appVersion: null,
|
||||||
@@ -833,6 +870,16 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
await loadBackgroundJobProgress();
|
await loadBackgroundJobProgress();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addFolders: async (paths) => {
|
||||||
|
const { loadFolders, loadBackgroundJobProgress } = get();
|
||||||
|
const results = await invoke<FolderAddResult[]>("add_folders", { paths });
|
||||||
|
await loadFolders();
|
||||||
|
await loadBackgroundJobProgress();
|
||||||
|
return results;
|
||||||
|
},
|
||||||
|
|
||||||
|
listDirectories: (path) => invoke<DirListing>("list_directories", { path }),
|
||||||
|
|
||||||
removeFolder: async (folderId) => {
|
removeFolder: async (folderId) => {
|
||||||
const { selectedFolderId, loadFolders, loadImages, loadBackgroundJobProgress } = get();
|
const { selectedFolderId, loadFolders, loadImages, loadBackgroundJobProgress } = get();
|
||||||
// Optimistically drop it from the sidebar for instant feedback (the backend
|
// Optimistically drop it from the sidebar for instant feedback (the backend
|
||||||
@@ -1565,6 +1612,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setSettingsOpen: (settingsOpen) => set({ settingsOpen }),
|
setSettingsOpen: (settingsOpen) => set({ settingsOpen }),
|
||||||
|
setFolderPickerOpen: (folderPickerOpen) => set({ folderPickerOpen }),
|
||||||
|
|
||||||
loadTaggingQueueScope: async () => {
|
loadTaggingQueueScope: async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1649,6 +1697,16 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
set({ theme });
|
set({ theme });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setLightboxAutoplay: (enabled) => {
|
||||||
|
window.localStorage.setItem(LIGHTBOX_AUTOPLAY_KEY, String(enabled));
|
||||||
|
set({ lightboxAutoplay: enabled });
|
||||||
|
},
|
||||||
|
|
||||||
|
setLightboxAutoMute: (enabled) => {
|
||||||
|
window.localStorage.setItem(LIGHTBOX_AUTO_MUTE_KEY, String(enabled));
|
||||||
|
set({ lightboxAutoMute: enabled });
|
||||||
|
},
|
||||||
|
|
||||||
loadWorkerStates: async () => {
|
loadWorkerStates: async () => {
|
||||||
const folderIds = get().folders.map((folder) => folder.id);
|
const folderIds = get().folders.map((folder) => folder.id);
|
||||||
if (folderIds.length === 0) {
|
if (folderIds.length === 0) {
|
||||||
@@ -1834,6 +1892,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
|
|
||||||
vacuumDatabase: () => invoke<VacuumResult>("vacuum_database"),
|
vacuumDatabase: () => invoke<VacuumResult>("vacuum_database"),
|
||||||
|
|
||||||
|
rebuildSemanticIndex: () => invoke<number>("rebuild_semantic_index"),
|
||||||
|
|
||||||
getOrphanedThumbnailsInfo: () => invoke<OrphanedThumbnailsInfo>("get_orphaned_thumbnails_info"),
|
getOrphanedThumbnailsInfo: () => invoke<OrphanedThumbnailsInfo>("get_orphaned_thumbnails_info"),
|
||||||
|
|
||||||
cleanupOrphanedThumbnails: () => invoke<CleanupOrphanedThumbnailsResult>("cleanup_orphaned_thumbnails"),
|
cleanupOrphanedThumbnails: () => invoke<CleanupOrphanedThumbnailsResult>("cleanup_orphaned_thumbnails"),
|
||||||
|
|||||||
Reference in New Issue
Block a user