Compare commits

...

7 Commits

Author SHA1 Message Date
LyAhn e7d9c39fd1 docs: regroup unreleased changelog Added entries
github/actions/ci GitHub Actions CI finished: success
2026-06-28 14:56:00 +01:00
LyAhn 136d74a81b Merge feat/smart-albums-multiselect: albums, multi-select, EXIF, tag management, color search
- Manual albums (create/rename/delete/reorder; multi-select bulk add/remove) in a distinct sidebar section
- Gallery multi-select + bulk actions (tag, rate, favorite, add-to-album, delete with disk-delete confirmation)
- Lightbox EXIF/camera panel (on-demand) with a GPS map link
- Tag management in Explore (rename/merge/delete, library-wide)
- Album-scoped similar-image search
- Color search — filter the gallery by dominant color (swatches + custom picker), with background backfill
- Reusable tooltip component, duplicate-finder delete confirmation, CPU/CUDA build badge
- Post-review security/accessibility hardening (validated URL-open commands, etc.)
2026-06-28 14:45:31 +01:00
LyAhn bb0038e0a1 chore: post-review hardening + changelog link tooltip
Security / robustness / a11y polish on top of the color-search + tooltips work:

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

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

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

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

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

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

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

Also:
- Duplicate Finder delete now requires confirmation with clear "from disk"
  wording (was single-click fire-and-forget).
- CPU/CUDA build-variant badge in Settings (get_build_variant).
- Rating/favorite no longer re-sorts derived collections (similar/region/
  semantic/tag/album results); single and bulk paths replace in place there.
- Album-aware indexed-images/media-updated handlers so thumbnails paint and
  newly-indexed files don't leak into an album view.
- CHANGELOG updated.
2026-06-27 15:23:54 +01:00
27 changed files with 3449 additions and 139 deletions
+38
View File
@@ -9,19 +9,57 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
### Added
- **Albums** — curate your own collections. A new Albums section in the sidebar
(with cover thumbnails, kept visually distinct from Libraries) lets you create,
rename, reorder (drag the row), and open albums; albums can span multiple
folders. Add images from the gallery's bulk action bar or from the lightbox,
remove them from within an album, and use Manage mode to multi-select and
delete albums in one go. Deleting an album never touches your files — only the
grouping is removed.
- **Multi-select & bulk actions in the gallery** — hover a thumbnail's top-left
corner to reveal a selection checkbox (or click it to start selecting); while
selecting, click tiles to toggle and double-click to open. A floating action
bar then lets you tag (with autocomplete), rate, favorite, add to an album, or
delete the whole selection at once. Works in similar-image, region, and album
views too.
- **Color search** — filter the gallery (and Timeline) by dominant color via a
collapsible swatch palette plus a custom color picker in the toolbar; existing
libraries are backfilled automatically in the background.
- **Album-scoped similar search** — when finding visually similar images or
searching by a selected region from an album, you can now keep results scoped
to that album, switch back to the source folder, or search all media.
- **Tag management** — Explore → Tag Cloud gains a Manage mode with a flat tag
list where you can rename a tag, merge it into another (rename it to an
existing tag's name), or delete it from every image. Changes apply across the
whole library.
- **Camera info in the lightbox** — the image info panel now shows EXIF details
(camera, lens, aperture, shutter, ISO, focal length) and, when a photo is
geotagged, its GPS coordinates as a link that opens the location in your
browser. Read on demand from the file, so it works on already-indexed images
without re-indexing.
- **What's New** — after updating, Phokus now greets you with a "What's new"
toast that opens an in-app release-notes screen for the new version, with the
changes grouped into collapsible Added / Changed / Fixed sections. It's
sourced from the bundled changelog (so it works offline) and can be reopened
any time from Settings → Updates → What's new.
- **Build badge in Settings** — the version line in Settings → Updates now shows
whether the running build is the CPU or CUDA (GPU-accelerated) variant.
### Changed
- **Safer deletion** — deleting media now asks for confirmation and spells out
that it permanently removes the file(s) from disk. This covers the new gallery
bulk delete and the Duplicate Finder, which previously deleted on a single
click with no confirmation or warning.
- The updater now shows a real download progress bar with a percentage in
Settings → Updates (previously it only said "Downloading").
### Fixed
- **Rating no longer scrambles search results** — rating or favoriting an image
while viewing similar-image, region, semantic, tag, or album results no longer
re-sorts the view back into the default order; the current result ordering is
preserved.
- The update download/install progress toast now reappears when you start an
update from the title-bar indicator or Settings after dismissing the earlier
"Update available" prompt — previously progress only showed in Settings.
+6 -4
View File
@@ -6,16 +6,18 @@
"type": "module",
"scripts": {
"build:app": "tauri build",
"build:app:cpu": "tauri build -- --no-default-features",
"build:app:cuda": "tauri build --config src-tauri/tauri.cuda.conf.json",
"build:vite": "tsc && vite build",
"build:web": "cd website && tsc && vite build",
"changelog:add": "node tools/changelog-add.mjs",
"clean:app": "cd src-tauri && cargo clean",
"dev:app": "tauri dev",
"dev:app:cpu": "tauri dev -- --no-default-features",
"dev:web": "cd website && pnpm dev",
"build:app:cpu": "tauri build -- --no-default-features",
"build:app:cuda": "tauri build --config src-tauri/tauri.cuda.conf.json",
"changelog:add": "node tools/changelog-add.mjs",
"dev:vite": "vite",
"dev:web": "cd website && pnpm dev",
"format:app": "cd src-tauri && cargo fmt",
"format:check": "cd src-tauri && cargo fmt --check",
"preview": "vite preview",
"tauri": "tauri"
},
+91
View File
@@ -0,0 +1,91 @@
//! Dominant-color palette extraction for color search.
//!
//! Colors are sampled from the already-generated thumbnail (small, fast) rather
//! than the full image. We coarse-quantize pixels into an RGB histogram, then
//! return the most populated bins as representative colors with their weight
//! (fraction of sampled pixels). Search then filters images whose palette has a
//! color within a distance threshold of the query color.
use image::RgbImage;
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Copy)]
pub struct PaletteColor {
pub r: u8,
pub g: u8,
pub b: u8,
/// Fraction of sampled pixels (0.01.0) that fell in this color's bin.
pub weight: f32,
}
/// Bits kept per channel when binning. 4 bits → 16 levels/channel → 4096 bins:
/// coarse enough to group near-identical shades, fine enough to separate hues.
const QUANT_BITS: u32 = 4;
/// Cap on sampled pixels so very large frames stay cheap; thumbnails are tiny so
/// this rarely bites, but the backfill may read arbitrary thumbnail sizes.
const MAX_SAMPLES: usize = 50_000;
/// Extract up to `k` dominant colors from an RGB image, most-common first.
pub fn extract_palette(img: &RgbImage, k: usize) -> Vec<PaletteColor> {
let pixels = img.as_raw();
let pixel_count = pixels.len() / 3;
if pixel_count == 0 {
return Vec::new();
}
let step = (pixel_count / MAX_SAMPLES).max(1);
let shift = 8 - QUANT_BITS;
// bin key → (sum_r, sum_g, sum_b, count); summing lets us return the bin's
// average color rather than the quantized corner.
let mut bins: HashMap<u16, (u64, u64, u64, u64)> = HashMap::new();
let mut total: u64 = 0;
for pixel in pixels.chunks_exact(3).step_by(step) {
let (r, g, b) = (pixel[0], pixel[1], pixel[2]);
let key = (((r as u16) >> shift) << (QUANT_BITS * 2))
| (((g as u16) >> shift) << QUANT_BITS)
| ((b as u16) >> shift);
let entry = bins.entry(key).or_insert((0, 0, 0, 0));
entry.0 += r as u64;
entry.1 += g as u64;
entry.2 += b as u64;
entry.3 += 1;
total += 1;
}
if total == 0 {
return Vec::new();
}
let mut entries: Vec<(u64, u64, u64, u64)> = bins.into_values().collect();
entries.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.3));
entries
.into_iter()
.take(k)
.map(|(sum_r, sum_g, sum_b, count)| PaletteColor {
r: (sum_r / count) as u8,
g: (sum_g / count) as u8,
b: (sum_b / count) as u8,
weight: count as f32 / total as f32,
})
.collect()
}
/// Decode a thumbnail file and extract its palette. Used by the backfill pass.
pub fn extract_palette_from_file(thumbnail_path: &Path, k: usize) -> Option<Vec<PaletteColor>> {
let img = image::ImageReader::open(thumbnail_path)
.ok()?
.decode()
.ok()?;
Some(extract_palette(&img.into_rgb8(), k))
}
/// Number of palette colors stored per image.
pub const PALETTE_SIZE: usize = 5;
/// Max squared RGB distance for a palette color to count as matching a query
/// color (~70 units in RGB space). Tunable feel/precision of color search.
pub const MATCH_DISTANCE_SQ: i64 = 4900;
/// Minimum weight (fraction of pixels) a palette color must have to match, so
/// trivial specks of a color don't trigger a match.
pub const MATCH_MIN_WEIGHT: f64 = 0.05;
+405 -3
View File
@@ -2,7 +2,9 @@ use crate::captioner::{
self, CaptionAcceleration, CaptionDetail, CaptionModelStatus, CaptionRuntimeProbe,
CaptionVisionProbe,
};
use crate::db::{self, DbPool, ExploreTagEntry, Folder, FolderJobProgress, ImageRecord, ImageTag};
use crate::db::{
self, Album, DbPool, ExploreTagEntry, Folder, FolderJobProgress, ImageRecord, ImageTag,
};
use crate::embedder;
use crate::hnsw_index;
use crate::indexer::{self, WatcherHandle};
@@ -40,6 +42,9 @@ pub struct GetImagesParams {
pub rating_min: Option<i64>,
pub embedding_failed_only: Option<bool>,
pub tagging_failed_only: Option<bool>,
/// Optional `[r, g, b]` color filter — matches images whose palette contains
/// a prominent color near this one.
pub color: Option<[u8; 3]>,
pub sort: Option<String>,
pub offset: Option<i64>,
pub limit: Option<i64>,
@@ -56,6 +61,9 @@ pub struct UpdateImageDetailsParams {
pub struct FindSimilarImagesParams {
pub image_id: i64,
pub folder_id: Option<i64>,
/// When set, restrict results to images in this album (takes precedence
/// over `folder_id`). Used by the "Similar: Album" scope.
pub album_id: Option<i64>,
pub offset: Option<usize>,
pub limit: Option<usize>,
pub threshold: Option<f32>,
@@ -70,6 +78,8 @@ pub struct FindSimilarByRegionParams {
pub crop_w: f32,
pub crop_h: f32,
pub folder_id: Option<i64>,
/// Restrict to an album (takes precedence over `folder_id`).
pub album_id: Option<i64>,
pub offset: Option<usize>,
pub limit: Option<usize>,
}
@@ -557,6 +567,7 @@ pub async fn get_images(
let rating_min = params.rating_min.unwrap_or(0);
let embedding_failed_only = params.embedding_failed_only.unwrap_or(false);
let tagging_failed_only = params.tagging_failed_only.unwrap_or(false);
let color = params.color.map(|[r, g, b]| (r, g, b));
let total = db::count_images(
&conn,
@@ -567,6 +578,7 @@ pub async fn get_images(
rating_min,
embedding_failed_only,
tagging_failed_only,
color,
)
.map_err(|e| e.to_string())?;
@@ -579,6 +591,7 @@ pub async fn get_images(
rating_min,
embedding_failed_only,
tagging_failed_only,
color,
sort,
offset,
limit,
@@ -702,6 +715,7 @@ pub async fn find_similar_images(
&conn,
params.image_id,
params.folder_id,
params.album_id,
threshold,
offset,
limit + 1,
@@ -747,8 +761,19 @@ pub async fn find_similar_by_region(
)
.map_err(|e| e.to_string())?;
// Search for similar images using the crop embedding
let image_ids = match params.folder_id {
// Search for similar images using the crop embedding. Album scope takes
// precedence over folder scope.
let image_ids = if let Some(album_id) = params.album_id {
vector::search_image_ids_by_embedding_in_album(
&conn,
&embedding,
album_id,
Some(params.image_id),
offset + limit + 1,
)
.map_err(|e| e.to_string())?
} else {
match params.folder_id {
Some(folder_id) => vector::search_image_ids_by_embedding_in_folder(
&conn,
&embedding,
@@ -766,6 +791,7 @@ pub async fn find_similar_by_region(
ids.retain(|&id| id != params.image_id);
ids
}
}
};
let has_more = image_ids.len() > offset + limit;
@@ -1647,6 +1673,119 @@ pub async fn get_images_by_ids(
db::get_images_by_ids(&conn, &params.image_ids).map_err(|e| e.to_string())
}
/// Which acceleration variant this binary was compiled with. Used to badge the
/// version in Settings so it's clear whether the CPU or CUDA build is running.
#[tauri::command]
pub fn get_build_variant() -> String {
if cfg!(feature = "candle-cuda") {
"cuda".to_string()
} else {
"cpu".to_string()
}
}
/// Camera/EXIF metadata for the lightbox info panel. Read on demand from the
/// file (not stored in the DB) so it works on every already-indexed image
/// without a reindex. All fields are optional — absent tags are simply omitted.
#[derive(Serialize, Default)]
pub struct ImageExif {
pub make: Option<String>,
pub model: Option<String>,
pub lens: Option<String>,
pub iso: Option<String>,
pub f_number: Option<String>,
pub exposure_time: Option<String>,
pub focal_length: Option<String>,
pub datetime_original: Option<String>,
pub gps_lat: Option<f64>,
pub gps_lon: Option<f64>,
}
fn gps_coord(exif: &exif::Exif, coord: exif::Tag, reference: exif::Tag) -> Option<f64> {
let (max_abs, positive_ref, negative_ref) = match (coord, reference) {
(exif::Tag::GPSLatitude, exif::Tag::GPSLatitudeRef) => (90.0, b'N', b'S'),
(exif::Tag::GPSLongitude, exif::Tag::GPSLongitudeRef) => (180.0, b'E', b'W'),
_ => return None,
};
let field = exif.get_field(coord, exif::In::PRIMARY)?;
if let exif::Value::Rational(ref parts) = field.value {
if parts.len() >= 3 {
let degrees = parts[0].to_f64() + parts[1].to_f64() / 60.0 + parts[2].to_f64() / 3600.0;
if !degrees.is_finite() || degrees < 0.0 || degrees > max_abs {
return None;
}
// Read the hemisphere straight from the ref tag's ASCII bytes
// ("N"/"S"/"E"/"W") rather than its formatted display string.
let ref_byte =
exif.get_field(reference, exif::In::PRIMARY)
.and_then(|f| match &f.value {
exif::Value::Ascii(values) => values.iter().flatten().next().copied(),
_ => None,
})?;
if ref_byte != positive_ref && ref_byte != negative_ref {
return None;
}
let signed = if ref_byte == negative_ref {
-degrees
} else {
degrees
};
return signed
.is_finite()
.then_some(signed.clamp(-max_abs, max_abs));
}
}
None
}
fn extract_image_exif(path: &Path) -> ImageExif {
let mut out = ImageExif::default();
let Ok(file) = std::fs::File::open(path) else {
return out;
};
let mut reader = std::io::BufReader::new(file);
let Ok(exif) = exif::Reader::new().read_from_container(&mut reader) else {
return out;
};
let text = |tag: exif::Tag| -> Option<String> {
let value = exif
.get_field(tag, exif::In::PRIMARY)?
.display_value()
.with_unit(&exif)
.to_string();
let trimmed = value.trim().trim_matches('"').trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
};
out.make = text(exif::Tag::Make);
out.model = text(exif::Tag::Model);
out.lens = text(exif::Tag::LensModel);
out.iso = text(exif::Tag::PhotographicSensitivity);
out.f_number = text(exif::Tag::FNumber);
out.exposure_time = text(exif::Tag::ExposureTime);
out.focal_length = text(exif::Tag::FocalLength);
out.datetime_original = text(exif::Tag::DateTimeOriginal);
out.gps_lat = gps_coord(&exif, exif::Tag::GPSLatitude, exif::Tag::GPSLatitudeRef);
out.gps_lon = gps_coord(&exif, exif::Tag::GPSLongitude, exif::Tag::GPSLongitudeRef);
out
}
#[derive(Deserialize)]
pub struct GetImageExifParams {
pub image_id: i64,
}
#[tauri::command]
pub async fn get_image_exif(
db: State<'_, DbState>,
params: GetImageExifParams,
) -> Result<ImageExif, String> {
let conn = db.get().map_err(|e| e.to_string())?;
let record = db::get_image_by_id(&conn, params.image_id).map_err(|e| e.to_string())?;
Ok(extract_image_exif(Path::new(&record.path)))
}
// ── k-means with cosine similarity (all vectors assumed to be unit-normalized) ──
fn dot(a: &[f32], b: &[f32]) -> f32 {
@@ -2061,6 +2200,228 @@ pub async fn remove_tag(db: State<'_, DbState>, params: RemoveTagParams) -> Resu
db::remove_tag(&conn, params.tag_id).map_err(|e| e.to_string())
}
#[derive(Deserialize)]
pub struct RenameTagParams {
pub from: String,
pub to: String,
}
#[derive(Deserialize)]
pub struct DeleteTagParams {
pub tag: String,
}
#[tauri::command]
pub async fn rename_tag(db: State<'_, DbState>, params: RenameTagParams) -> Result<(), String> {
let from = params.from.trim();
let to = params.to.trim();
if from.is_empty() || to.is_empty() {
return Err("Tag names cannot be empty".to_string());
}
if from == to {
return Ok(());
}
let conn = db.get().map_err(|e| e.to_string())?;
db::rename_tag(&conn, from, to).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_tag(db: State<'_, DbState>, params: DeleteTagParams) -> Result<i64, String> {
let tag = params.tag.trim();
if tag.is_empty() {
return Err("Tag name cannot be empty".to_string());
}
let conn = db.get().map_err(|e| e.to_string())?;
db::delete_tag(&conn, tag).map_err(|e| e.to_string())
}
// ---------------------------------------------------------------------------
// Albums
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct CreateAlbumParams {
pub name: String,
}
#[derive(Deserialize)]
pub struct RenameAlbumParams {
pub album_id: i64,
pub new_name: String,
}
#[derive(Deserialize)]
pub struct DeleteAlbumParams {
pub album_id: i64,
}
#[derive(Deserialize)]
pub struct ReorderAlbumsParams {
pub album_ids: Vec<i64>,
}
#[derive(Deserialize)]
pub struct DeleteAlbumsParams {
pub album_ids: Vec<i64>,
}
#[derive(Deserialize)]
pub struct AlbumImagesParams {
pub album_id: i64,
pub image_ids: Vec<i64>,
}
#[derive(Deserialize)]
pub struct GetAlbumImagesParams {
pub album_id: i64,
pub sort: Option<String>,
pub offset: Option<i64>,
pub limit: Option<i64>,
}
#[tauri::command]
pub async fn list_albums(db: State<'_, DbState>) -> Result<Vec<Album>, String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::list_albums(&conn).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn create_album(
db: State<'_, DbState>,
params: CreateAlbumParams,
) -> Result<Album, String> {
let conn = db.get().map_err(|e| e.to_string())?;
let name = params.name.trim();
if name.is_empty() {
return Err("Album name cannot be empty".to_string());
}
db::create_album(&conn, name).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn rename_album(db: State<'_, DbState>, params: RenameAlbumParams) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
let name = params.new_name.trim();
if name.is_empty() {
return Err("Album name cannot be empty".to_string());
}
db::rename_album(&conn, params.album_id, name).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_album(db: State<'_, DbState>, params: DeleteAlbumParams) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::delete_album(&conn, params.album_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn reorder_albums(
db: State<'_, DbState>,
params: ReorderAlbumsParams,
) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::reorder_albums(&conn, &params.album_ids).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_albums(
db: State<'_, DbState>,
params: DeleteAlbumsParams,
) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::delete_albums(&conn, &params.album_ids).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn add_images_to_album(
db: State<'_, DbState>,
params: AlbumImagesParams,
) -> Result<i64, String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::add_images_to_album(&conn, params.album_id, &params.image_ids).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn remove_images_from_album(
db: State<'_, DbState>,
params: AlbumImagesParams,
) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::remove_images_from_album(&conn, params.album_id, &params.image_ids)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_album_images(
db: State<'_, DbState>,
params: GetAlbumImagesParams,
) -> Result<ImagesPage, String> {
let conn = db.get().map_err(|e| e.to_string())?;
let sort = params.sort.as_deref().unwrap_or("position");
let offset = params.offset.unwrap_or(0);
let limit = params.limit.unwrap_or(100);
let total = db::count_album_images(&conn, params.album_id).map_err(|e| e.to_string())?;
let images = db::get_album_images(&conn, params.album_id, sort, offset, limit)
.map_err(|e| e.to_string())?;
Ok(ImagesPage {
images,
total,
offset,
limit,
})
}
// ---------------------------------------------------------------------------
// Bulk image operations
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct BulkUpdateDetailsParams {
pub image_ids: Vec<i64>,
pub favorite: Option<bool>,
pub rating: Option<i64>,
}
#[derive(Deserialize)]
pub struct BulkAddTagsParams {
pub image_ids: Vec<i64>,
pub tags: Vec<String>,
}
#[derive(Deserialize)]
pub struct BulkRemoveTagParams {
pub image_ids: Vec<i64>,
pub tag: String,
}
#[tauri::command]
pub async fn bulk_update_details(
db: State<'_, DbState>,
params: BulkUpdateDetailsParams,
) -> Result<Vec<ImageRecord>, String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::bulk_update_details(&conn, &params.image_ids, params.favorite, params.rating)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn bulk_add_tags(
db: State<'_, DbState>,
params: BulkAddTagsParams,
) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::bulk_add_tags(&conn, &params.image_ids, &params.tags).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn bulk_remove_tag(
db: State<'_, DbState>,
params: BulkRemoveTagParams,
) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::bulk_remove_tag_by_name(&conn, &params.image_ids, &params.tag).map_err(|e| e.to_string())
}
// ---------------------------------------------------------------------------
// Queue scope / folder-id persistence
// ---------------------------------------------------------------------------
@@ -2151,6 +2512,47 @@ pub async fn open_app_data_folder(app: AppHandle) -> Result<(), String> {
.map_err(|e| e.to_string())
}
#[derive(Deserialize)]
pub struct OpenMapLocationParams {
pub lat: f64,
pub lon: f64,
}
#[tauri::command]
pub async fn open_map_location(
app: AppHandle,
params: OpenMapLocationParams,
) -> Result<(), String> {
use tauri_plugin_opener::OpenerExt;
if !params.lat.is_finite()
|| !params.lon.is_finite()
|| !(-90.0..=90.0).contains(&params.lat)
|| !(-180.0..=180.0).contains(&params.lon)
{
return Err("Invalid map coordinates".to_string());
}
let url = format!(
"https://www.openstreetmap.org/?mlat={lat:.6}&mlon={lon:.6}#map=15/{lat:.6}/{lon:.6}",
lat = params.lat,
lon = params.lon,
);
app.opener()
.open_url(url, None::<&str>)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn open_changelog_url(app: AppHandle) -> Result<(), String> {
use tauri_plugin_opener::OpenerExt;
app.opener()
.open_url(
"https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md",
None::<&str>,
)
.map_err(|e| e.to_string())
}
// ---------------------------------------------------------------------------
// Database maintenance
// ---------------------------------------------------------------------------
+416 -3
View File
@@ -34,6 +34,18 @@ pub struct Folder {
pub sort_order: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Album {
pub id: i64,
pub name: String,
pub cover_image_id: Option<i64>,
pub cover_thumbnail_path: Option<String>,
pub image_count: i64,
pub sort_order: i64,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageRecord {
pub id: i64,
@@ -285,6 +297,41 @@ pub fn migrate(conn: &Connection) -> Result<()> {
CREATE INDEX IF NOT EXISTS idx_image_tags_image_id ON image_tags(image_id);
CREATE INDEX IF NOT EXISTS idx_image_tags_source ON image_tags(source);
CREATE INDEX IF NOT EXISTS idx_image_tags_tag ON image_tags(tag);
CREATE TABLE IF NOT EXISTS albums (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
cover_image_id INTEGER REFERENCES images(id) ON DELETE SET NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
-- Forward-compat for smart albums (saved searches); unused in v1.
-- 'manual' = a curated set held in album_images.
kind TEXT NOT NULL DEFAULT 'manual',
query_json TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS album_images (
album_id INTEGER NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
added_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (album_id, image_id)
);
CREATE INDEX IF NOT EXISTS idx_album_images_album ON album_images(album_id);
CREATE INDEX IF NOT EXISTS idx_album_images_image ON album_images(image_id);
CREATE TABLE IF NOT EXISTS image_colors (
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
idx INTEGER NOT NULL,
r INTEGER NOT NULL,
g INTEGER NOT NULL,
b INTEGER NOT NULL,
weight REAL NOT NULL,
PRIMARY KEY (image_id, idx)
);
CREATE INDEX IF NOT EXISTS idx_image_colors_image ON image_colors(image_id);
",
)?;
@@ -1586,6 +1633,309 @@ pub fn reorder_folders(conn: &Connection, folder_ids: &[i64]) -> Result<()> {
Ok(())
}
// ── Albums ────────────────────────────────────────────────────────────────────
fn map_album_row(row: &Row<'_>) -> rusqlite::Result<Album> {
Ok(Album {
id: row.get(0)?,
name: row.get(1)?,
cover_image_id: row.get(2)?,
cover_thumbnail_path: row.get(3)?,
image_count: row.get(4)?,
sort_order: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
})
}
/// SELECT that resolves each album's cover thumbnail (explicit cover, else the
/// first member by position) and live image count. Shared by list/get-one.
const ALBUM_SELECT: &str = "
SELECT a.id, a.name, a.cover_image_id,
(SELECT ci.thumbnail_path FROM images ci
WHERE ci.id = COALESCE(
a.cover_image_id,
(SELECT ai.image_id FROM album_images ai
WHERE ai.album_id = a.id
ORDER BY ai.position, ai.added_at LIMIT 1)
)) AS cover_thumbnail_path,
(SELECT COUNT(*) FROM album_images ai WHERE ai.album_id = a.id) AS image_count,
a.sort_order, a.created_at, a.updated_at
FROM albums a";
pub fn list_albums(conn: &Connection) -> Result<Vec<Album>> {
let sql = format!("{ALBUM_SELECT} ORDER BY a.sort_order, a.id");
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([], map_album_row)?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
pub fn get_album(conn: &Connection, album_id: i64) -> Result<Album> {
let sql = format!("{ALBUM_SELECT} WHERE a.id = ?1");
conn.query_row(&sql, [album_id], map_album_row)
.map_err(Into::into)
}
pub fn create_album(conn: &Connection, name: &str) -> Result<Album> {
let id: i64 = conn.query_row(
"INSERT INTO albums (name, sort_order)
VALUES (?1, COALESCE((SELECT MAX(sort_order) + 1 FROM albums), 1))
RETURNING id",
params![name],
|row| row.get(0),
)?;
get_album(conn, id)
}
pub fn rename_album(conn: &Connection, album_id: i64, new_name: &str) -> Result<()> {
conn.execute(
"UPDATE albums SET name = ?2, updated_at = datetime('now') WHERE id = ?1",
params![album_id, new_name],
)?;
Ok(())
}
pub fn delete_album(conn: &Connection, album_id: i64) -> Result<()> {
// album_images rows cascade away via the FK.
conn.execute("DELETE FROM albums WHERE id = ?1", [album_id])?;
Ok(())
}
/// Delete many albums at once (membership rows cascade away via the FK).
pub fn delete_albums(conn: &Connection, album_ids: &[i64]) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for album_id in album_ids {
tx.execute("DELETE FROM albums WHERE id = ?1", [album_id])?;
}
tx.commit()?;
Ok(())
}
pub fn reorder_albums(conn: &Connection, album_ids: &[i64]) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for (index, album_id) in album_ids.iter().enumerate() {
tx.execute(
"UPDATE albums SET sort_order = ?2 WHERE id = ?1",
params![album_id, index as i64 + 1],
)?;
}
tx.commit()?;
Ok(())
}
/// Append images to an album (idempotent). Returns the number newly added.
pub fn add_images_to_album(conn: &Connection, album_id: i64, image_ids: &[i64]) -> Result<i64> {
let tx = conn.unchecked_transaction()?;
let mut next_position: i64 = tx.query_row(
"SELECT COALESCE(MAX(position) + 1, 0) FROM album_images WHERE album_id = ?1",
[album_id],
|row| row.get(0),
)?;
let mut added = 0i64;
for image_id in image_ids {
let changed = tx.execute(
"INSERT INTO album_images (album_id, image_id, position)
VALUES (?1, ?2, ?3)
ON CONFLICT(album_id, image_id) DO NOTHING",
params![album_id, image_id, next_position],
)?;
if changed > 0 {
next_position += 1;
added += 1;
}
}
tx.execute(
"UPDATE albums SET updated_at = datetime('now') WHERE id = ?1",
[album_id],
)?;
tx.commit()?;
Ok(added)
}
pub fn remove_images_from_album(conn: &Connection, album_id: i64, image_ids: &[i64]) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for image_id in image_ids {
tx.execute(
"DELETE FROM album_images WHERE album_id = ?1 AND image_id = ?2",
params![album_id, image_id],
)?;
}
tx.execute(
"UPDATE albums SET updated_at = datetime('now') WHERE id = ?1",
[album_id],
)?;
tx.commit()?;
Ok(())
}
pub fn count_album_images(conn: &Connection, album_id: i64) -> Result<i64> {
let count = conn.query_row(
"SELECT COUNT(*) FROM album_images WHERE album_id = ?1",
[album_id],
|row| row.get(0),
)?;
Ok(count)
}
pub fn get_album_images(
conn: &Connection,
album_id: i64,
sort: &str,
offset: i64,
limit: i64,
) -> Result<Vec<ImageRecord>> {
// Default to curated order (album_images.position); otherwise honor the same
// sort vocabulary as get_images. Albums span folders, so no folder filter.
let order = match sort {
"name_asc" => "i.filename ASC",
"name_desc" => "i.filename DESC",
"date_asc" => "i.modified_at ASC NULLS LAST",
"date_desc" => "i.modified_at DESC NULLS LAST",
"size_asc" => "i.file_size ASC",
"size_desc" => "i.file_size DESC",
"rating_asc" => "i.rating ASC, i.modified_at DESC NULLS LAST",
"rating_desc" => "i.rating DESC, i.modified_at DESC NULLS LAST",
"duration_asc" => "i.duration_ms ASC NULLS LAST",
"duration_desc" => "i.duration_ms DESC NULLS LAST",
"taken_asc" => "COALESCE(i.taken_at, i.modified_at) ASC NULLS LAST",
"taken_desc" => "COALESCE(i.taken_at, i.modified_at) DESC NULLS LAST",
_ => "ai.position ASC",
};
let sql = format!(
"SELECT i.id, i.folder_id, i.path, i.filename, i.thumbnail_path, i.width, i.height, i.file_size, i.created_at, i.modified_at, i.taken_at, i.mime_type,
i.media_kind, i.duration_ms, i.video_codec, i.audio_codec, i.metadata_updated_at, i.metadata_error,
i.favorite, i.rating, i.embedding_status, i.embedding_model, i.embedding_updated_at, i.embedding_error,
i.generated_caption, i.caption_model, i.caption_updated_at, i.caption_error,
i.ai_rating, i.ai_tagger_model, i.ai_tagged_at, i.ai_tagger_error
FROM images i
JOIN album_images ai ON ai.image_id = i.id
WHERE ai.album_id = ?1
ORDER BY {order}
LIMIT ?2 OFFSET ?3"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params![album_id, limit, offset], map_image_row)?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
// ── Bulk image operations ──────────────────────────────────────────────────────
/// Apply favorite and/or rating to many images at once; returns the updated rows.
pub fn bulk_update_details(
conn: &Connection,
image_ids: &[i64],
favorite: Option<bool>,
rating: Option<i64>,
) -> Result<Vec<ImageRecord>> {
let tx = conn.unchecked_transaction()?;
for image_id in image_ids {
tx.execute(
"UPDATE images
SET favorite = COALESCE(?2, favorite),
rating = COALESCE(?3, rating)
WHERE id = ?1",
params![image_id, favorite, rating],
)?;
}
tx.commit()?;
get_images_by_ids(conn, image_ids)
}
/// Add one or more user tags to many images at once.
pub fn bulk_add_tags(conn: &Connection, image_ids: &[i64], tags: &[String]) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for image_id in image_ids {
for tag in tags {
let trimmed = tag.trim();
if trimmed.is_empty() {
continue;
}
tx.execute(
"INSERT INTO image_tags (image_id, tag, source, ai_model, confidence, created_at)
VALUES (?1, ?2, 'user', NULL, NULL, datetime('now'))
ON CONFLICT(image_id, tag) DO UPDATE SET
source = 'user',
ai_model = NULL,
confidence = NULL
WHERE source = 'ai'",
params![image_id, trimmed],
)?;
}
}
tx.commit()?;
Ok(())
}
/// Remove a tag (by name) from many images at once.
pub fn bulk_remove_tag_by_name(conn: &Connection, image_ids: &[i64], tag: &str) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for image_id in image_ids {
tx.execute(
"DELETE FROM image_tags WHERE image_id = ?1 AND tag = ?2",
params![image_id, tag],
)?;
}
tx.commit()?;
Ok(())
}
// ── Color palettes (color search) ──────────────────────────────────────────────
/// Replace an image's stored color palette. `colors` is `(r, g, b, weight)`.
pub fn replace_image_colors(
conn: &Connection,
image_id: i64,
colors: &[(u8, u8, u8, f32)],
) -> Result<()> {
conn.execute("DELETE FROM image_colors WHERE image_id = ?1", [image_id])?;
for (idx, (r, g, b, weight)) in colors.iter().enumerate() {
conn.execute(
"INSERT INTO image_colors (image_id, idx, r, g, b, weight)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
image_id,
idx as i64,
*r as i64,
*g as i64,
*b as i64,
*weight as f64
],
)?;
}
Ok(())
}
/// Images (with thumbnails) that have no stored palette yet — the backfill set.
/// Returns `(image_id, thumbnail_path)`.
pub fn get_images_missing_colors(conn: &Connection, limit: i64) -> Result<Vec<(i64, String)>> {
let mut stmt = conn.prepare(
"SELECT i.id, i.thumbnail_path
FROM images i
WHERE i.media_kind = 'image'
AND i.thumbnail_path IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM image_colors c WHERE c.image_id = i.id)
LIMIT ?1",
)?;
let rows = stmt.query_map([limit], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
/// Count of images still awaiting palette extraction (for backfill progress).
pub fn count_images_missing_colors(conn: &Connection) -> Result<i64> {
let count = conn.query_row(
"SELECT COUNT(*)
FROM images i
WHERE i.media_kind = 'image'
AND i.thumbnail_path IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM image_colors c WHERE c.image_id = i.id)",
[],
|row| row.get(0),
)?;
Ok(count)
}
pub fn repair_deferred_embedding_jobs(conn: &Connection) -> Result<usize> {
let pattern = "No thumbnail available yet for%";
let repaired = conn.execute(
@@ -1713,6 +2063,7 @@ pub fn get_images(
rating_min: i64,
embedding_failed_only: bool,
tagging_failed_only: bool,
color: Option<(u8, u8, u8)>,
sort: &str,
offset: i64,
limit: i64,
@@ -1737,6 +2088,10 @@ pub fn get_images(
let favorites_flag = i64::from(favorites_only);
let embedding_failed_flag = i64::from(embedding_failed_only);
let tagging_failed_flag = i64::from(tagging_failed_only);
let (color_flag, qr, qg, qb) = match color {
Some((r, g, b)) => (1i64, r as i64, g as i64, b as i64),
None => (0, 0, 0, 0),
};
let sql = format!(
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type,
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
@@ -1751,6 +2106,12 @@ pub fn get_images(
AND rating >= ?5
AND (?6 = 0 OR embedding_status = 'failed')
AND (?7 = 0 OR ai_tagger_error IS NOT NULL)
AND (?10 = 0 OR EXISTS (
SELECT 1 FROM image_colors c
WHERE c.image_id = images.id
AND c.weight >= ?15
AND ((c.r - ?11)*(c.r - ?11) + (c.g - ?12)*(c.g - ?12) + (c.b - ?13)*(c.b - ?13)) <= ?14
))
ORDER BY {order}
LIMIT ?8 OFFSET ?9"
);
@@ -1765,7 +2126,13 @@ pub fn get_images(
embedding_failed_flag,
tagging_failed_flag,
limit,
offset
offset,
color_flag,
qr,
qg,
qb,
crate::color::MATCH_DISTANCE_SQ,
crate::color::MATCH_MIN_WEIGHT
],
map_image_row,
)?;
@@ -1782,12 +2149,17 @@ pub fn count_images(
rating_min: i64,
embedding_failed_only: bool,
tagging_failed_only: bool,
color: Option<(u8, u8, u8)>,
) -> Result<i64> {
let search_pattern = search.map(|value| format!("%{value}%"));
let favorites_flag = i64::from(favorites_only);
let embedding_failed_flag = i64::from(embedding_failed_only);
let tagging_failed_flag = i64::from(tagging_failed_only);
let (color_flag, qr, qg, qb) = match color {
Some((r, g, b)) => (1i64, r as i64, g as i64, b as i64),
None => (0, 0, 0, 0),
};
let count = conn.query_row(
"SELECT COUNT(*) FROM images
WHERE (?1 IS NULL OR folder_id = ?1)
@@ -1796,7 +2168,13 @@ pub fn count_images(
AND (?4 = 0 OR favorite = 1)
AND rating >= ?5
AND (?6 = 0 OR embedding_status = 'failed')
AND (?7 = 0 OR ai_tagger_error IS NOT NULL)",
AND (?7 = 0 OR ai_tagger_error IS NOT NULL)
AND (?8 = 0 OR EXISTS (
SELECT 1 FROM image_colors c
WHERE c.image_id = images.id
AND c.weight >= ?13
AND ((c.r - ?9)*(c.r - ?9) + (c.g - ?10)*(c.g - ?10) + (c.b - ?11)*(c.b - ?11)) <= ?12
))",
params![
folder_id,
search_pattern,
@@ -1804,7 +2182,13 @@ pub fn count_images(
favorites_flag,
rating_min,
embedding_failed_flag,
tagging_failed_flag
tagging_failed_flag,
color_flag,
qr,
qg,
qb,
crate::color::MATCH_DISTANCE_SQ,
crate::color::MATCH_MIN_WEIGHT
],
|row| row.get(0),
)?;
@@ -2338,6 +2722,35 @@ pub fn remove_tag(conn: &Connection, tag_id: i64) -> Result<()> {
Ok(())
}
/// Rename a tag across the whole library, or merge it into an existing tag when
/// `to` already exists. Images that already carry `to` keep a single instance
/// (the colliding source row is dropped).
pub fn rename_tag(conn: &Connection, from: &str, to: &str) -> Result<()> {
let tx = conn.unchecked_transaction()?;
// Move rows where the target tag isn't already on that image; UNIQUE
// collisions are skipped (OR IGNORE)…
tx.execute(
"UPDATE OR IGNORE image_tags SET tag = ?2 WHERE tag = ?1",
params![from, to],
)?;
// …then drop the now-duplicate leftovers still under the old name.
tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![from])?;
// The tag-cloud cache keys on image-id hashes, not tag text, so a rename
// wouldn't invalidate it automatically — clear it.
tx.execute("DELETE FROM tag_cloud_cache", [])?;
tx.commit()?;
Ok(())
}
/// Delete a tag from every image in the library. Returns the number of rows removed.
pub fn delete_tag(conn: &Connection, name: &str) -> Result<i64> {
let tx = conn.unchecked_transaction()?;
let removed = tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![name])? as i64;
tx.execute("DELETE FROM tag_cloud_cache", [])?;
tx.commit()?;
Ok(removed)
}
pub fn enqueue_missing_tagging_jobs_for_folder(conn: &Connection, folder_id: i64) -> Result<usize> {
let inserted = conn.execute(
"INSERT INTO tagging_jobs (image_id, status, attempts, last_error, created_at, updated_at)
+12 -4
View File
@@ -92,6 +92,7 @@ pub fn find_similar_image_matches(
conn: &Connection,
image_id: i64,
folder_id: Option<i64>,
album_id: Option<i64>,
threshold: f32,
offset: usize,
limit: usize,
@@ -103,10 +104,17 @@ pub fn find_similar_image_matches(
None => return Ok(Vec::new()),
};
// Fetch folder image IDs *before* acquiring the read lock so we don't hold
// Build the allowed-id set *before* acquiring the read lock so we don't hold
// the lock across a potentially slow SQLite query, which would delay any
// concurrent ensure_index call waiting for a write lock.
let folder_image_ids: Option<Vec<i64>> = if let Some(folder_id) = folder_id {
// concurrent ensure_index call waiting for a write lock. Album scope takes
// precedence over folder scope; both reuse the HNSW filtered search.
let allowed_image_ids: Option<Vec<i64>> = if let Some(album_id) = album_id {
let mut stmt = conn.prepare("SELECT image_id FROM album_images WHERE album_id = ?1")?;
let ids = stmt
.query_map([album_id], |row| row.get::<_, i64>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
Some(ids)
} else if let Some(folder_id) = folder_id {
let ids = vector::get_all_image_embeddings_with_ids(conn, Some(folder_id))?
.into_iter()
.map(|(id, _)| id)
@@ -122,7 +130,7 @@ pub fn find_similar_image_matches(
};
let knbn = (offset + limit).max(limit).saturating_add(32);
let neighbours: Vec<Neighbour> = if let Some(image_ids) = folder_image_ids {
let neighbours: Vec<Neighbour> = if let Some(image_ids) = allowed_image_ids {
let mut allowed_ids = image_ids
.into_iter()
.filter_map(|allowed_image_id| {
+84
View File
@@ -190,6 +190,13 @@ pub struct MediaUpdateBatch {
pub images: Vec<ImageRecord>,
}
#[derive(Clone, Serialize)]
pub struct ColorBackfillProgress {
pub processed: i64,
pub total: i64,
pub done: bool,
}
#[derive(Clone, Serialize)]
pub struct MediaJobProgressEvent {
pub progress: Vec<FolderJobProgress>,
@@ -737,6 +744,13 @@ fn persist_thumbnail_results(
width,
height,
)?);
// Store the dominant-color palette sampled during resizing.
if let Some(thumb) = &generated {
if !thumb.palette.is_empty() {
db::replace_image_colors(&tx, image_id, &thumb.palette)?;
}
}
}
tx.commit()?;
@@ -767,6 +781,76 @@ fn is_avif_path(path: &Path) -> bool {
.is_some_and(|ext| ext.eq_ignore_ascii_case("avif"))
}
/// One-shot background pass that samples a color palette from the (already
/// generated) thumbnails of images indexed before color search existed. New
/// images get their palette during thumbnail generation, so this only fills the
/// historical gap. Emits `color-backfill-progress` so the UI can show a count.
pub fn start_color_backfill(app: AppHandle, pool: DbPool) {
std::thread::spawn(move || {
let total = match pool.get() {
Ok(conn) => db::count_images_missing_colors(&conn).unwrap_or(0),
Err(_) => return,
};
if total == 0 {
return;
}
log::info!("Color backfill: sampling palettes for {total} images.");
let mut processed: i64 = 0;
loop {
let batch = match pool.get() {
Ok(conn) => db::get_images_missing_colors(&conn, 64).unwrap_or_default(),
Err(_) => break,
};
if batch.is_empty() {
break;
}
for (image_id, thumbnail_path) in batch {
let palette = crate::color::extract_palette_from_file(
Path::new(&thumbnail_path),
crate::color::PALETTE_SIZE,
);
let colors: Vec<(u8, u8, u8, f32)> = match palette {
Some(colors) if !colors.is_empty() => colors
.into_iter()
.map(|c| (c.r, c.g, c.b, c.weight))
.collect(),
// Unreadable thumbnail: store a zero-weight sentinel so the
// image isn't reprocessed forever (it just won't match).
_ => vec![(0, 0, 0, 0.0)],
};
let _ = with_db_write_lock(|| {
let conn = pool.get()?;
db::replace_image_colors(&conn, image_id, &colors)
});
processed += 1;
}
let _ = app.emit(
"color-backfill-progress",
ColorBackfillProgress {
processed,
total,
done: false,
},
);
// Yield so the backfill stays in the background under active use.
std::thread::sleep(Duration::from_millis(15));
}
log::info!("Color backfill complete: {processed} images sampled.");
let _ = app.emit(
"color-backfill-progress",
ColorBackfillProgress {
processed,
total,
done: true,
},
);
});
}
/// Returns `Ok(true)` if a batch was claimed and processed, `Ok(false)` if
/// the queue was empty.
fn process_metadata_batch(
+21
View File
@@ -1,4 +1,5 @@
mod captioner;
mod color;
mod commands;
mod db;
mod embedder;
@@ -122,6 +123,8 @@ pub fn run() {
// Caption worker disabled — UI removed; keeping backend code intact for future use.
// indexer::start_caption_worker(app.handle().clone(), pool.clone(), app_dir.clone());
indexer::start_tagging_worker(app.handle().clone(), pool.clone(), app_dir.clone());
// Backfill color palettes for images indexed before color search existed.
indexer::start_color_backfill(app.handle().clone(), pool.clone());
let watcher_handle = indexer::start_watcher(app.handle().clone(), pool.clone(), thumb_dir.clone());
@@ -185,6 +188,22 @@ pub fn run() {
commands::get_image_tags,
commands::add_user_tag,
commands::remove_tag,
commands::rename_tag,
commands::delete_tag,
commands::get_image_exif,
commands::list_albums,
commands::create_album,
commands::rename_album,
commands::delete_album,
commands::delete_albums,
commands::reorder_albums,
commands::add_images_to_album,
commands::remove_images_from_album,
commands::get_album_images,
commands::bulk_update_details,
commands::bulk_add_tags,
commands::bulk_remove_tag,
commands::get_build_variant,
commands::search_tags_autocomplete,
commands::find_duplicates,
commands::load_duplicate_scan_cache,
@@ -197,6 +216,8 @@ pub fn run() {
commands::get_tagging_queue_folder_ids,
commands::set_tagging_queue_folder_ids,
commands::open_app_data_folder,
commands::open_map_location,
commands::open_changelog_url,
commands::get_database_info,
commands::vacuum_database,
commands::rebuild_semantic_index,
+23
View File
@@ -12,6 +12,9 @@ pub struct GeneratedThumbnail {
pub path: PathBuf,
pub width: Option<i64>,
pub height: Option<i64>,
/// Dominant-color palette `(r, g, b, weight)` sampled while resizing. Empty
/// when the thumbnail already existed (the color backfill handles those).
pub palette: Vec<(u8, u8, u8, f32)>,
}
pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<GeneratedThumbnail> {
@@ -28,6 +31,7 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
path: out_path,
width: original_dimensions.0,
height: original_dimensions.1,
palette: Vec::new(),
});
}
@@ -47,6 +51,12 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
let thumb = image::RgbImage::from_raw(dst_width, dst_height, dst.buffer().to_vec())
.ok_or_else(|| anyhow!("failed to construct resized thumbnail buffer"))?;
// Sample the dominant-color palette from the resized buffer before encoding.
let palette = crate::color::extract_palette(&thumb, crate::color::PALETTE_SIZE)
.into_iter()
.map(|color| (color.r, color.g, color.b, color.weight))
.collect();
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
@@ -58,6 +68,7 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
path: out_path,
width: original_dimensions.0,
height: original_dimensions.1,
palette,
})
}
@@ -166,6 +177,7 @@ pub fn generate_video_thumbnail(
path: out_path,
width: None,
height: None,
palette: Vec::new(),
});
}
@@ -231,6 +243,7 @@ pub fn generate_video_thumbnail(
path: out_path,
width: None,
height: None,
palette: Vec::new(),
});
}
last_error = String::from_utf8_lossy(&output.stderr).to_string();
@@ -255,6 +268,7 @@ pub fn generate_avif_thumbnail(
path: out_path,
width: original_dimensions.0,
height: original_dimensions.1,
palette: Vec::new(),
});
}
@@ -282,10 +296,19 @@ pub fn generate_avif_thumbnail(
.output()?;
if output.status.success() && out_path.exists() {
// AVIF is decoded by FFmpeg, so there's no in-memory RGB buffer here;
// sample the palette by reading the JPEG thumbnail we just wrote.
let palette =
crate::color::extract_palette_from_file(&out_path, crate::color::PALETTE_SIZE)
.unwrap_or_default()
.into_iter()
.map(|color| (color.r, color.g, color.b, color.weight))
.collect();
return Ok(GeneratedThumbnail {
path: out_path,
width: original_dimensions.0,
height: original_dimensions.1,
palette,
});
}
+35
View File
@@ -372,6 +372,41 @@ pub fn search_image_ids_by_embedding_in_folder(
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
/// Brute-force cosine search scoped to a single album (membership via
/// `album_images`), ordered by ascending distance. Mirrors the folder-scoped
/// variant for region-based similarity search.
pub fn search_image_ids_by_embedding_in_album(
conn: &Connection,
embedding: &[f32],
album_id: i64,
exclude_image_id: Option<i64>,
limit: usize,
) -> Result<Vec<i64>> {
if embedding.len() != CLIP_VECTOR_DIM {
return Err(anyhow!(
"expected {}-dimensional embedding, got {}",
CLIP_VECTOR_DIM,
embedding.len()
));
}
let packed = pack_f32(embedding);
let exclude_id = exclude_image_id.unwrap_or(-1);
let mut stmt = conn.prepare(
"SELECT v.image_id
FROM image_vec v
JOIN album_images ai ON ai.image_id = v.image_id
WHERE ai.album_id = ?2
AND v.image_id != ?3
ORDER BY vec_distance_cosine(v.embedding, vec_f32(?1)) ASC
LIMIT ?4",
)?;
let rows = stmt.query_map((&packed, album_id, exclude_id, limit as i64), |row| {
row.get::<_, i64>(0)
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
#[allow(dead_code)]
pub fn search_caption_ids_by_embedding(
conn: &Connection,
+2
View File
@@ -24,6 +24,7 @@ export default function App() {
const loadImages = useGalleryStore((state) => state.loadImages);
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus);
const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache);
const loadAlbums = useGalleryStore((state) => state.loadAlbums);
const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds);
const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused);
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
@@ -51,6 +52,7 @@ export default function App() {
void loadBackgroundJobProgress();
void loadCaptionModelStatus();
void loadDuplicateScanCache();
void loadAlbums();
return loadImages(true);
});
let unlisten: (() => void) | undefined;
+276
View File
@@ -0,0 +1,276 @@
import { useEffect, useRef, useState } from "react";
import { useGalleryStore } from "../store";
import { BulkTagPopover } from "./bulk/BulkTagPopover";
type Panel = "tag" | "rating" | "album" | "delete" | null;
export function BulkActionBar() {
const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size);
const selectedIds = useGalleryStore((state) => state.gallerySelectedIds);
const clearGallerySelection = useGalleryStore((state) => state.clearGallerySelection);
const selectAllGallery = useGalleryStore((state) => state.selectAllGallery);
const loadedCount = useGalleryStore((state) => state.loadedCount);
const totalImages = useGalleryStore((state) => state.totalImages);
const bulkSetFavorite = useGalleryStore((state) => state.bulkSetFavorite);
const bulkSetRating = useGalleryStore((state) => state.bulkSetRating);
const bulkDeleteSelected = useGalleryStore((state) => state.bulkDeleteSelected);
const activeView = useGalleryStore((state) => state.activeView);
const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId);
const albums = useGalleryStore((state) => state.albums);
const addToAlbum = useGalleryStore((state) => state.addToAlbum);
const removeFromAlbum = useGalleryStore((state) => state.removeFromAlbum);
const createAlbum = useGalleryStore((state) => state.createAlbum);
const [panel, setPanel] = useState<Panel>(null);
const [deleting, setDeleting] = useState(false);
const [creatingAlbum, setCreatingAlbum] = useState(false);
const [newAlbumName, setNewAlbumName] = useState("");
const barRef = useRef<HTMLDivElement>(null);
// Close any open popover when clicking outside the bar.
useEffect(() => {
const onPointerDown = (event: PointerEvent) => {
if (barRef.current?.contains(event.target as Node)) return;
setPanel(null);
};
window.addEventListener("pointerdown", onPointerDown);
return () => window.removeEventListener("pointerdown", onPointerDown);
}, []);
// Reset transient UI whenever the selection empties.
useEffect(() => {
if (selectedCount === 0) {
setPanel(null);
setNewAlbumName("");
}
}, [selectedCount]);
if (selectedCount === 0) return null;
const ids = Array.from(selectedIds);
const inAlbumView = activeView === "album" && selectedAlbumId !== null;
const togglePanel = (next: Exclude<Panel, null>) => setPanel((current) => (current === next ? null : next));
const handleDelete = async () => {
setDeleting(true);
try {
await bulkDeleteSelected();
} finally {
setDeleting(false);
setPanel(null);
}
};
const handlePickAlbum = async (albumId: number) => {
await addToAlbum(albumId, ids);
setPanel(null);
};
const handleCreateAlbum = async () => {
const name = newAlbumName.trim();
if (!name || creatingAlbum) return;
setCreatingAlbum(true);
try {
const album = await createAlbum(name);
await addToAlbum(album.id, ids);
setNewAlbumName("");
setPanel(null);
} finally {
setCreatingAlbum(false);
}
};
const btn = "rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors";
const btnIdle = `${btn} text-gray-300 hover:bg-white/10 hover:text-white`;
const btnActive = `${btn} bg-white/10 text-white`;
return (
<div
ref={barRef}
className="pointer-events-auto absolute bottom-6 left-1/2 z-30 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-white/10 bg-gray-950/95 px-2 py-1.5 shadow-2xl shadow-black/50 backdrop-blur"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center gap-2 px-1.5">
<span className="text-xs font-medium text-white">{selectedCount} selected</span>
{loadedCount < totalImages || loadedCount > selectedCount ? (
<button
className="text-[11px] text-gray-500 transition-colors hover:text-gray-300"
onClick={selectAllGallery}
title={loadedCount < totalImages ? "Selects loaded items only" : "Select all loaded"}
>
Select all{loadedCount < totalImages ? " loaded" : ""}
</button>
) : null}
</div>
<div className="h-5 w-px bg-white/10" />
<div className="relative">
<button className={panel === "tag" ? btnActive : btnIdle} onClick={() => togglePanel("tag")}>
Tag
</button>
{panel === "tag" ? <BulkTagPopover onClose={() => setPanel(null)} /> : null}
</div>
<div className="relative">
<button className={panel === "rating" ? btnActive : btnIdle} onClick={() => togglePanel("rating")}>
Rating
</button>
{panel === "rating" ? (
<div
data-bulk-popover
className="absolute bottom-full left-1/2 mb-2 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-white/10 bg-gray-950/98 p-2 shadow-2xl backdrop-blur"
>
{Array.from({ length: 5 }, (_, index) => {
const rating = index + 1;
return (
<button
key={rating}
className="rounded-md p-1 text-white/25 transition-colors hover:bg-white/5 hover:text-amber-300"
onClick={async () => {
await bulkSetRating(rating);
setPanel(null);
}}
title={`Set ${rating} star${rating === 1 ? "" : "s"}`}
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</button>
);
})}
<button
className="ml-1 rounded-md border border-white/10 px-2 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/5 hover:text-white"
onClick={async () => {
await bulkSetRating(0);
setPanel(null);
}}
>
Clear
</button>
</div>
) : null}
</div>
<button className={btnIdle} onClick={() => void bulkSetFavorite(true)} title="Mark as favorite">
Favorite
</button>
<div className="relative">
<button className={panel === "album" ? btnActive : btnIdle} onClick={() => togglePanel("album")}>
Add to album
</button>
{panel === "album" ? (
<div
data-bulk-popover
className="absolute bottom-full left-1/2 mb-2 w-60 -translate-x-1/2 rounded-xl border border-white/10 bg-gray-950/98 p-2 shadow-2xl backdrop-blur"
>
<div className="max-h-48 overflow-y-auto">
{albums.length === 0 ? (
<p className="px-2 py-2 text-[11px] text-gray-600">No albums yet create one below.</p>
) : (
albums.map((album) => (
<button
key={album.id}
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs text-gray-300 transition-colors hover:bg-white/5 hover:text-white"
onClick={() => void handlePickAlbum(album.id)}
>
<span className="truncate">{album.name}</span>
<span className="shrink-0 text-[10px] text-gray-600">{album.image_count}</span>
</button>
))
)}
</div>
<form
className="mt-1 flex gap-1 border-t border-white/[0.06] pt-2"
onSubmit={(event) => {
event.preventDefault();
void handleCreateAlbum();
}}
>
<input
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white placeholder-gray-600 focus:border-white/20 focus:outline-none"
placeholder="New album…"
value={newAlbumName}
onChange={(event) => setNewAlbumName(event.target.value)}
disabled={creatingAlbum}
/>
<button
type="submit"
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50"
disabled={creatingAlbum || !newAlbumName.trim()}
>
Add
</button>
</form>
</div>
) : null}
</div>
{inAlbumView ? (
<button
className={`${btn} text-amber-300/90 hover:bg-amber-500/10 hover:text-amber-200`}
onClick={() => void removeFromAlbum(selectedAlbumId, ids)}
>
Remove from album
</button>
) : null}
<div className="h-5 w-px bg-white/10" />
<div className="relative">
<button
className={panel === "delete" ? `${btn} bg-red-500/15 text-red-300` : `${btn} text-gray-300 hover:bg-red-500/10 hover:text-red-300`}
onClick={() => togglePanel("delete")}
disabled={deleting}
title="Delete files from disk"
>
{deleting ? "Deleting…" : "Delete"}
</button>
{panel === "delete" ? (
<div
data-bulk-popover
className="absolute bottom-full left-1/2 mb-2 w-64 -translate-x-1/2 rounded-xl border border-red-500/30 bg-gray-950/98 p-3 shadow-2xl backdrop-blur"
>
<div className="mb-1 flex items-center gap-1.5 text-red-300">
<svg className="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<p className="text-xs font-semibold">Delete from disk</p>
</div>
<p className="mb-2.5 text-[11px] leading-relaxed text-gray-400">
Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer.
This removes the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone.
</p>
<div className="flex justify-end gap-1.5">
<button
className="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setPanel(null)}
>
Cancel
</button>
<button
className="rounded-md bg-red-500/20 px-2.5 py-1 text-[11px] font-medium text-red-300 transition-colors hover:bg-red-500/30 hover:text-red-200 disabled:opacity-50"
onClick={() => void handleDelete()}
disabled={deleting}
>
{deleting ? "Deleting…" : `Delete ${selectedCount} from disk`}
</button>
</div>
</div>
) : null}
</div>
<button
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={clearGallerySelection}
title="Clear selection"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
}
+158
View File
@@ -0,0 +1,158 @@
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../store";
import { Tooltip } from "./Tooltip";
type Rgb = [number, number, number];
// Representative colors for the quick-pick swatches. Each is just an RGB the
// distance filter matches against — not a hard bucket.
const SWATCHES: { name: string; rgb: Rgb }[] = [
{ name: "Red", rgb: [226, 59, 59] },
{ name: "Orange", rgb: [232, 134, 46] },
{ name: "Yellow", rgb: [242, 207, 46] },
{ name: "Green", rgb: [76, 175, 80] },
{ name: "Teal", rgb: [31, 182, 166] },
{ name: "Blue", rgb: [59, 125, 216] },
{ name: "Purple", rgb: [139, 92, 246] },
{ name: "Pink", rgb: [236, 72, 153] },
{ name: "Brown", rgb: [139, 90, 43] },
{ name: "Black", rgb: [26, 26, 26] },
{ name: "White", rgb: [245, 245, 245] },
{ name: "Gray", rgb: [154, 160, 166] },
];
function rgbEquals(a: Rgb | null, b: Rgb): boolean {
return a !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
}
function toHex([r, g, b]: Rgb): string {
return `#${[r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("")}`;
}
function fromHex(hex: string): Rgb {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export function ColorFilter() {
const colorFilter = useGalleryStore((state) => state.colorFilter);
const setColorFilter = useGalleryStore((state) => state.setColorFilter);
const colorBackfill = useGalleryStore((state) => state.colorBackfill);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const isActive = colorFilter !== null;
const isCustom = isActive && !SWATCHES.some((swatch) => rgbEquals(colorFilter, swatch.rgb));
// Collapse the panel when clicking elsewhere.
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
};
window.addEventListener("pointerdown", onPointerDown);
return () => window.removeEventListener("pointerdown", onPointerDown);
}, [open]);
return (
<div ref={ref} className="ml-1 flex items-center border-l border-white/[0.06] pl-2">
{/* Trigger a single palette icon; shows the active color as a dot when a
filter is applied so the collapsed state still communicates it. */}
<Tooltip label={isActive ? "Color filter active" : "Filter by color"} delay={400}>
<button
className={`relative flex items-center gap-1.5 rounded-lg px-2 py-1.5 transition-colors ${
open || isActive ? "bg-white/10 text-white" : "text-gray-500 hover:bg-white/5 hover:text-gray-200"
}`}
onClick={() => setOpen((value) => !value)}
aria-label="Filter by color"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6}
d="M12 3a9 9 0 100 18c.83 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.01-.23-.26-.39-.61-.39-1 0-.83.67-1.5 1.5-1.5H16a5 5 0 005-5c0-4.42-4.03-8-9-8z" />
<circle cx="7.5" cy="11.5" r="1" fill="currentColor" stroke="none" />
<circle cx="11.5" cy="7.5" r="1" fill="currentColor" stroke="none" />
<circle cx="15.5" cy="9.5" r="1" fill="currentColor" stroke="none" />
</svg>
{isActive ? (
<span
className="h-3 w-3 rounded-full border border-white/30"
style={{ backgroundColor: toHex(colorFilter as Rgb) }}
/>
) : null}
</button>
</Tooltip>
<AnimatePresence initial={false}>
{open ? (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: "auto", opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
// Horizontal/vertical padding gives the active swatch's ring + scale
// room inside the overflow-hidden clip box (needed for the width slide).
// py-1 keeps the panel shorter than the always-present filter pills so
// opening it never changes the row height (no 1px toolbar shift).
className="flex items-center gap-1 overflow-hidden whitespace-nowrap px-1.5 py-1"
>
{SWATCHES.map((swatch) => {
const active = rgbEquals(colorFilter, swatch.rgb);
return (
<button
key={swatch.name}
title={swatch.name}
aria-label={`Filter by ${swatch.name}`}
className={`h-4 w-4 shrink-0 rounded-full border transition-transform ${
active ? "scale-110 border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110"
}`}
style={{ backgroundColor: toHex(swatch.rgb) }}
onClick={() => setColorFilter(active ? null : swatch.rgb)}
/>
);
})}
{/* Custom color picker — rainbow until a custom color is chosen. */}
<label
title="Custom color"
className={`relative h-4 w-4 shrink-0 cursor-pointer overflow-hidden rounded-full border ${
isCustom ? "border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110"
}`}
style={
isCustom
? { backgroundColor: toHex(colorFilter as Rgb) }
: { background: "conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)" }
}
>
<input
type="color"
className="absolute inset-0 cursor-pointer opacity-0"
value={colorFilter ? toHex(colorFilter) : "#3b7dd8"}
onChange={(event) => setColorFilter(fromHex(event.target.value))}
/>
</label>
{isActive ? (
<button
className="ml-0.5 shrink-0 rounded px-1 text-[11px] text-gray-500 transition-colors hover:text-gray-200"
onClick={() => setColorFilter(null)}
title="Clear color filter"
>
Clear
</button>
) : null}
{colorBackfill && colorBackfill.total > 0 ? (
<span
className="ml-0.5 shrink-0 text-[10px] text-gray-600"
title="Sampling colors from existing thumbnails — color search fills in as this runs"
>
sampling {colorBackfill.processed.toLocaleString()}/{colorBackfill.total.toLocaleString()}
</span>
) : null}
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
+38 -1
View File
@@ -129,6 +129,7 @@ export function DuplicateFinder() {
const deleteSelectedDuplicates = useGalleryStore((state) => state.deleteSelectedDuplicates);
const [deleting, setDeleting] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [deleteResult, setDeleteResult] = useState<string | null>(null);
// Virtualize the group list so a large result set (e.g. thousands of pairs)
@@ -153,6 +154,7 @@ export function DuplicateFinder() {
const handleDelete = async () => {
setDeleting(true);
setConfirmingDelete(false);
setDeleteResult(null);
try {
const deleted = await deleteSelectedDuplicates();
@@ -222,13 +224,48 @@ export function DuplicateFinder() {
>
Deselect all
</button>
<div className="relative">
<button
className="rounded-lg border border-red-400/25 bg-red-500/10 px-3 py-1.5 text-xs text-red-300 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-40"
onClick={handleDelete}
onClick={() => setConfirmingDelete((v) => !v)}
disabled={deleting}
>
{deleting ? "Deleting…" : `Delete ${selectedCount} file${selectedCount === 1 ? "" : "s"}`}
</button>
{confirmingDelete && !deleting ? (
<>
{/* Click-away backdrop */}
<div className="fixed inset-0 z-40" onClick={() => setConfirmingDelete(false)} />
<div className="absolute right-0 top-full z-50 mt-2 w-72 rounded-xl border border-red-500/30 bg-gray-950/98 p-3 text-left shadow-2xl backdrop-blur">
<div className="mb-1 flex items-center gap-1.5 text-red-300">
<svg className="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<p className="text-xs font-semibold">Delete from disk</p>
</div>
<p className="mb-2.5 text-[11px] leading-relaxed text-gray-400">
Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer.
This removes the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone.
</p>
<div className="flex justify-end gap-1.5">
<button
className="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setConfirmingDelete(false)}
>
Cancel
</button>
<button
className="rounded-md bg-red-500/20 px-2.5 py-1 text-[11px] font-medium text-red-300 transition-colors hover:bg-red-500/30 hover:text-red-200"
onClick={handleDelete}
>
Delete {selectedCount} from disk
</button>
</div>
</div>
</>
) : null}
</div>
</>
) : null}
<button
+80 -30
View File
@@ -2,6 +2,8 @@ import { useEffect, useLayoutEffect, useRef, useCallback, useMemo, useState } fr
import { useVirtualizer } from "@tanstack/react-virtual";
import { convertFileSrc } from "@tauri-apps/api/core";
import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store";
import { BulkActionBar } from "./BulkActionBar";
import { Tooltip } from "./Tooltip";
const GAP = 6;
@@ -30,8 +32,7 @@ export function ContextMenu({
}) {
const openImage = useGalleryStore((state) => state.openImage);
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
const similarScope = useGalleryStore((state) => state.similarScope);
const findSimilar = useGalleryStore((state) => state.findSimilar);
const canFindSimilar = image.embedding_status === "ready";
return (
@@ -59,9 +60,9 @@ export function ContextMenu({
? "text-gray-200 hover:bg-white/5 hover:text-white"
: "text-gray-600 cursor-not-allowed"
}`}
onClick={async () => {
onClick={() => {
if (!canFindSimilar) return;
await loadSimilarImages(image.id, similarScope === "current_folder" ? image.folder_id : null, true, image.folder_id);
findSimilar(image.id, image.folder_id);
onClose();
}}
disabled={!canFindSimilar}
@@ -112,24 +113,70 @@ export function ImageTile({
}: {
image: ImageRecord;
onClick: () => void;
onContextMenu: (event: React.MouseEvent<HTMLButtonElement>) => void;
onContextMenu: (event: React.MouseEvent<HTMLDivElement>) => void;
}) {
const [loaded, setLoaded] = useState(false);
const [errored, setErrored] = useState(false);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
const similarScope = useGalleryStore((state) => state.similarScope);
const findSimilar = useGalleryStore((state) => state.findSimilar);
const selected = useGalleryStore((state) => state.gallerySelectedIds.has(image.id));
const selectionActive = useGalleryStore((state) => state.gallerySelectedIds.size > 0);
const toggleGallerySelected = useGalleryStore((state) => state.toggleGallerySelected);
const canFindSimilar = image.embedding_status === "ready";
const src = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) : null;
return (
<button
className="media-dark-surface group relative overflow-hidden rounded-xl bg-white/[0.04] text-left focus:outline-none"
<Tooltip label={image.filename} delay={500} block followCursor>
<div
className={`media-dark-surface group relative overflow-hidden rounded-xl bg-white/[0.04] text-left focus:outline-none transition-shadow ${
selected ? "ring-2 ring-inset ring-blue-400/80" : ""
}`}
style={{ width: "100%", aspectRatio: "1 / 1" }}
onClick={onClick}
onContextMenu={onContextMenu}
title={image.filename}
>
{/* Full-tile click target opens, or toggles selection while selecting.
A real button (over the non-interactive tile div) keeps it keyboard-
accessible without nesting buttons. */}
<button
type="button"
className="absolute inset-0 z-10 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
aria-label={`Open ${image.filename}`}
onClick={(event) => {
event.stopPropagation();
if (selectionActive) toggleGallerySelected(image.id);
else onClick();
}}
onDoubleClick={(event) => {
event.stopPropagation();
onClick();
}}
/>
{/* Selection corner a top-left zone that reveals the checkbox only when
hovered (not the whole tile) and toggles selection on click. The
checkbox stays visible once the item is selected. */}
<button
type="button"
role="checkbox"
aria-checked={selected}
aria-label={selected ? "Deselect" : "Select"}
className="group/cb absolute top-0 left-0 z-20 h-11 w-11 cursor-pointer"
onClick={(event) => {
event.stopPropagation();
toggleGallerySelected(image.id);
}}
>
<div
className={`absolute top-2 left-2 flex h-5 w-5 items-center justify-center rounded-full border transition-all duration-150 ${
selected
? "border-blue-400 bg-blue-500 text-white opacity-100"
: "border-white/70 bg-black/40 text-transparent opacity-0 backdrop-blur-sm group-hover/cb:opacity-100"
}`}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
</button>
{/* Image / placeholder */}
{src && !errored ? (
<>
@@ -173,6 +220,17 @@ export function ImageTile({
{/* Persistent badges — only shown when meaningful */}
<div className="absolute top-2 right-2 flex flex-col items-end gap-1 pointer-events-none">
{image.embedding_status === "failed" && (
<div
className="flex items-center gap-1 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 backdrop-blur-sm"
title={image.embedding_error ?? "Embedding failed"}
>
<svg className="h-2.5 w-2.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
</div>
)}
{image.favorite && (
<div className="rounded-full bg-black/50 p-1 text-rose-400 backdrop-blur-sm">
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 20 20">
@@ -196,21 +254,6 @@ export function ImageTile({
)}
</div>
{/* Embedding failed badge — top-left */}
{image.embedding_status === "failed" && (
<div
className="absolute top-2 left-2 pointer-events-none"
title={image.embedding_error ?? "Embedding failed"}
>
<div className="flex items-center gap-1 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 backdrop-blur-sm">
<svg className="h-2.5 w-2.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
</div>
</div>
)}
{/* Hover overlay — slides up from bottom */}
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" />
@@ -230,7 +273,7 @@ export function ImageTile({
<span />
)}
<button
className={`rounded-md px-2 py-0.5 text-[10px] transition-colors pointer-events-auto backdrop-blur-sm ${
className={`relative z-20 rounded-md px-2 py-0.5 text-[10px] transition-colors pointer-events-auto backdrop-blur-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80 ${
canFindSimilar
? "bg-white/10 text-white/80 hover:bg-white/20 hover:text-white"
: "bg-white/5 text-white/30 cursor-not-allowed"
@@ -238,7 +281,7 @@ export function ImageTile({
onClick={(event) => {
event.stopPropagation();
if (!canFindSimilar) return;
void loadSimilarImages(image.id, similarScope === "current_folder" ? image.folder_id : null, true, image.folder_id);
findSimilar(image.id, image.folder_id);
}}
disabled={!canFindSimilar}
>
@@ -246,7 +289,8 @@ export function ImageTile({
</button>
</div>
</div>
</button>
</div>
</Tooltip>
);
}
@@ -338,7 +382,8 @@ export function Gallery() {
}, []);
return (
<div ref={parentRef} className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-gray-950">
<div className="relative flex-1 min-h-0">
<div ref={parentRef} className="absolute inset-0 overflow-y-auto overflow-x-hidden bg-gray-950">
{images.length === 0 && loadingImages ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 min-w-72">
@@ -448,5 +493,10 @@ export function Gallery() {
/>
) : null}
</div>
{/* Pinned to the bottom of the gallery viewport outside the scroll
container so it stays put while the grid scrolls. */}
<BulkActionBar />
</div>
);
}
+154 -10
View File
@@ -1,8 +1,8 @@
import { useEffect, useCallback, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { convertFileSrc } from "@tauri-apps/api/core";
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { useGalleryStore, ImageTag, AiRating } from "../store";
import { useGalleryStore, ImageTag, ImageExif, AiRating } from "../store";
import { VideoPlayer } from "./VideoPlayer";
function formatBytes(bytes: number): string {
@@ -144,15 +144,18 @@ export function Lightbox() {
const closeImage = useGalleryStore((state) => state.closeImage);
const images = useGalleryStore((state) => state.images);
const openImage = useGalleryStore((state) => state.openImage);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
const loadSimilarByRegion = useGalleryStore((state) => state.loadSimilarByRegion);
const similarScope = useGalleryStore((state) => state.similarScope);
const findSimilar = useGalleryStore((state) => state.findSimilar);
const findSimilarByRegion = useGalleryStore((state) => state.findSimilarByRegion);
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
const getImageTags = useGalleryStore((state) => state.getImageTags);
const addUserTag = useGalleryStore((state) => state.addUserTag);
const removeTag = useGalleryStore((state) => state.removeTag);
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
const queueTaggingForImage = useGalleryStore((state) => state.queueTaggingForImage);
const albums = useGalleryStore((state) => state.albums);
const addToAlbum = useGalleryStore((state) => state.addToAlbum);
const createAlbum = useGalleryStore((state) => state.createAlbum);
const getImageExif = useGalleryStore((state) => state.getImageExif);
// Tracks the image id that is currently displayed, used to discard async
// tag mutations that resolve after the user has navigated to another image.
@@ -164,12 +167,17 @@ export function Lightbox() {
const [isPanning, setIsPanning] = useState(false);
const lastPanPointRef = useRef({ x: 0, y: 0 });
const [imageTags, setImageTags] = useState<ImageTag[]>([]);
const [imageExif, setImageExif] = useState<ImageExif | null>(null);
const [tagInput, setTagInput] = useState("");
const [tagAdding, setTagAdding] = useState(false);
const [tagsExpanded, setTagsExpanded] = useState(false);
const [taggingQueued, setTaggingQueued] = useState(false);
// Region selection state
const [albumMenuOpen, setAlbumMenuOpen] = useState(false);
const [albumAddedTo, setAlbumAddedTo] = useState<number | null>(null);
const [newAlbumName, setNewAlbumName] = useState("");
const [albumAdding, setAlbumAdding] = useState(false);
const [regionSelectMode, setRegionSelectMode] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [dragRect, setDragRect] = useState<DragRect | null>(null);
@@ -215,6 +223,7 @@ export function Lightbox() {
useEffect(() => {
setView(IDENTITY_VIEW);
setImageTags([]);
setImageExif(null);
setTagInput("");
setTagsExpanded(false);
setTaggingQueued(false);
@@ -233,6 +242,20 @@ export function Lightbox() {
return () => { cancelled = true; };
}, [selectedImage?.id, selectedImage?.ai_tagged_at, getImageTags]);
// EXIF is read on demand from the file (not stored), so it works on every
// already-indexed image without a reindex. Only meaningful for images.
useEffect(() => {
if (!selectedImage || selectedImage.media_kind !== "image") {
setImageExif(null);
return;
}
let cancelled = false;
void getImageExif(selectedImage.id)
.then((exif) => { if (!cancelled) setImageExif(exif); })
.catch(() => { if (!cancelled) setImageExif(null); });
return () => { cancelled = true; };
}, [selectedImage?.id, selectedImage?.media_kind, getImageExif]);
// Reset the queued state once the worker finishes so the button is usable again
useEffect(() => {
if (selectedImage?.ai_tagged_at) setTaggingQueued(false);
@@ -361,12 +384,10 @@ export function Lightbox() {
exitRegionMode();
setRegionSearching(true);
const folderId =
similarScope === "current_folder" ? selectedImage.folder_id : null;
void loadSimilarByRegion(selectedImage.id, crop, folderId, selectedImage.folder_id)
void findSimilarByRegion(selectedImage.id, crop, selectedImage.folder_id)
.finally(() => setRegionSearching(false));
},
[isPanning, isDragging, dragRect, selectedImage, similarScope, loadSimilarByRegion, exitRegionMode],
[isPanning, isDragging, dragRect, selectedImage, findSimilarByRegion, exitRegionMode],
);
// Build the CSS rect for the selection overlay (viewport-relative)
@@ -520,7 +541,7 @@ export function Lightbox() {
}`}
onClick={() => {
if (!canFindSimilar) return;
void loadSimilarImages(selectedImage.id, similarScope === "current_folder" ? selectedImage.folder_id : null, true, selectedImage.folder_id);
void findSimilar(selectedImage.id, selectedImage.folder_id);
}}
disabled={!canFindSimilar}
>
@@ -780,6 +801,129 @@ export function Lightbox() {
</form>
</div>
<div className="relative">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs uppercase tracking-wider text-gray-500">Albums</p>
<button
className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => { setAlbumMenuOpen((v) => !v); setAlbumAddedTo(null); }}
>
Add to album
</button>
</div>
{albumMenuOpen ? (
<div className="rounded-lg border border-white/10 bg-white/[0.03] p-1.5">
<div className="max-h-40 overflow-y-auto">
{albums.length === 0 ? (
<p className="px-2 py-1.5 text-[11px] text-gray-600">No albums yet create one below.</p>
) : (
albums.map((album) => (
<button
key={album.id}
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1 text-left text-xs text-gray-300 transition-colors hover:bg-white/5 hover:text-white"
onClick={() => {
if (albumAdding) return;
setAlbumAdding(true);
void addToAlbum(album.id, [selectedImage.id])
.then(() => setAlbumAddedTo(album.id))
.catch(() => undefined)
.finally(() => setAlbumAdding(false));
}}
disabled={albumAdding}
>
<span className="truncate">{album.name}</span>
{albumAddedTo === album.id ? (
<span className="shrink-0 text-[10px] text-emerald-400">Added</span>
) : (
<span className="shrink-0 text-[10px] text-gray-600">{album.image_count}</span>
)}
</button>
))
)}
</div>
<form
className="mt-1 flex gap-1 border-t border-white/[0.06] pt-1.5"
onSubmit={(e) => {
e.preventDefault();
const name = newAlbumName.trim();
if (!name || albumAdding) return;
setAlbumAdding(true);
void createAlbum(name)
.then(async (album) => {
await addToAlbum(album.id, [selectedImage.id]);
setAlbumAddedTo(album.id);
setNewAlbumName("");
})
.catch(() => undefined)
.finally(() => setAlbumAdding(false));
}}
>
<input
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white placeholder-gray-600 focus:border-white/20 focus:outline-none"
placeholder="New album…"
value={newAlbumName}
onChange={(e) => setNewAlbumName(e.target.value)}
disabled={albumAdding}
/>
<button
type="submit"
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50"
disabled={albumAdding || !newAlbumName.trim()}
>
Add
</button>
</form>
</div>
) : null}
</div>
{imageExif &&
(imageExif.make ||
imageExif.model ||
imageExif.lens ||
imageExif.f_number ||
imageExif.exposure_time ||
imageExif.iso ||
imageExif.focal_length ||
(imageExif.gps_lat != null && imageExif.gps_lon != null)) ? (
<div>
<p className="mb-2 text-xs uppercase tracking-wider text-gray-500">Camera</p>
<div className="space-y-1.5">
{imageExif.make || imageExif.model ? (
<p className="text-sm text-white">
{[imageExif.make, imageExif.model].filter(Boolean).join(" ")}
</p>
) : null}
{imageExif.lens ? <p className="text-xs text-gray-400">{imageExif.lens}</p> : null}
{imageExif.f_number || imageExif.exposure_time || imageExif.iso || imageExif.focal_length ? (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-gray-400">
{imageExif.f_number ? <span>{imageExif.f_number}</span> : null}
{imageExif.exposure_time ? <span>{imageExif.exposure_time}</span> : null}
{imageExif.iso ? <span>ISO {imageExif.iso}</span> : null}
{imageExif.focal_length ? <span>{imageExif.focal_length}</span> : null}
</div>
) : null}
{imageExif.gps_lat != null && imageExif.gps_lon != null ? (
<button
className="inline-flex items-center gap-1 text-xs text-sky-400 transition-colors hover:text-sky-300"
title="Open location in your browser"
onClick={() =>
void invoke("open_map_location", {
params: { lat: imageExif.gps_lat, lon: imageExif.gps_lon },
})
}
>
{imageExif.gps_lat.toFixed(5)}, {imageExif.gps_lon.toFixed(5)}
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
) : null}
</div>
</div>
) : null}
<div>
<div className="mb-1 flex items-center justify-between">
<p className="text-xs uppercase tracking-wider text-gray-500">Path</p>
+6
View File
@@ -191,6 +191,7 @@ export function SettingsModal() {
const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo);
const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails);
const appVersion = useGalleryStore((state) => state.appVersion);
const buildVariant = useGalleryStore((state) => state.buildVariant);
const updateStatus = useGalleryStore((state) => state.updateStatus);
const updateVersion = useGalleryStore((state) => state.updateVersion);
const updateProgress = useGalleryStore((state) => state.updateProgress);
@@ -662,6 +663,11 @@ export function SettingsModal() {
label={
<span className="inline-flex items-center gap-2.5">
<span>Phokus {appVersion ? `v${appVersion}` : "—"}</span>
{buildVariant ? (
<StatusPill tone={buildVariant === "cuda" ? "ready" : "muted"}>
{buildVariant === "cuda" ? "CUDA" : "CPU"}
</StatusPill>
) : null}
{updateStatus === "available" || updateStatus === "downloading" || updateStatus === "installing" ? (
<StatusPill tone="busy">v{updateVersion} available</StatusPill>
) : updateStatus === "upToDate" ? (
+520 -1
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Reorder, useDragControls } from "framer-motion";
import { open } from "@tauri-apps/plugin-dialog";
import { useGalleryStore, Folder, IndexProgress } from "../store";
import { convertFileSrc } from "@tauri-apps/api/core";
import { useGalleryStore, Folder, Album, IndexProgress } from "../store";
import { ThemedDropdown } from "./ThemedDropdown";
interface ContextMenuState {
@@ -339,6 +340,270 @@ function FolderItem({
);
}
function AlbumContextMenu({
x,
y,
onClose,
onRename,
onDelete,
}: {
x: number;
y: number;
onClose: () => void;
onRename: () => void;
onDelete: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", handleDown);
document.addEventListener("keydown", handleKey);
return () => {
document.removeEventListener("mousedown", handleDown);
document.removeEventListener("keydown", handleKey);
};
}, [onClose]);
const item = (label: string, onClick: () => void, danger = false) => (
<button
className={`w-full text-left px-3 py-1.5 text-[12px] rounded-md transition-colors ${
danger
? "text-red-400 hover:bg-red-500/15 hover:text-red-300"
: "text-gray-300 hover:bg-white/8 hover:text-white"
}`}
onClick={(event) => {
event.stopPropagation();
onClick();
onClose();
}}
>
{label}
</button>
);
return (
<div
ref={ref}
className="fixed z-50 min-w-[160px] py-1 px-1 rounded-lg bg-gray-900 border border-white/10 shadow-xl shadow-black/50"
style={{ left: x, top: y }}
>
{item("Rename", onRename)}
<div className="my-1 border-t border-white/[0.06]" />
{item("Delete album", onDelete, true)}
</div>
);
}
function AlbumItem({
album,
manageMode = false,
selectedForManage = false,
onToggleManage,
reorderable = false,
onDragStart,
onDragEnd,
}: {
album: Album;
manageMode?: boolean;
selectedForManage?: boolean;
onToggleManage?: () => void;
reorderable?: boolean;
onDragStart?: () => void;
onDragEnd?: () => void;
}) {
const dragControls = useDragControls();
const viewAlbum = useGalleryStore((state) => state.viewAlbum);
const renameAlbum = useGalleryStore((state) => state.renameAlbum);
const deleteAlbum = useGalleryStore((state) => state.deleteAlbum);
const activeView = useGalleryStore((state) => state.activeView);
const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId);
const selected = !manageMode && activeView === "album" && selectedAlbumId === album.id;
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(album.name);
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
const renameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (renaming) {
setRenameValue(album.name);
setTimeout(() => renameInputRef.current?.select(), 0);
}
}, [renaming, album.name]);
const commitRename = async () => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== album.name) {
await renameAlbum(album.id, trimmed);
}
setRenaming(false);
};
const cover = album.cover_thumbnail_path ? convertFileSrc(album.cover_thumbnail_path) : null;
const row = (
<div
role={manageMode ? "checkbox" : "button"}
tabIndex={renaming ? -1 : 0}
aria-checked={manageMode ? selectedForManage : undefined}
aria-current={!manageMode && selected ? "page" : undefined}
className={`group relative flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-150 ${
selectedForManage
? "bg-blue-500/10 text-white ring-1 ring-inset ring-blue-400/50"
: selected
? "bg-white/8 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
onClick={() => {
if (manageMode) {
onToggleManage?.();
} else if (!renaming) {
viewAlbum(album.id);
}
}}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (renaming || (e.key !== "Enter" && e.key !== " ")) return;
e.preventDefault();
if (manageMode) {
onToggleManage?.();
} else {
viewAlbum(album.id);
}
}}
onContextMenu={(e) => {
if (manageMode) return;
e.preventDefault();
e.stopPropagation();
setMenu({ x: Math.min(e.clientX, window.innerWidth - 180), y: Math.min(e.clientY, window.innerHeight - 120) });
}}
>
{/* Manage-mode selection checkbox */}
{manageMode ? (
<div
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
selectedForManage ? "border-blue-400 bg-blue-500 text-white" : "border-white/30 text-transparent"
}`}
>
<svg className="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
) : null}
{/* Drag handle — hover-revealed, reorders albums */}
{reorderable ? (
<button
type="button"
aria-label={`Reorder ${album.name}`}
title="Drag to reorder"
className="-ml-1 flex h-6 w-3.5 shrink-0 cursor-grab touch-none items-center justify-center rounded text-gray-700 opacity-0 transition-opacity hover:text-gray-400 group-hover:opacity-100"
onPointerDown={(e) => {
e.stopPropagation();
onDragStart?.();
dragControls.start(e);
}}
onClick={(e) => e.stopPropagation()}
>
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="currentColor">
<circle cx="3" cy="3" r="1" /><circle cx="9" cy="3" r="1" />
<circle cx="3" cy="6" r="1" /><circle cx="9" cy="6" r="1" />
<circle cx="3" cy="9" r="1" /><circle cx="9" cy="9" r="1" />
</svg>
</button>
) : null}
{/* Cover thumbnail — distinguishes albums from folder rows */}
<div className="h-7 w-7 shrink-0 overflow-hidden rounded-md bg-white/[0.05] ring-1 ring-white/10">
{cover ? (
<img src={cover} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center text-white/20">
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
</div>
<div className="min-w-0 flex-1">
{renaming ? (
<input
ref={renameInputRef}
className="w-full bg-white/10 text-white text-[13px] font-medium rounded px-1 py-0 outline-none ring-1 ring-blue-500/60 leading-tight"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); void commitRename(); }
if (e.key === "Escape") setRenaming(false);
}}
onBlur={() => void commitRename()}
onClick={(e) => e.stopPropagation()}
/>
) : (
<div className={`truncate text-[13px] font-medium leading-tight ${selected ? "text-white" : ""}`}>
{album.name}
</div>
)}
<div className="text-[11px] text-gray-600 mt-0.5">{album.image_count.toLocaleString()}</div>
</div>
{!renaming && confirmingRemoval ? (
<div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
<button
className="px-1.5 py-0.5 text-[10px] rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 hover:text-red-300 transition-colors"
onClick={() => { void deleteAlbum(album.id); setConfirmingRemoval(false); }}
>
Confirm
</button>
<button
className="px-1.5 py-0.5 text-[10px] rounded bg-white/5 text-gray-500 hover:bg-white/10 hover:text-gray-300 transition-colors"
onClick={() => setConfirmingRemoval(false)}
>
Cancel
</button>
</div>
) : null}
{menu ? (
<AlbumContextMenu
x={menu.x}
y={menu.y}
onClose={() => setMenu(null)}
onRename={() => setRenaming(true)}
onDelete={() => setConfirmingRemoval(true)}
/>
) : null}
</div>
);
if (reorderable) {
return (
<Reorder.Item
as="div"
value={album.id}
drag="y"
dragControls={dragControls}
dragListener={false}
dragElastic={0.08}
onDragEnd={onDragEnd}
layout
transition={{ layout: { type: "spring", stiffness: 520, damping: 38, mass: 0.55 } }}
>
{row}
</Reorder.Item>
);
}
return row;
}
export function Sidebar() {
const folders = useGalleryStore((state) => state.folders);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
@@ -348,6 +613,59 @@ export function Sidebar() {
const activeView = useGalleryStore((state) => state.activeView);
const setView = useGalleryStore((state) => state.setView);
const reorderFolders = useGalleryStore((state) => state.reorderFolders);
const albums = useGalleryStore((state) => state.albums);
const createAlbum = useGalleryStore((state) => state.createAlbum);
const deleteAlbums = useGalleryStore((state) => state.deleteAlbums);
const reorderAlbums = useGalleryStore((state) => state.reorderAlbums);
const [creatingAlbum, setCreatingAlbum] = useState(false);
const [createAlbumPending, setCreateAlbumPending] = useState(false);
const [newAlbumName, setNewAlbumName] = useState("");
const newAlbumInputRef = useRef<HTMLInputElement>(null);
const [manageAlbums, setManageAlbums] = useState(false);
const [manageSelectedIds, setManageSelectedIds] = useState<Set<number>>(new Set());
const [confirmingAlbumDelete, setConfirmingAlbumDelete] = useState(false);
const [orderedAlbums, setOrderedAlbums] = useState(albums);
const orderedAlbumsRef = useRef(albums);
const [draggingAlbum, setDraggingAlbum] = useState(false);
// Keep the local drag order in sync with the store except mid-drag, so a
// background album refresh doesn't yank the row out from under the pointer.
useEffect(() => {
if (draggingAlbum) return;
setOrderedAlbums(albums);
orderedAlbumsRef.current = albums;
}, [albums, draggingAlbum]);
const handleAlbumReorder = (ids: number[]) => {
const byId = new Map(orderedAlbumsRef.current.map((album) => [album.id, album]));
const next = ids
.map((id) => byId.get(id))
.filter((album): album is Album => album !== undefined);
orderedAlbumsRef.current = next;
setOrderedAlbums(next);
};
const finishAlbumReorder = () => {
setDraggingAlbum(false);
const nextIds = orderedAlbumsRef.current.map((album) => album.id);
// Read live store order (not the render-time closure) in case albums changed.
const currentIds = useGalleryStore.getState().albums.map((album) => album.id);
const snapshotIds = albums.map((album) => album.id);
if (
snapshotIds.length !== currentIds.length ||
snapshotIds.some((id, index) => id !== currentIds[index])
) {
orderedAlbumsRef.current = useGalleryStore.getState().albums;
setOrderedAlbums(orderedAlbumsRef.current);
return;
}
if (
nextIds.length !== currentIds.length ||
nextIds.some((id, index) => id !== currentIds[index])
) {
void reorderAlbums(nextIds);
}
};
const [librarySort, setLibrarySortState] = useState<LibrarySort>(() => {
const saved = window.localStorage.getItem(LIBRARY_SORT_KEY);
return saved === "za" || saved === "custom" ? saved : "az";
@@ -463,6 +781,53 @@ export function Sidebar() {
setFolderPickerOpen(true);
};
const startCreatingAlbum = () => {
setCreatingAlbum(true);
setNewAlbumName("");
setTimeout(() => newAlbumInputRef.current?.focus(), 0);
};
const handleCreateAlbum = async () => {
const name = newAlbumName.trim();
if (!name) {
setCreatingAlbum(false);
return;
}
if (createAlbumPending) return;
setCreateAlbumPending(true);
try {
const album = await createAlbum(name);
setNewAlbumName("");
setCreatingAlbum(false);
useGalleryStore.getState().viewAlbum(album.id);
} finally {
setCreateAlbumPending(false);
}
};
const exitManageAlbums = () => {
setManageAlbums(false);
setManageSelectedIds(new Set());
setConfirmingAlbumDelete(false);
};
const toggleManageSelected = (albumId: number) => {
setManageSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(albumId)) next.delete(albumId);
else next.add(albumId);
return next;
});
setConfirmingAlbumDelete(false);
};
const handleDeleteSelectedAlbums = async () => {
const ids = Array.from(manageSelectedIds);
if (ids.length === 0) return;
await deleteAlbums(ids);
exitManageAlbums();
};
return (
<aside className="w-60 shrink-0 flex flex-col bg-gray-950 border-r border-white/[0.06]">
{/* Header */}
@@ -601,6 +966,160 @@ export function Sidebar() {
))
)}
</Reorder.Group>
{/* Albums a visually distinct block, separated from Libraries by a
heavier divider and given cover-thumbnail rows so the two never
read as one list. */}
<div className="shrink-0 border-t-2 border-white/[0.08] bg-white/[0.015]">
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-1">
<span className="text-[10px] font-semibold uppercase tracking-[0.15em] text-gray-600">
{manageAlbums ? `${manageSelectedIds.size} selected` : "Albums"}
</span>
{manageAlbums ? (
<button
onClick={exitManageAlbums}
className="rounded px-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500 transition-colors hover:text-gray-200"
>
Done
</button>
) : (
<div className="flex items-center gap-0.5">
{albums.length > 0 ? (
<button
onClick={() => setManageAlbums(true)}
className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200"
title="Manage albums"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
) : null}
<button
onClick={startCreatingAlbum}
className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200"
title="New album"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
)}
</div>
{/* Manage action row */}
{manageAlbums ? (
<div className="px-3 pb-1.5">
{confirmingAlbumDelete ? (
<div className="rounded-lg border border-red-500/25 bg-red-500/[0.06] p-2">
<p className="mb-2 text-[11px] leading-relaxed text-gray-400">
Delete {manageSelectedIds.size} album{manageSelectedIds.size === 1 ? "" : "s"}? Your images stay in
the library only the album{manageSelectedIds.size === 1 ? "" : "s"} {manageSelectedIds.size === 1 ? "is" : "are"} removed.
</p>
<div className="flex justify-end gap-1.5">
<button
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-[11px] text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setConfirmingAlbumDelete(false)}
>
Cancel
</button>
<button
className="rounded-md bg-red-500/20 px-2 py-1 text-[11px] font-medium text-red-300 transition-colors hover:bg-red-500/30 hover:text-red-200"
onClick={() => void handleDeleteSelectedAlbums()}
>
Delete {manageSelectedIds.size}
</button>
</div>
</div>
) : (
<div className="flex items-center justify-between gap-2">
<button
className="text-[11px] text-gray-500 transition-colors hover:text-gray-300"
onClick={() =>
setManageSelectedIds((prev) =>
prev.size === albums.length ? new Set() : new Set(albums.map((a) => a.id)),
)
}
>
{manageSelectedIds.size === albums.length ? "Deselect all" : "Select all"}
</button>
<button
className="rounded-md border border-red-400/25 bg-red-500/10 px-2 py-1 text-[11px] text-red-300 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => setConfirmingAlbumDelete(true)}
disabled={manageSelectedIds.size === 0}
>
Delete {manageSelectedIds.size > 0 ? manageSelectedIds.size : ""}
</button>
</div>
)}
</div>
) : null}
<div className="max-h-52 overflow-y-auto px-2 pb-2 space-y-px">
{creatingAlbum ? (
<form
className="flex gap-1 px-1 py-1"
onSubmit={(e) => {
e.preventDefault();
void handleCreateAlbum();
}}
>
<input
ref={newAlbumInputRef}
className="min-w-0 flex-1 rounded border border-white/10 bg-white/10 px-1.5 py-1 text-[13px] text-white outline-none ring-1 ring-blue-500/40 placeholder-gray-600"
placeholder="Album name…"
value={newAlbumName}
onChange={(e) => setNewAlbumName(e.target.value)}
disabled={createAlbumPending}
onKeyDown={(e) => {
if (createAlbumPending) return;
if (e.key === "Escape") {
setCreatingAlbum(false);
setNewAlbumName("");
}
}}
onBlur={() => void handleCreateAlbum()}
/>
</form>
) : null}
{albums.length === 0 && !creatingAlbum ? (
<p className="px-3 py-3 text-center text-[11px] leading-relaxed text-gray-700">
Select images and Add to album to start curating
</p>
) : manageAlbums ? (
albums.map((album) => (
<AlbumItem
key={album.id}
album={album}
manageMode
selectedForManage={manageSelectedIds.has(album.id)}
onToggleManage={() => toggleManageSelected(album.id)}
/>
))
) : (
<Reorder.Group
as="div"
axis="y"
values={orderedAlbums.map((album) => album.id)}
onReorder={handleAlbumReorder}
className="space-y-px"
>
{orderedAlbums.map((album) => (
<AlbumItem
key={album.id}
album={album}
reorderable
onDragStart={() => setDraggingAlbum(true)}
onDragEnd={finishAlbumReorder}
/>
))}
</Reorder.Group>
)}
</div>
</div>
</aside>
);
}
+179
View File
@@ -292,6 +292,162 @@ function ClusterCloud({
);
}
// A flat, manageable row for a single tag — rename (which doubles as merge when
// the new name already exists) and delete across the whole library.
function TagManageRow({
entry,
onSearch,
onRename,
onDelete,
}: {
entry: ExploreTagEntry;
onSearch: (tag: string) => void;
onRename: (from: string, to: string) => Promise<void>;
onDelete: (tag: string) => Promise<void>;
}) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(entry.tag);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing) {
setValue(entry.tag);
setTimeout(() => inputRef.current?.select(), 0);
}
}, [editing, entry.tag]);
const commitRename = async () => {
const next = value.trim();
if (!next || next === entry.tag) {
setEditing(false);
return;
}
setBusy(true);
try {
await onRename(entry.tag, next);
setEditing(false);
} finally {
setBusy(false);
}
};
return (
<div className="group flex items-center gap-3 rounded-lg px-3 py-2 transition-colors hover:bg-white/[0.04]">
<div className="min-w-0 flex-1">
{editing ? (
<input
ref={inputRef}
className="w-full rounded border border-white/10 bg-white/10 px-2 py-1 text-sm text-white outline-none ring-1 ring-blue-500/40"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); void commitRename(); }
if (e.key === "Escape") setEditing(false);
}}
disabled={busy}
/>
) : (
<button
className="truncate text-left text-sm text-white/85 transition-colors hover:text-white"
onClick={() => onSearch(entry.tag)}
title="Search this tag"
>
{entry.tag}
</button>
)}
</div>
<span className="shrink-0 text-xs tabular-nums text-white/30">{entry.count.toLocaleString()}</span>
{editing ? (
<div className="flex shrink-0 items-center gap-1">
<button
className="rounded-md bg-blue-500/20 px-2 py-1 text-[11px] text-blue-200 transition-colors hover:bg-blue-500/30 disabled:opacity-50"
onClick={() => void commitRename()}
disabled={busy || !value.trim()}
title="Rename (merges into the target if it already exists)"
>
Save
</button>
<button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/50 transition-colors hover:bg-white/5 hover:text-white/80"
onClick={() => setEditing(false)}
disabled={busy}
>
Cancel
</button>
</div>
) : confirming ? (
<div className="flex shrink-0 items-center gap-1">
<button
className="rounded-md bg-red-500/20 px-2 py-1 text-[11px] text-red-300 transition-colors hover:bg-red-500/30 disabled:opacity-50"
onClick={async () => { setBusy(true); try { await onDelete(entry.tag); setConfirming(false); } finally { setBusy(false); } }}
disabled={busy}
>
Delete
</button>
<button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/50 transition-colors hover:bg-white/5 hover:text-white/80"
onClick={() => setConfirming(false)}
disabled={busy}
>
Cancel
</button>
</div>
) : (
<div className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
<button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-white/8 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
onClick={() => setEditing(true)}
title="Rename or merge into another tag"
>
Rename
</button>
<button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-red-500/10 hover:text-red-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400/80"
onClick={() => setConfirming(true)}
>
Delete
</button>
</div>
)}
</div>
);
}
function TagManageList({
entries,
onSearch,
onRename,
onDelete,
}: {
entries: ExploreTagEntry[];
onSearch: (tag: string) => void;
onRename: (from: string, to: string) => Promise<void>;
onDelete: (tag: string) => Promise<void>;
}) {
return (
<div className="mx-auto w-full max-w-2xl overflow-y-auto px-6 py-6">
<p className="mb-3 px-3 text-[11px] leading-relaxed text-white/30">
Rename a tag to clean it up, or rename it to an existing tag's name to merge them. Delete
removes a tag from every image. These changes apply across your whole library.
</p>
<div className="divide-y divide-white/[0.05]">
{entries.map((entry) => (
<TagManageRow
key={entry.tag}
entry={entry}
onSearch={onSearch}
onRename={onRename}
onDelete={onDelete}
/>
))}
</div>
</div>
);
}
export function TagCloud() {
const exploreMode = useGalleryStore((state) => state.exploreMode);
const setExploreMode = useGalleryStore((state) => state.setExploreMode);
@@ -303,7 +459,11 @@ export function TagCloud() {
const loadExploreTags = useGalleryStore((state) => state.loadExploreTags);
const showVisualCluster = useGalleryStore((state) => state.showVisualCluster);
const searchForTag = useGalleryStore((state) => state.searchForTag);
const renameTag = useGalleryStore((state) => state.renameTag);
const deleteTag = useGalleryStore((state) => state.deleteTag);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const [manageTags, setManageTags] = useState(false);
const handleDeleteTag = async (tag: string) => { await deleteTag(tag); };
useEffect(() => {
if (exploreMode === "visual") void loadTagCloud();
@@ -342,6 +502,18 @@ export function TagCloud() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{exploreMode === "tags" && hasEntries ? (
<button
className={`rounded-lg border px-3 py-1.5 text-xs transition-colors ${
manageTags
? "border-white/15 bg-white/10 text-white"
: "border-white/8 bg-white/[0.03] text-gray-500 hover:text-gray-300"
}`}
onClick={() => setManageTags((v) => !v)}
>
{manageTags ? "Done" : "Manage"}
</button>
) : null}
<FolderScopeDropdown />
<div className="explore-mode-toggle flex rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
<button
@@ -380,6 +552,13 @@ export function TagCloud() {
</div>
) : exploreMode === "visual" ? (
<ClusterCloud entries={tagCloudEntries} onOpen={showVisualCluster} />
) : manageTags ? (
<TagManageList
entries={exploreTagEntries}
onSearch={searchForTag}
onRename={renameTag}
onDelete={handleDeleteTag}
/>
) : (
/* Tag cloud — words sized by log-scaled frequency, wrapped freely */
<div className="overflow-y-auto px-8 py-8">
+5 -8
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useGalleryStore } from "../store";
import { PhokusMark } from "./PhokusMark";
import { Tooltip } from "./Tooltip";
// SVG icons for window controls
function MinimizeIcon() {
@@ -79,10 +80,9 @@ export function TitleBar() {
up its focal point and the chip becomes a button that re-opens the prompt. */}
<div className="flex items-center gap-2 pl-3 pr-4">
{updatePending ? (
<div
className="group relative"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<div style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}>
{/* Instant tooltip (delay 0) — this affordance should read immediately. */}
<Tooltip label={`Click to update — v${updateVersion}`} delay={0} align="start">
<button
onClick={() => void installUpdate()}
aria-label={`Update available — click to update to Phokus v${updateVersion}`}
@@ -91,10 +91,7 @@ export function TitleBar() {
<span className="pointer-events-none absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/60 animate-ping" />
<PhokusMark className="relative h-4 w-4" dotClassName="fill-amber-400" />
</button>
{/* Custom tooltip — fades in ~100ms instead of the native ~500ms delay. */}
<span className="pointer-events-none absolute left-0 top-full z-50 mt-1.5 whitespace-nowrap rounded-md border border-white/10 bg-gray-800 px-2 py-1 text-[11px] text-gray-200 opacity-0 shadow-lg transition-opacity duration-100 group-hover:opacity-100">
Click to update v{updateVersion}
</span>
</Tooltip>
</div>
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300">
+16 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { ColorFilter } from "./ColorFilter";
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
{ value: "date_desc", label: "Newest first" },
@@ -160,8 +161,11 @@ export function Toolbar() {
const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly);
const failedTaggingOnly = useGalleryStore((state) => state.failedTaggingOnly);
const setFailedTaggingOnly = useGalleryStore((state) => state.setFailedTaggingOnly);
const colorFilter = useGalleryStore((state) => state.colorFilter);
const setColorFilter = useGalleryStore((state) => state.setColorFilter);
const similarScope = useGalleryStore((state) => state.similarScope);
const setSimilarScope = useGalleryStore((state) => state.setSimilarScope);
const similarSourceAlbumId = useGalleryStore((state) => state.similarSourceAlbumId);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
@@ -187,6 +191,12 @@ export function Toolbar() {
const hasActiveSearch = search.trim().length > 0;
const parsedSearch = parseSearchValue(composeSearchValue(searchCommand, searchQuery));
const isSimilarResults = collectionTitle === "Similar Images";
// Album scope is offered while browsing an album (where it's the default) and
// while viewing similar/region results that were launched from an album.
const showAlbumScope =
activeView === "album" ||
(similarSourceAlbumId !== null &&
(collectionTitle === "Similar Images" || collectionTitle === "Region Search Results"));
// If current sort is video-only but we switched away from video filter, reset to date_desc
useEffect(() => {
@@ -444,12 +454,15 @@ export function Toolbar() {
{/* Filter row */}
<div className="flex items-center gap-1 px-4 pb-1.5">
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly && minimumRating === 0} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly && minimumRating === 0 && colorFilter === null} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); setColorFilter(null); }} />
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Rated" active={minimumRating === 1} onClick={() => { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="4★+" active={minimumRating === 4} onClick={() => { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
{showAlbumScope ? (
<FilterPill label="Similar: Album" active={similarScope === "current_album"} onClick={() => setSimilarScope("current_album")} />
) : null}
<FilterPill label="Similar: Folder" active={similarScope === "current_folder"} onClick={() => setSimilarScope("current_folder")} />
<FilterPill label="Similar: All" active={similarScope === "all_media"} onClick={() => setSimilarScope("all_media")} />
{hasAnyFailedEmbeddings ? (
@@ -468,7 +481,8 @@ export function Toolbar() {
onClick={() => setFailedTaggingOnly(!failedTaggingOnly)}
/>
) : null}
{isSimilarResults ? <span className="ml-2 text-[11px] text-gray-500">Current similar scope: {similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
<ColorFilter />
{isSimilarResults ? <span className="ml-2 text-[11px] text-gray-500">Current similar scope: {similarScope === "current_album" ? "this album" : similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
</div>
</div>
);
+145
View File
@@ -0,0 +1,145 @@
import { MouseEvent, ReactNode, useEffect, useRef, useState } from "react";
import { motion } from "framer-motion";
type TooltipSide = "top" | "bottom" | "left" | "right";
type TooltipAlign = "center" | "start" | "end";
// Horizontal alignment only applies to top/bottom; left/right center vertically.
function positionClasses(side: TooltipSide, align: TooltipAlign): string {
if (side === "left") return "right-full top-1/2 mr-1.5 -translate-y-1/2";
if (side === "right") return "left-full top-1/2 ml-1.5 -translate-y-1/2";
const vertical = side === "top" ? "bottom-full mb-1.5" : "top-full mt-1.5";
const horizontal = align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
return `${vertical} ${horizontal}`;
}
const BASE_CLASSES =
"pointer-events-none z-50 whitespace-nowrap rounded-md border border-white/10 bg-gray-800 px-2 py-1 text-[11px] text-gray-200 shadow-lg";
const STATIC_CLASSES = `${BASE_CLASSES} transition-opacity duration-100`;
/**
* Lightweight custom tooltip fades in (faster than the native one) after a
* tunable hover `delay`, and hides instantly on leave or click. By default it
* anchors to a `side` of the wrapper; with `followCursor` it tracks the pointer
* (fixed-positioned, so it escapes scroll-container clipping).
*/
export function Tooltip({
label,
delay = 400,
side = "bottom",
align = "center",
block = false,
followCursor = false,
className = "",
children,
}: {
label: ReactNode;
/** Milliseconds the pointer must hover before the tooltip appears (0 = instant). */
delay?: number;
side?: TooltipSide;
/** Horizontal alignment for top/bottom tooltips. */
align?: TooltipAlign;
/** Full-width block wrapper (e.g. wrapping a grid cell) instead of inline. */
block?: boolean;
/** Track the cursor (fixed position) instead of anchoring to a side. */
followCursor?: boolean;
/** Extra classes for the wrapper (e.g. layout). */
className?: string;
children: ReactNode;
}) {
const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({ x: 0, y: 0 });
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const frame = useRef<number | undefined>(undefined);
const tooltipRef = useRef<HTMLSpanElement>(null);
// Resolve a viewport-safe position near the cursor: below-right by default,
// but flipped above the cursor if it would overflow the bottom edge, and
// clamped so it never runs off the right/left.
const place = (clientX: number, clientY: number) => {
const tip = tooltipRef.current;
const tipWidth = tip?.offsetWidth ?? 0;
const tipHeight = tip?.offsetHeight ?? 24;
const gap = 16;
let top = clientY + gap;
if (top + tipHeight > window.innerHeight - 4) {
top = clientY - gap - tipHeight;
}
let left = clientX + 14;
if (left + tipWidth > window.innerWidth - 4) left = window.innerWidth - 4 - tipWidth;
if (left < 4) left = 4;
setCoords({ x: left, y: top });
};
const clear = () => {
if (timer.current) clearTimeout(timer.current);
timer.current = undefined;
if (frame.current !== undefined) cancelAnimationFrame(frame.current);
frame.current = undefined;
};
const show = (event: MouseEvent<HTMLElement>) => {
clear();
if (followCursor) place(event.clientX, event.clientY);
if (delay <= 0) {
setVisible(true);
return;
}
timer.current = setTimeout(() => setVisible(true), delay);
};
const hide = () => {
clear();
setVisible(false);
};
const move = (event: MouseEvent<HTMLElement>) => {
if (!followCursor) return;
const { clientX, clientY } = event;
if (frame.current !== undefined) cancelAnimationFrame(frame.current);
frame.current = requestAnimationFrame(() => {
frame.current = undefined;
place(clientX, clientY);
});
};
useEffect(() => clear, []);
const Wrapper = block ? "div" : "span";
return (
<Wrapper
className={`${block ? "relative block w-full" : "relative inline-flex"} ${className}`}
onMouseEnter={show}
onMouseMove={followCursor ? move : undefined}
onMouseLeave={hide}
onPointerDown={hide}
>
{children}
{followCursor ? (
<motion.span
ref={tooltipRef}
role="tooltip"
aria-hidden={!visible}
// Fixed (so the scroll container's overflow doesn't clip it) and
// positioned by `place()` near the cursor with viewport-edge flipping.
className={`fixed ${BASE_CLASSES}`}
initial={false}
animate={{ left: coords.x, top: coords.y, opacity: visible ? 1 : 0 }}
transition={{
left: { type: "spring", stiffness: 700, damping: 45, mass: 0.35 },
top: { type: "spring", stiffness: 520, damping: 34, mass: 0.45 },
opacity: { duration: visible ? 0.1 : 0.05 },
}}
>
{label}
</motion.span>
) : (
<span
role="tooltip"
aria-hidden={!visible}
className={`absolute ${STATIC_CLASSES} ${positionClasses(side, align)} ${visible ? "opacity-100" : "opacity-0"}`}
>
{label}
</span>
)}
</Wrapper>
);
}
+7 -2
View File
@@ -1,9 +1,12 @@
import { useEffect, useState, type ReactNode } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { openUrl } from "@tauri-apps/plugin-opener";
import { invoke } from "@tauri-apps/api/core";
import { useGalleryStore } from "../store";
import { getChangelogForVersion, type ChangelogSection } from "../changelog";
import { Tooltip } from "./Tooltip";
// Shown in the tooltip so the user can see the link's destination before
// clicking. The actual navigation is handled by the open_changelog_url command.
const CHANGELOG_URL = "https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md";
// Per-section accent. These all use the standard colour scale, which the
@@ -175,13 +178,15 @@ export function WhatsNewModal() {
</div>
<div className="flex items-center justify-between gap-4 border-t border-white/[0.07] px-6 py-4">
<Tooltip label={CHANGELOG_URL} side="top" align="start">
<button
type="button"
onClick={() => void openUrl(CHANGELOG_URL)}
onClick={() => void invoke("open_changelog_url")}
className="text-xs text-gray-500 transition-colors hover:text-gray-300"
>
Full changelog
</button>
</Tooltip>
<button
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3.5 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25"
onClick={closeWhatsNew}
+73
View File
@@ -0,0 +1,73 @@
import { useBulkTagEditor } from "./useBulkTagEditor";
// Presentational tag-editing fields shared by the popover and modal surfaces.
export function BulkTagFields({ autoFocus = false }: { autoFocus?: boolean }) {
const { selectedCount, input, setInput, suggestions, appliedTags, pending, addTag, removeTag } =
useBulkTagEditor();
return (
<div className="space-y-2">
<form
className="flex gap-1.5"
onSubmit={(event) => {
event.preventDefault();
void addTag(input);
}}
>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input
autoFocus={autoFocus}
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white placeholder-gray-600 focus:border-white/20 focus:outline-none"
placeholder={`Add tag to ${selectedCount} item${selectedCount === 1 ? "" : "s"}`}
value={input}
onChange={(event) => setInput(event.target.value)}
disabled={pending}
/>
<button
type="submit"
className="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
disabled={pending || !input.trim()}
>
Add
</button>
</form>
{suggestions.length > 0 ? (
<div className="flex flex-wrap gap-1">
{suggestions.map((suggestion) => (
<button
key={suggestion.tag}
className="rounded-md border border-white/10 bg-white/[0.03] px-2 py-0.5 text-[11px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => void addTag(suggestion.tag)}
title={`${suggestion.count.toLocaleString()} tagged`}
>
{suggestion.tag}
</button>
))}
</div>
) : null}
{appliedTags.length > 0 ? (
<div className="flex flex-wrap gap-1 border-t border-white/[0.06] pt-2">
{appliedTags.map((tag) => (
<span
key={tag}
className="group/chip flex items-center gap-1 rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-gray-300"
>
{tag}
<button
className="text-gray-600 transition-colors hover:text-white"
title="Remove from selected"
onClick={() => void removeTag(tag)}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
))}
</div>
) : null}
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { BulkTagFields } from "./BulkTagFields";
// Inline popover surface for bulk tagging — the default editing surface.
// Anchored above the bar by the parent; closes on outside click via the
// data-bulk-popover guard handled in BulkActionBar.
export function BulkTagPopover({ onClose }: { onClose: () => void }) {
return (
<div
data-bulk-popover
className="absolute bottom-full left-1/2 mb-2 w-72 -translate-x-1/2 rounded-xl border border-white/10 bg-gray-950/98 p-3 shadow-2xl backdrop-blur"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-2 flex items-center justify-between">
<p className="text-[11px] uppercase tracking-wider text-gray-500">Add tags</p>
<button
className="text-gray-600 transition-colors hover:text-white"
onClick={onClose}
title="Close"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<BulkTagFields autoFocus />
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { ExploreTagEntry, useGalleryStore } from "../../store";
// Shared logic for the bulk tag editor, consumed by both the inline popover and
// the modal surface so they stay behaviorally identical.
export function useBulkTagEditor() {
const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const bulkAddTags = useGalleryStore((state) => state.bulkAddTags);
const bulkRemoveTag = useGalleryStore((state) => state.bulkRemoveTag);
const [input, setInput] = useState("");
const [suggestions, setSuggestions] = useState<ExploreTagEntry[]>([]);
const [appliedTags, setAppliedTags] = useState<string[]>([]);
const [pending, setPending] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const requestRef = useRef(0);
useEffect(() => {
const query = input.trim();
if (!query) {
requestRef.current += 1;
setSuggestions([]);
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
const requestId = ++requestRef.current;
debounceRef.current = setTimeout(async () => {
try {
const results = await invoke<ExploreTagEntry[]>("search_tags_autocomplete", {
params: { query, folder_id: selectedFolderId ?? null, limit: 8 },
});
if (requestId !== requestRef.current) return;
setSuggestions(results);
} catch {
if (requestId !== requestRef.current) return;
setSuggestions([]);
}
}, 120);
return () => {
requestRef.current += 1;
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [input, selectedFolderId]);
const addTag = useCallback(
async (raw: string) => {
const tag = raw.trim();
if (!tag || pending) return;
setPending(true);
try {
await bulkAddTags([tag]);
setAppliedTags((prev) => (prev.includes(tag) ? prev : [...prev, tag]));
setInput("");
setSuggestions([]);
} finally {
setPending(false);
}
},
[bulkAddTags, pending],
);
const removeTag = useCallback(
async (tag: string) => {
await bulkRemoveTag(tag);
setAppliedTags((prev) => prev.filter((entry) => entry !== tag));
},
[bulkRemoveTag],
);
return { selectedCount, input, setInput, suggestions, appliedTags, pending, addTag, removeTag };
}
+525 -38
View File
@@ -53,7 +53,7 @@ export type CaptionDetail = "short" | "detailed" | "paragraph";
export type TaggerAcceleration = "auto" | "cpu" | "directml";
export type AiRating = "general" | "sensitive" | "questionable" | "explicit";
export type TaggingQueueScope = "all" | "selected";
export type SimilarScope = "all_media" | "current_folder";
export type SimilarScope = "all_media" | "current_folder" | "current_album";
export type ExploreMode = "visual" | "tags";
export type AppTheme = "phokus" | "subtle-light" | "conventional-dark";
@@ -176,7 +176,31 @@ export interface ThumbnailBatch {
images: ImageRecord[];
}
export type ActiveView = "gallery" | "explore" | "duplicates" | "timeline";
export type ActiveView = "gallery" | "explore" | "duplicates" | "timeline" | "album";
export interface Album {
id: number;
name: string;
cover_image_id: number | null;
cover_thumbnail_path: string | null;
image_count: number;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface ImageExif {
make: string | null;
model: string | null;
lens: string | null;
iso: string | null;
f_number: string | null;
exposure_time: string | null;
focal_length: string | null;
datetime_original: string | null;
gps_lat: number | null;
gps_lon: number | null;
}
export interface TagCloudEntry {
count: number;
@@ -330,11 +354,14 @@ interface GalleryState {
minimumRating: number;
failedEmbeddingsOnly: boolean;
failedTaggingOnly: boolean;
colorFilter: [number, number, number] | null; // [r,g,b] dominant-color filter
colorBackfill: { processed: number; total: number; done: boolean } | null;
zoomPreset: ZoomPreset;
selectedImage: ImageRecord | null;
collectionTitle: string | null;
similarSourceImageId: number | null;
similarSourceFolderId: number | null;
similarSourceAlbumId: number | null; // album a similar search was launched from (enables "Similar: Album")
similarHasMore: boolean;
similarScope: SimilarScope;
similarFolderId: number | null;
@@ -374,6 +401,7 @@ interface GalleryState {
workerPaused: Record<number, Record<WorkerKey, boolean>>;
appVersion: string | null;
buildVariant: "cpu" | "cuda" | null;
updateStatus: UpdateStatus;
updateVersion: string | null;
updateProgress: number | null; // 0..1 download progress, null while size unknown
@@ -411,6 +439,14 @@ interface GalleryState {
duplicateLastScanned: number | null; // Unix timestamp (seconds)
duplicateScanFolderId: number | null | undefined; // undefined = never scanned
// Gallery multi-select (Feature A)
gallerySelectedIds: Set<number>;
// Albums (Feature B)
albums: Album[];
albumsLoaded: boolean;
selectedAlbumId: number | null;
loadFolders: () => Promise<void>;
loadBackgroundJobProgress: () => Promise<void>;
addFolder: (path: string) => Promise<void>;
@@ -435,6 +471,7 @@ interface GalleryState {
setMinimumRating: (minimumRating: number) => void;
setFailedEmbeddingsOnly: (failedEmbeddingsOnly: boolean) => void;
setFailedTaggingOnly: (failedTaggingOnly: boolean) => void;
setColorFilter: (color: [number, number, number] | null) => void;
showFailedTagging: (folderId: number) => void;
setZoomPreset: (zoomPreset: ZoomPreset) => void;
openImage: (image: ImageRecord) => void;
@@ -445,8 +482,11 @@ interface GalleryState {
loadExploreTags: () => Promise<void>;
showVisualCluster: (imageIds: number[]) => Promise<void>;
searchForTag: (tag: string) => void;
loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null) => Promise<void>;
loadSimilarByRegion: (imageId: number, crop: { x: number; y: number; w: number; h: number }, folderId?: number | null, sourceFolderId?: number | null) => Promise<void>;
loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null, albumId?: number | null) => Promise<void>;
loadSimilarByRegion: (imageId: number, crop: { x: number; y: number; w: number; h: number }, folderId?: number | null, sourceFolderId?: number | null, albumId?: number | null) => Promise<void>;
// Entry points that decide scope (album when launched from an album, else folder/all per similarScope).
findSimilar: (imageId: number, sourceFolderId: number | null) => Promise<void>;
findSimilarByRegion: (imageId: number, crop: { x: number; y: number; w: number; h: number }, sourceFolderId: number | null) => Promise<void>;
setSimilarScope: (scope: SimilarScope) => void;
suggestImageTags: (imageId: number) => Promise<string[]>;
loadCaptionModelStatus: () => Promise<void>;
@@ -531,6 +571,30 @@ interface GalleryState {
getImageTags: (imageId: number) => Promise<ImageTag[]>;
addUserTag: (imageId: number, tag: string) => Promise<ImageTag>;
removeTag: (tagId: number) => Promise<void>;
getImageExif: (imageId: number) => Promise<ImageExif>;
renameTag: (from: string, to: string) => Promise<void>;
deleteTag: (tag: string) => Promise<number>;
// Gallery multi-select (Feature A)
toggleGallerySelected: (imageId: number) => void;
selectAllGallery: () => void;
clearGallerySelection: () => void;
bulkSetFavorite: (favorite: boolean) => Promise<void>;
bulkSetRating: (rating: number) => Promise<void>;
bulkAddTags: (tags: string[]) => Promise<void>;
bulkRemoveTag: (tag: string) => Promise<void>;
bulkDeleteSelected: () => Promise<number>;
// Albums (Feature B)
loadAlbums: () => Promise<void>;
createAlbum: (name: string) => Promise<Album>;
renameAlbum: (albumId: number, name: string) => Promise<void>;
deleteAlbum: (albumId: number) => Promise<void>;
deleteAlbums: (albumIds: number[]) => Promise<void>;
reorderAlbums: (albumIds: number[]) => Promise<void>;
addToAlbum: (albumId: number, imageIds: number[]) => Promise<number>;
removeFromAlbum: (albumId: number, imageIds: number[]) => Promise<void>;
viewAlbum: (albumId: number) => void;
}
const PAGE_SIZE = 200;
@@ -773,11 +837,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
minimumRating: 0,
failedEmbeddingsOnly: false,
failedTaggingOnly: false,
colorFilter: null,
colorBackfill: null,
zoomPreset: "comfortable",
selectedImage: null,
collectionTitle: null,
similarSourceImageId: null,
similarSourceFolderId: null,
similarSourceAlbumId: null,
similarHasMore: false,
similarScope: "all_media",
similarFolderId: null,
@@ -815,6 +882,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
workerPaused: {},
appVersion: null,
buildVariant: null,
updateStatus: "idle",
updateVersion: null,
updateProgress: null,
@@ -849,6 +917,12 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
duplicateLastScanned: null,
duplicateScanFolderId: undefined,
gallerySelectedIds: new Set(),
albums: [],
albumsLoaded: false,
selectedAlbumId: null,
setCacheDir: (cacheDir) => set({ cacheDir }),
loadFolders: async () => {
@@ -957,7 +1031,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
},
selectFolder: (folderId) => {
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, failedTaggingOnly: false, imageLoadError: null });
// Leaving any album: drop the album-origin scope so the Folder/All pills
// highlight correctly again.
const similarScope = get().similarScope === "current_album" ? "all_media" : get().similarScope;
set({ selectedFolderId: folderId, selectedAlbumId: null, similarSourceAlbumId: null, similarScope, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, failedTaggingOnly: false, imageLoadError: null });
void get().loadImages(true);
},
@@ -990,12 +1067,38 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
},
loadImages: async (reset = false) => {
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly, failedTaggingOnly, activeView } = get();
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly, failedTaggingOnly, colorFilter, activeView } = get();
const parsedSearch = parseSearchValue(search);
const requestToken = ++galleryRequestToken;
set({ loadingImages: true, imageLoadError: null });
// Any fresh collection load invalidates a selection that referenced the
// previous set of visible images.
set({ loadingImages: true, imageLoadError: null, ...(reset ? { gallerySelectedIds: new Set<number>() } : {}) });
try {
// Album view loads from the album membership, honoring sort changes from
// the Toolbar while staying within the album (ignores folder/search/filters).
if (activeView === "album") {
const albumId = get().selectedAlbumId;
if (albumId === null) {
set({ loadingImages: false });
return;
}
const offset = reset ? 0 : loadedCount;
const result = await invoke<{ images: ImageRecord[]; total: number; offset: number; limit: number }>("get_album_images", {
params: { album_id: albumId, sort, offset, limit: PAGE_SIZE },
});
if (requestToken !== galleryRequestToken) return;
const albumName = get().albums.find((entry) => entry.id === albumId)?.name ?? "Album";
set((state) => ({
images: reset ? result.images : [...state.images, ...result.images],
totalImages: result.total,
loadedCount: reset ? result.images.length : state.loadedCount + result.images.length,
loadingImages: false,
collectionTitle: albumName,
}));
return;
}
if (parsedSearch.mode === "semantic" && parsedSearch.query) {
const images = await invoke<ImageRecord[]>("semantic_search_images", {
params: {
@@ -1078,6 +1181,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
rating_min: minimumRating > 0 ? minimumRating : null,
embedding_failed_only: failedEmbeddingsOnly,
tagging_failed_only: failedTaggingOnly,
color: colorFilter,
sort,
offset,
limit: activeView === "timeline" ? TIMELINE_PAGE_SIZE : PAGE_SIZE,
@@ -1107,9 +1211,31 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
const { loadedCount, totalImages, loadingImages, collectionTitle, similarSourceImageId, similarHasMore, similarFolderId, similarCrop } = get();
if (loadingImages || loadedCount >= totalImages) return;
if (collectionTitle === "Explore Cluster") return;
const { activeView, selectedAlbumId, sort } = get();
if (activeView === "album" && selectedAlbumId !== null) {
const requestToken = ++galleryRequestToken;
set({ loadingImages: true });
try {
const result = await invoke<{ images: ImageRecord[]; total: number; offset: number; limit: number }>("get_album_images", {
params: { album_id: selectedAlbumId, sort, offset: loadedCount, limit: PAGE_SIZE },
});
if (requestToken !== galleryRequestToken) return;
set((state) => ({
images: [...state.images, ...result.images],
loadedCount: state.loadedCount + result.images.length,
totalImages: result.total,
loadingImages: false,
}));
} catch {
if (requestToken !== galleryRequestToken) return;
set({ loadingImages: false });
}
return;
}
const pageAlbumId = get().similarScope === "current_album" ? get().similarSourceAlbumId : null;
if (collectionTitle === "Similar Images" && similarSourceImageId !== null) {
if (!similarHasMore) return;
await get().loadSimilarImages(similarSourceImageId, similarFolderId, false, get().similarSourceFolderId ?? null);
await get().loadSimilarImages(similarSourceImageId, similarFolderId, false, get().similarSourceFolderId ?? null, pageAlbumId);
return;
}
if (collectionTitle === "Region Search Results" && similarSourceImageId !== null && similarCrop !== null) {
@@ -1124,7 +1250,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
crop_y: similarCrop.y,
crop_w: similarCrop.w,
crop_h: similarCrop.h,
folder_id: similarFolderId,
folder_id: pageAlbumId !== null ? null : similarFolderId,
album_id: pageAlbumId,
offset: loadedCount,
limit: PAGE_SIZE,
},
@@ -1196,6 +1323,11 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
void get().loadImages(true);
},
setColorFilter: (colorFilter) => {
set({ colorFilter, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
void get().loadImages(true);
},
showFailedTagging: (folderId) => {
set({
selectedFolderId: folderId,
@@ -1225,8 +1357,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
closeImage: () => set({ selectedImage: null }),
setView: (activeView) => {
// Leaving an album view drops the album-origin similar scope.
const similarScopeReset = get().similarScope === "current_album" ? "all_media" : get().similarScope;
if (activeView === "timeline") {
set({ activeView, sort: "taken_asc", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarSourceFolderId: null, similarFolderId: null, similarHasMore: false, similarCrop: null, imageLoadError: null });
set({ activeView, sort: "taken_asc", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarSourceFolderId: null, similarSourceAlbumId: null, similarScope: similarScopeReset, similarFolderId: null, similarHasMore: false, similarCrop: null, imageLoadError: null });
void get().loadImages(true);
return;
}
@@ -1244,7 +1378,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
return;
}
}
set({ activeView });
set({ activeView, similarSourceAlbumId: null, similarScope: similarScopeReset });
},
setExploreMode: (exploreMode) => set({ exploreMode }),
@@ -1305,6 +1439,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
similarSourceFolderId: null,
similarHasMore: false,
similarFolderId: null,
gallerySelectedIds: new Set<number>(),
selectedAlbumId: null,
galleryScrollResetKey: state.galleryScrollResetKey + 1,
}));
@@ -1339,10 +1475,12 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
void get().loadImages(true);
},
loadSimilarImages: async (imageId, folderId = get().selectedFolderId, reset = true, sourceFolderId = folderId ?? null) => {
loadSimilarImages: async (imageId, folderId = get().selectedFolderId, reset = true, sourceFolderId = folderId ?? null, albumId = null) => {
const requestToken = ++galleryRequestToken;
const offset = reset ? 0 : get().loadedCount;
const similarScope = folderId === null ? "all_media" : "current_folder";
const similarScope: SimilarScope = albumId !== null ? "current_album" : folderId === null ? "all_media" : "current_folder";
// Album scope drives results off album membership, so the folder query is null.
const queryFolderId = albumId !== null ? null : folderId ?? null;
set((state) => ({
images: reset ? [] : state.images,
loadedCount: reset ? 0 : state.loadedCount,
@@ -1351,8 +1489,13 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
imageLoadError: null,
similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId,
similarFolderId: folderId ?? null,
similarFolderId: queryFolderId,
similarScope,
// Force the gallery grid so results (and the bulk bar) render regardless
// of which view the search was launched from.
activeView: "gallery",
gallerySelectedIds: reset ? new Set<number>() : state.gallerySelectedIds,
selectedAlbumId: null,
galleryScrollResetKey: reset ? state.galleryScrollResetKey + 1 : state.galleryScrollResetKey,
}));
@@ -1360,7 +1503,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
const result = await invoke<SimilarImagesPage>("find_similar_images", {
params: {
image_id: imageId,
folder_id: folderId ?? null,
folder_id: queryFolderId,
album_id: albumId,
offset,
limit: PAGE_SIZE,
threshold: SIMILAR_DISTANCE_THRESHOLD,
@@ -1382,7 +1526,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId,
similarHasMore: result.has_more,
similarFolderId: folderId ?? null,
similarFolderId: queryFolderId,
similarScope,
selectedImage: reset ? null : state.selectedImage,
};
@@ -1400,16 +1544,17 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId,
similarHasMore: false,
similarFolderId: folderId ?? null,
similarFolderId: queryFolderId,
similarScope,
selectedImage: null,
});
}
},
loadSimilarByRegion: async (imageId, crop, folderId = get().selectedFolderId, sourceFolderId = folderId ?? null) => {
loadSimilarByRegion: async (imageId, crop, folderId = get().selectedFolderId, sourceFolderId = folderId ?? null, albumId = null) => {
const requestToken = ++galleryRequestToken;
const similarScope = folderId === null ? "all_media" : "current_folder";
const similarScope: SimilarScope = albumId !== null ? "current_album" : folderId === null ? "all_media" : "current_folder";
const queryFolderId = albumId !== null ? null : folderId ?? null;
set((state) => ({
images: [],
loadedCount: 0,
@@ -1418,9 +1563,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
imageLoadError: null,
similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId,
similarFolderId: folderId ?? null,
similarFolderId: queryFolderId,
similarCrop: crop,
similarScope,
// Force the gallery grid so results (and the bulk bar) render regardless
// of which view the search was launched from.
activeView: "gallery",
gallerySelectedIds: new Set<number>(),
selectedAlbumId: null,
galleryScrollResetKey: state.galleryScrollResetKey + 1,
selectedImage: null,
}));
@@ -1433,7 +1583,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
crop_y: crop.y,
crop_w: crop.w,
crop_h: crop.h,
folder_id: folderId ?? null,
folder_id: queryFolderId,
album_id: albumId,
offset: 0,
limit: PAGE_SIZE,
},
@@ -1451,7 +1602,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId,
similarHasMore: result.has_more,
similarFolderId: folderId ?? null,
similarFolderId: queryFolderId,
similarCrop: crop,
similarScope,
});
@@ -1468,22 +1619,51 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId,
similarHasMore: false,
similarFolderId: folderId ?? null,
similarFolderId: queryFolderId,
similarCrop: crop,
similarScope,
selectedImage: null,
});
}
},
// Decide the scope at launch: album when triggered from an album, else the
// current folder/all preference. Sets similarSourceAlbumId so the "Similar:
// Album" pill and scope toggle work afterward.
findSimilar: (imageId, sourceFolderId) => {
const { activeView, selectedAlbumId, similarScope } = get();
const albumOrigin = activeView === "album" ? selectedAlbumId : null;
set({ similarSourceAlbumId: albumOrigin });
// Respect the chosen scope; album is the default in an album view but the
// user can override to Folder/All before searching.
if (similarScope === "current_album" && albumOrigin !== null) {
return get().loadSimilarImages(imageId, null, true, sourceFolderId, albumOrigin);
}
const folderId = similarScope === "current_folder" ? sourceFolderId : null;
return get().loadSimilarImages(imageId, folderId, true, sourceFolderId, null);
},
findSimilarByRegion: (imageId, crop, sourceFolderId) => {
const { activeView, selectedAlbumId, similarScope } = get();
const albumOrigin = activeView === "album" ? selectedAlbumId : null;
set({ similarSourceAlbumId: albumOrigin });
if (similarScope === "current_album" && albumOrigin !== null) {
return get().loadSimilarByRegion(imageId, crop, null, sourceFolderId, albumOrigin);
}
const folderId = similarScope === "current_folder" ? sourceFolderId : null;
return get().loadSimilarByRegion(imageId, crop, folderId, sourceFolderId, null);
},
setSimilarScope: (similarScope) => {
set({ similarScope });
const { similarSourceImageId, similarSourceFolderId, selectedFolderId, collectionTitle, similarCrop } = get();
const { similarSourceImageId, similarSourceFolderId, similarSourceAlbumId, selectedFolderId, collectionTitle, similarCrop } = get();
if (similarSourceImageId === null) return;
const albumId = similarScope === "current_album" ? similarSourceAlbumId : null;
const folderId = similarScope === "current_folder" ? (similarSourceFolderId ?? selectedFolderId) : null;
if (collectionTitle === "Region Search Results" && similarCrop !== null) {
void get().loadSimilarByRegion(similarSourceImageId, similarCrop, folderId, similarSourceFolderId);
void get().loadSimilarByRegion(similarSourceImageId, similarCrop, folderId, similarSourceFolderId, albumId);
} else {
void get().loadSimilarImages(similarSourceImageId, folderId, true, similarSourceFolderId);
void get().loadSimilarImages(similarSourceImageId, folderId, true, similarSourceFolderId, albumId);
}
},
@@ -1781,6 +1961,12 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
} catch {
// leave null; the UI falls back to a dash
}
try {
const variant = await invoke<string>("get_build_variant");
set({ buildVariant: variant === "cuda" ? "cuda" : "cpu" });
} catch {
// leave null; the badge is hidden until known
}
},
checkForUpdates: async (options) => {
@@ -2097,6 +2283,280 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
set({ exploreTagsFolderId: undefined });
},
getImageExif: async (imageId) => {
return invoke<ImageExif>("get_image_exif", { params: { image_id: imageId } });
},
renameTag: async (from, to) => {
await invoke("rename_tag", { params: { from, to } });
// Tag content changed — invalidate the explore-tags and tag-cloud caches.
set({
exploreTagsFolderId: undefined,
exploreTagEntries: [],
tagCloudFolderId: undefined,
tagCloudEntries: [],
});
const parsed = parseSearchValue(get().search);
if (parsed.mode === "tag" && parsed.query === from) {
// An active tag-search points at the old name — repoint it so the gallery
// refreshes instead of showing stale results for a tag that no longer exists.
get().setSearch(`/t ${to}`);
} else if (get().activeView === "explore") {
await get().loadExploreTags();
}
},
deleteTag: async (tag) => {
const removed = await invoke<number>("delete_tag", { params: { tag } });
set({
exploreTagsFolderId: undefined,
exploreTagEntries: [],
tagCloudFolderId: undefined,
tagCloudEntries: [],
});
const parsed = parseSearchValue(get().search);
if (parsed.mode === "tag" && parsed.query === tag) {
// The searched tag is gone — reload so the now-empty result is reflected.
void get().loadImages(true);
} else if (get().activeView === "explore") {
await get().loadExploreTags();
}
return removed;
},
// ── Gallery multi-select (Feature A) ──────────────────────────────────────
toggleGallerySelected: (imageId) => {
set((state) => {
const next = new Set(state.gallerySelectedIds);
if (next.has(imageId)) next.delete(imageId);
else next.add(imageId);
return { gallerySelectedIds: next };
});
},
selectAllGallery: () => {
set((state) => ({ gallerySelectedIds: new Set(state.images.map((image) => image.id)) }));
},
clearGallerySelection: () => set({ gallerySelectedIds: new Set() }),
bulkSetFavorite: async (favorite) => {
const ids = Array.from(get().gallerySelectedIds);
if (ids.length === 0) return;
const updated = await invoke<ImageRecord[]>("bulk_update_details", {
params: { image_ids: ids, favorite, rating: null },
});
set((state) => {
const match = state.selectedImage && updated.find((image) => image.id === state.selectedImage!.id);
// Derived collections keep their relevance order (replace in place); only
// the real sorted gallery re-sorts.
return {
images: isDerivedCollectionTitle(state.collectionTitle)
? replaceExistingImages(state.images, updated)
: mergeImages(state.images, updated, state.sort),
selectedImage: match ?? state.selectedImage,
};
});
},
bulkSetRating: async (rating) => {
const ids = Array.from(get().gallerySelectedIds);
if (ids.length === 0) return;
const updated = await invoke<ImageRecord[]>("bulk_update_details", {
params: { image_ids: ids, favorite: null, rating },
});
set((state) => {
const match = state.selectedImage && updated.find((image) => image.id === state.selectedImage!.id);
return {
images: isDerivedCollectionTitle(state.collectionTitle)
? replaceExistingImages(state.images, updated)
: mergeImages(state.images, updated, state.sort),
selectedImage: match ?? state.selectedImage,
};
});
},
bulkAddTags: async (tags) => {
const ids = Array.from(get().gallerySelectedIds);
const cleaned = tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0);
if (ids.length === 0 || cleaned.length === 0) return;
await invoke<void>("bulk_add_tags", { params: { image_ids: ids, tags: cleaned } });
// New tags landed — invalidate Explore tag caches.
set({ exploreTagsFolderId: undefined, tagCloudFolderId: undefined, tagCloudEntries: [] });
},
bulkRemoveTag: async (tag) => {
const ids = Array.from(get().gallerySelectedIds);
if (ids.length === 0 || !tag.trim()) return;
await invoke<void>("bulk_remove_tag", { params: { image_ids: ids, tag: tag.trim() } });
set({ exploreTagsFolderId: undefined, tagCloudFolderId: undefined, tagCloudEntries: [] });
},
bulkDeleteSelected: async () => {
const ids = Array.from(get().gallerySelectedIds);
if (ids.length === 0) return 0;
const affectedFolderIds = new Set<number>(
get().images.filter((image) => get().gallerySelectedIds.has(image.id)).map((image) => image.folder_id),
);
const succeededIds = await invoke<number[]>("delete_images_from_disk", { params: { image_ids: ids } });
const succeededSet = new Set(succeededIds);
set((state) => ({
// Only remove images confirmed deleted — failed files remain selected for retry.
images: state.images.filter((image) => !succeededSet.has(image.id)),
loadedCount: state.images.filter((image) => !succeededSet.has(image.id)).length,
totalImages: Math.max(0, state.totalImages - succeededIds.length),
gallerySelectedIds: new Set([...state.gallerySelectedIds].filter((id) => !succeededSet.has(id))),
// Deletion changes tag/duplicate/album aggregates.
tagCloudFolderId: undefined,
tagCloudEntries: [],
exploreTagsFolderId: undefined,
}));
// The DB cascade already removed these from album_images; refresh counts/covers.
void get().loadAlbums();
await invoke("invalidate_duplicate_scan_cache", { folderId: null });
for (const folderId of affectedFolderIds) {
await invoke("invalidate_duplicate_scan_cache", { folderId });
}
return succeededIds.length;
},
// ── Albums (Feature B) ────────────────────────────────────────────────────
loadAlbums: async () => {
const albums = await invoke<Album[]>("list_albums");
set({ albums, albumsLoaded: true });
},
createAlbum: async (name) => {
const album = await invoke<Album>("create_album", { params: { name } });
await get().loadAlbums();
return album;
},
renameAlbum: async (albumId, name) => {
await invoke("rename_album", { params: { album_id: albumId, new_name: name } });
await get().loadAlbums();
},
deleteAlbum: async (albumId) => {
await invoke("delete_album", { params: { album_id: albumId } });
// If the deleted album is being viewed, drop back to All Media.
if (get().activeView === "album" && get().selectedAlbumId === albumId) {
set({ activeView: "gallery", selectedAlbumId: null, collectionTitle: null });
void get().loadImages(true);
}
await get().loadAlbums();
},
deleteAlbums: async (albumIds) => {
if (albumIds.length === 0) return;
await invoke("delete_albums", { params: { album_ids: albumIds } });
// If a deleted album is being viewed, drop back to All Media.
if (get().activeView === "album" && get().selectedAlbumId !== null && albumIds.includes(get().selectedAlbumId!)) {
set({ activeView: "gallery", selectedAlbumId: null, collectionTitle: null });
void get().loadImages(true);
}
await get().loadAlbums();
},
reorderAlbums: async (albumIds) => {
const previous = get().albums;
const byId = new Map(previous.map((album) => [album.id, album]));
const albums = albumIds
.map((id, index) => {
const album = byId.get(id);
return album ? { ...album, sort_order: index + 1 } : null;
})
.filter((album): album is Album => album !== null);
set({ albums });
try {
await invoke("reorder_albums", { params: { album_ids: albumIds } });
} catch (error) {
set({ albums: previous });
throw error;
}
},
addToAlbum: async (albumId, imageIds) => {
if (imageIds.length === 0) return 0;
const added = await invoke<number>("add_images_to_album", {
params: { album_id: albumId, image_ids: imageIds },
});
await get().loadAlbums();
return added;
},
removeFromAlbum: async (albumId, imageIds) => {
if (imageIds.length === 0) return;
await invoke("remove_images_from_album", {
params: { album_id: albumId, image_ids: imageIds },
});
// If viewing this album, splice the removed images out immediately.
if (get().activeView === "album" && get().selectedAlbumId === albumId) {
const removed = new Set(imageIds);
set((state) => {
const nextImages = state.images.filter((image) => !removed.has(image.id));
// Decrement by what was actually on screen, not the requested count —
// some ids may live beyond the loaded page.
const removedFromView = state.images.length - nextImages.length;
return {
images: nextImages,
loadedCount: nextImages.length,
totalImages: Math.max(0, state.totalImages - removedFromView),
gallerySelectedIds: new Set([...state.gallerySelectedIds].filter((id) => !removed.has(id))),
};
});
}
await get().loadAlbums();
},
viewAlbum: (albumId) => {
const requestToken = ++galleryRequestToken;
const album = get().albums.find((entry) => entry.id === albumId);
const sort = get().sort;
set((state) => ({
activeView: "album",
selectedAlbumId: albumId,
search: "",
images: [],
totalImages: album?.image_count ?? 0,
loadedCount: 0,
loadingImages: true,
collectionTitle: album?.name ?? "Album",
imageLoadError: null,
similarSourceImageId: null,
similarSourceFolderId: null,
similarSourceAlbumId: albumId,
similarScope: "current_album",
similarHasMore: false,
similarFolderId: null,
similarCrop: null,
gallerySelectedIds: new Set<number>(),
galleryScrollResetKey: state.galleryScrollResetKey + 1,
}));
void (async () => {
try {
const result = await invoke<{ images: ImageRecord[]; total: number; offset: number; limit: number }>("get_album_images", {
params: { album_id: albumId, sort, offset: 0, limit: PAGE_SIZE },
});
if (requestToken !== galleryRequestToken) return;
set({
images: result.images,
totalImages: result.total,
loadedCount: result.images.length,
loadingImages: false,
imageLoadError: null,
collectionTitle: album?.name ?? "Album",
});
} catch (error) {
if (requestToken !== galleryRequestToken) return;
set({ images: [], totalImages: 0, loadedCount: 0, loadingImages: false, imageLoadError: String(error) });
}
})();
},
loadDuplicateScanCache: async (folderId = null) => {
interface CacheResult { groups: DuplicateGroup[]; scanned_at: number }
const cached = await invoke<CacheResult | null>("load_duplicate_scan_cache", { folderId: folderId ?? null });
@@ -2219,7 +2679,13 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
});
set((state) => ({
images: replaceImage(state.images, updatedImage, state.sort),
// Derived collections (similar / region / semantic / tag / album results)
// are ordered by relevance, not `state.sort` — re-sorting them on a
// favorite/rating change would scramble the results. Replace in place
// there; only the real sorted gallery re-sorts.
images: isDerivedCollectionTitle(state.collectionTitle)
? replaceExistingImages(state.images, [updatedImage])
: replaceImage(state.images, updatedImage, state.sort),
selectedImage: state.selectedImage?.id === updatedImage.id ? updatedImage : state.selectedImage,
}));
},
@@ -2350,7 +2816,15 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
const batch = event.payload;
set((state) => {
if (isDerivedCollectionTitle(state.collectionTitle) || state.activeView === "explore") {
// Album view holds a fixed membership set; newly-indexed files never
// auto-join it. Guarding on activeView also covers the brief window
// where collectionTitle is null mid sort-change in an album.
if (
isDerivedCollectionTitle(state.collectionTitle) ||
state.activeView === "explore" ||
state.activeView === "album" ||
state.colorFilter !== null
) {
return state;
}
@@ -2386,12 +2860,22 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
const batch = event.payload;
set((state) => {
if (isDerivedCollectionTitle(state.collectionTitle) || state.activeView === "explore") {
const selectedImage =
const selectedImageUpdate =
state.selectedImage && batch.images.some((image) => image.id === state.selectedImage?.id)
? batch.images.find((image) => image.id === state.selectedImage?.id) ?? state.selectedImage
: state.selectedImage;
return { selectedImage };
// Album view holds already-loaded images; paint thumbnail/metadata
// fills in place (without re-sorting) so tiles refresh while browsing.
if (state.activeView === "album") {
return {
images: replaceExistingImages(state.images, batch.images),
selectedImage: selectedImageUpdate,
};
}
if (isDerivedCollectionTitle(state.collectionTitle) || state.activeView === "explore") {
return { selectedImage: selectedImageUpdate };
}
const visibleImages = batch.images.filter((image) =>
@@ -2407,18 +2891,13 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
),
);
const selectedImage =
state.selectedImage && batch.images.some((image) => image.id === state.selectedImage?.id)
? batch.images.find((image) => image.id === state.selectedImage?.id) ?? state.selectedImage
: state.selectedImage;
if (visibleImages.length === 0) {
return { selectedImage };
return { selectedImage: selectedImageUpdate };
}
return {
images: replaceExistingImages(state.images, visibleImages),
selectedImage,
selectedImage: selectedImageUpdate,
};
});
});
@@ -2472,6 +2951,13 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}
});
const unlistenColorBackfill = await listen<{ processed: number; total: number; done: boolean }>(
"color-backfill-progress",
(event) => {
set({ colorBackfill: event.payload.done ? null : event.payload });
},
);
return () => {
unlistenProgress();
unlistenMediaJobs();
@@ -2482,6 +2968,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
unlistenWatcherDeleted();
unlistenFolderCounts();
unlistenFfmpegProgress();
unlistenColorBackfill();
};
},
}));