diff --git a/CHANGELOG.md b/CHANGELOG.md index 0641712..b70b9c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,38 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### Added +- **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. +- **Albums** — curate your own collections. A new Albums section in the sidebar + (with cover thumbnails, kept visually distinct from Libraries) lets you create, + rename, 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 the section's 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. +- **Build badge in Settings** — the version line in Settings → Updates now shows + whether the running build is the CPU or CUDA (GPU-accelerated) variant. +- **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. +- **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. +- **Reorderable albums** — drag albums in the sidebar (hover the row for the + drag handle) to set their order, which persists across sessions. +- **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. - **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 @@ -17,11 +49,19 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### 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. diff --git a/package.json b/package.json index b8b789f..e87a8af 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src-tauri/src/color.rs b/src-tauri/src/color.rs new file mode 100644 index 0000000..375c647 --- /dev/null +++ b/src-tauri/src/color.rs @@ -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.0–1.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 { + 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 = 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> { + 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; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f6c0da1..b591786 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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, pub embedding_failed_only: Option, pub tagging_failed_only: Option, + /// 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, pub offset: Option, pub limit: Option, @@ -56,6 +61,9 @@ pub struct UpdateImageDetailsParams { pub struct FindSimilarImagesParams { pub image_id: i64, pub folder_id: Option, + /// When set, restrict results to images in this album (takes precedence + /// over `folder_id`). Used by the "Similar: Album" scope. + pub album_id: Option, pub offset: Option, pub limit: Option, pub threshold: Option, @@ -70,6 +78,8 @@ pub struct FindSimilarByRegionParams { pub crop_w: f32, pub crop_h: f32, pub folder_id: Option, + /// Restrict to an album (takes precedence over `folder_id`). + pub album_id: Option, pub offset: Option, pub limit: Option, } @@ -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,24 +761,36 @@ 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 { - Some(folder_id) => vector::search_image_ids_by_embedding_in_folder( + // 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, - folder_id, + album_id, Some(params.image_id), offset + limit + 1, ) - .map_err(|e| e.to_string())?, - None => { - // Fetch one extra candidate to compensate for the source image that - // will be removed, so has_more is accurate and results span multiple pages. - let mut ids = - vector::search_image_ids_by_embedding(&conn, &embedding, offset + limit + 2) - .map_err(|e| e.to_string())?; - ids.retain(|&id| id != params.image_id); - ids + .map_err(|e| e.to_string())? + } else { + match params.folder_id { + Some(folder_id) => vector::search_image_ids_by_embedding_in_folder( + &conn, + &embedding, + folder_id, + Some(params.image_id), + offset + limit + 1, + ) + .map_err(|e| e.to_string())?, + None => { + // Fetch one extra candidate to compensate for the source image that + // will be removed, so has_more is accurate and results span multiple pages. + let mut ids = + vector::search_image_ids_by_embedding(&conn, &embedding, offset + limit + 2) + .map_err(|e| e.to_string())?; + ids.retain(|&id| id != params.image_id); + ids + } } }; @@ -1647,6 +1673,119 @@ pub async fn get_images_by_ids( db::get_images_by_ids(&conn, ¶ms.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, + pub model: Option, + pub lens: Option, + pub iso: Option, + pub f_number: Option, + pub exposure_time: Option, + pub focal_length: Option, + pub datetime_original: Option, + pub gps_lat: Option, + pub gps_lon: Option, +} + +fn gps_coord(exif: &exif::Exif, coord: exif::Tag, reference: exif::Tag) -> Option { + 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 { + 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 { + 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 { + 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, +} + +#[derive(Deserialize)] +pub struct DeleteAlbumsParams { + pub album_ids: Vec, +} + +#[derive(Deserialize)] +pub struct AlbumImagesParams { + pub album_id: i64, + pub image_ids: Vec, +} + +#[derive(Deserialize)] +pub struct GetAlbumImagesParams { + pub album_id: i64, + pub sort: Option, + pub offset: Option, + pub limit: Option, +} + +#[tauri::command] +pub async fn list_albums(db: State<'_, DbState>) -> Result, 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 { + 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, ¶ms.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, ¶ms.album_ids).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn add_images_to_album( + db: State<'_, DbState>, + params: AlbumImagesParams, +) -> Result { + let conn = db.get().map_err(|e| e.to_string())?; + db::add_images_to_album(&conn, params.album_id, ¶ms.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, ¶ms.image_ids) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn get_album_images( + db: State<'_, DbState>, + params: GetAlbumImagesParams, +) -> Result { + 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, + pub favorite: Option, + pub rating: Option, +} + +#[derive(Deserialize)] +pub struct BulkAddTagsParams { + pub image_ids: Vec, + pub tags: Vec, +} + +#[derive(Deserialize)] +pub struct BulkRemoveTagParams { + pub image_ids: Vec, + pub tag: String, +} + +#[tauri::command] +pub async fn bulk_update_details( + db: State<'_, DbState>, + params: BulkUpdateDetailsParams, +) -> Result, String> { + let conn = db.get().map_err(|e| e.to_string())?; + db::bulk_update_details(&conn, ¶ms.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, ¶ms.image_ids, ¶ms.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, ¶ms.image_ids, ¶ms.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(¶ms.lat) + || !(-180.0..=180.0).contains(¶ms.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 // --------------------------------------------------------------------------- diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 05d8d04..f6b2bd6 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -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, + pub cover_thumbnail_path: Option, + 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 { + 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> { + 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::>>()?) +} + +pub fn get_album(conn: &Connection, album_id: i64) -> Result { + 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 { + 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 { + 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 { + 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> { + // 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::>>()?) +} + +// ── 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, + rating: Option, +) -> Result> { + 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> { + 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::>>()?) +} + +/// Count of images still awaiting palette extraction (for backfill progress). +pub fn count_images_missing_colors(conn: &Connection) -> Result { + 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 { 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 { 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 { + 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 { let inserted = conn.execute( "INSERT INTO tagging_jobs (image_id, status, attempts, last_error, created_at, updated_at) diff --git a/src-tauri/src/hnsw_index.rs b/src-tauri/src/hnsw_index.rs index 0836a03..bc4f497 100644 --- a/src-tauri/src/hnsw_index.rs +++ b/src-tauri/src/hnsw_index.rs @@ -92,6 +92,7 @@ pub fn find_similar_image_matches( conn: &Connection, image_id: i64, folder_id: Option, + album_id: Option, 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> = 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> = 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::>>()?; + 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 = if let Some(image_ids) = folder_image_ids { + let neighbours: Vec = if let Some(image_ids) = allowed_image_ids { let mut allowed_ids = image_ids .into_iter() .filter_map(|allowed_image_id| { diff --git a/src-tauri/src/indexer.rs b/src-tauri/src/indexer.rs index 199fad8..789d34e 100644 --- a/src-tauri/src/indexer.rs +++ b/src-tauri/src/indexer.rs @@ -190,6 +190,13 @@ pub struct MediaUpdateBatch { pub images: Vec, } +#[derive(Clone, Serialize)] +pub struct ColorBackfillProgress { + pub processed: i64, + pub total: i64, + pub done: bool, +} + #[derive(Clone, Serialize)] pub struct MediaJobProgressEvent { pub progress: Vec, @@ -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( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dec5ccc..76966ce 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/thumbnail.rs b/src-tauri/src/thumbnail.rs index cb45785..af29b9c 100644 --- a/src-tauri/src/thumbnail.rs +++ b/src-tauri/src/thumbnail.rs @@ -12,6 +12,9 @@ pub struct GeneratedThumbnail { pub path: PathBuf, pub width: Option, pub height: Option, + /// 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 { @@ -28,6 +31,7 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result Result Result>>()?) } +/// 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, + limit: usize, +) -> Result> { + 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::>>()?) +} + #[allow(dead_code)] pub fn search_caption_ids_by_embedding( conn: &Connection, diff --git a/src/App.tsx b/src/App.tsx index 45bc904..3dfe52f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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; diff --git a/src/components/BulkActionBar.tsx b/src/components/BulkActionBar.tsx new file mode 100644 index 0000000..3404f24 --- /dev/null +++ b/src/components/BulkActionBar.tsx @@ -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(null); + const [deleting, setDeleting] = useState(false); + const [creatingAlbum, setCreatingAlbum] = useState(false); + const [newAlbumName, setNewAlbumName] = useState(""); + const barRef = useRef(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) => 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 ( +
event.stopPropagation()} + > +
+ {selectedCount} selected + {loadedCount < totalImages || loadedCount > selectedCount ? ( + + ) : null} +
+ +
+ +
+ + {panel === "tag" ? setPanel(null)} /> : null} +
+ +
+ + {panel === "rating" ? ( +
+ {Array.from({ length: 5 }, (_, index) => { + const rating = index + 1; + return ( + + ); + })} + +
+ ) : null} +
+ + + +
+ + {panel === "album" ? ( +
+
+ {albums.length === 0 ? ( +

No albums yet — create one below.

+ ) : ( + albums.map((album) => ( + + )) + )} +
+
{ + event.preventDefault(); + void handleCreateAlbum(); + }} + > + setNewAlbumName(event.target.value)} + disabled={creatingAlbum} + /> + +
+
+ ) : null} +
+ + {inAlbumView ? ( + + ) : null} + +
+ +
+ + {panel === "delete" ? ( +
+
+ + + +

Delete from disk

+
+

+ Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer. + This removes the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone. +

+
+ + +
+
+ ) : null} +
+ + +
+ ); +} diff --git a/src/components/ColorFilter.tsx b/src/components/ColorFilter.tsx new file mode 100644 index 0000000..5db3aef --- /dev/null +++ b/src/components/ColorFilter.tsx @@ -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(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 ( +
+ {/* Trigger — a single palette icon; shows the active color as a dot when a + filter is applied so the collapsed state still communicates it. */} + + + + + + {open ? ( + + {SWATCHES.map((swatch) => { + const active = rgbEquals(colorFilter, swatch.rgb); + return ( + + ) : null} + + {colorBackfill && colorBackfill.total > 0 ? ( + + sampling {colorBackfill.processed.toLocaleString()}/{colorBackfill.total.toLocaleString()} + + ) : null} + + ) : null} + +
+ ); +} diff --git a/src/components/DuplicateFinder.tsx b/src/components/DuplicateFinder.tsx index 7824a49..f7fe36e 100644 --- a/src/components/DuplicateFinder.tsx +++ b/src/components/DuplicateFinder.tsx @@ -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(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 - +
+ + {confirmingDelete && !deleting ? ( + <> + {/* Click-away backdrop */} +
setConfirmingDelete(false)} /> +
+
+ + + +

Delete from disk

+
+

+ Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer. + This removes the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone. +

+
+ + +
+
+ + ) : null} +
) : null} {/* Image / placeholder */} {src && !errored ? ( <> @@ -173,6 +220,17 @@ export function ImageTile({ {/* Persistent badges — only shown when meaningful */}
+ {image.embedding_status === "failed" && ( +
+ + + +
+ )} {image.favorite && (
@@ -196,21 +254,6 @@ export function ImageTile({ )}
- {/* Embedding failed badge — top-left */} - {image.embedding_status === "failed" && ( -
-
- - - -
-
- )} - {/* Hover overlay — slides up from bottom */}
@@ -230,7 +273,7 @@ export function ImageTile({ )}
- +
+ ); } @@ -338,7 +382,8 @@ export function Gallery() { }, []); return ( -
+
+
{images.length === 0 && loadingImages ? (
@@ -448,5 +493,10 @@ export function Gallery() { /> ) : null}
+ + {/* Pinned to the bottom of the gallery viewport — outside the scroll + container so it stays put while the grid scrolls. */} + +
); } diff --git a/src/components/Lightbox.tsx b/src/components/Lightbox.tsx index 3a34c0d..f4e08b3 100644 --- a/src/components/Lightbox.tsx +++ b/src/components/Lightbox.tsx @@ -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([]); + const [imageExif, setImageExif] = useState(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(null); + const [newAlbumName, setNewAlbumName] = useState(""); + const [albumAdding, setAlbumAdding] = useState(false); const [regionSelectMode, setRegionSelectMode] = useState(false); const [isDragging, setIsDragging] = useState(false); const [dragRect, setDragRect] = useState(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() {
+
+
+

Albums

+ +
+ {albumMenuOpen ? ( +
+
+ {albums.length === 0 ? ( +

No albums yet — create one below.

+ ) : ( + albums.map((album) => ( + + )) + )} +
+
{ + 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)); + }} + > + setNewAlbumName(e.target.value)} + disabled={albumAdding} + /> + +
+
+ ) : null} +
+ + {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)) ? ( +
+

Camera

+
+ {imageExif.make || imageExif.model ? ( +

+ {[imageExif.make, imageExif.model].filter(Boolean).join(" ")} +

+ ) : null} + {imageExif.lens ?

{imageExif.lens}

: null} + {imageExif.f_number || imageExif.exposure_time || imageExif.iso || imageExif.focal_length ? ( +
+ {imageExif.f_number ? {imageExif.f_number} : null} + {imageExif.exposure_time ? {imageExif.exposure_time} : null} + {imageExif.iso ? ISO {imageExif.iso} : null} + {imageExif.focal_length ? {imageExif.focal_length} : null} +
+ ) : null} + {imageExif.gps_lat != null && imageExif.gps_lon != null ? ( + + ) : null} +
+
+ ) : null} +

Path

diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index aaf2e88..3d09b97 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -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={ Phokus {appVersion ? `v${appVersion}` : "—"} + {buildVariant ? ( + + {buildVariant === "cuda" ? "CUDA" : "CPU"} + + ) : null} {updateStatus === "available" || updateStatus === "downloading" || updateStatus === "installing" ? ( v{updateVersion} available ) : updateStatus === "upToDate" ? ( diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index a3da94e..3c67282 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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(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) => ( + + ); + + return ( +
+ {item("Rename", onRename)} +
+ {item("Delete album", onDelete, true)} +
+ ); +} + +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(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 = ( +
{ + 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 ? ( +
+ + + +
+ ) : null} + + {/* Drag handle — hover-revealed, reorders albums */} + {reorderable ? ( + + ) : null} + + {/* Cover thumbnail — distinguishes albums from folder rows */} +
+ {cover ? ( + + ) : ( +
+ + + +
+ )} +
+ +
+ {renaming ? ( + 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()} + /> + ) : ( +
+ {album.name} +
+ )} +
{album.image_count.toLocaleString()}
+
+ + {!renaming && confirmingRemoval ? ( +
e.stopPropagation()}> + + +
+ ) : null} + + {menu ? ( + setMenu(null)} + onRename={() => setRenaming(true)} + onDelete={() => setConfirmingRemoval(true)} + /> + ) : null} +
+ ); + + if (reorderable) { + return ( + + {row} + + ); + } + 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(null); + const [manageAlbums, setManageAlbums] = useState(false); + const [manageSelectedIds, setManageSelectedIds] = useState>(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(() => { 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 ( ); } diff --git a/src/components/TagCloud.tsx b/src/components/TagCloud.tsx index 97ae8b3..e31b88a 100644 --- a/src/components/TagCloud.tsx +++ b/src/components/TagCloud.tsx @@ -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; + onDelete: (tag: string) => Promise; +}) { + const [editing, setEditing] = useState(false); + const [value, setValue] = useState(entry.tag); + const [confirming, setConfirming] = useState(false); + const [busy, setBusy] = useState(false); + const inputRef = useRef(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 ( +
+
+ {editing ? ( + setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); void commitRename(); } + if (e.key === "Escape") setEditing(false); + }} + disabled={busy} + /> + ) : ( + + )} +
+ {entry.count.toLocaleString()} + + {editing ? ( +
+ + +
+ ) : confirming ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+ ); +} + +function TagManageList({ + entries, + onSearch, + onRename, + onDelete, +}: { + entries: ExploreTagEntry[]; + onSearch: (tag: string) => void; + onRename: (from: string, to: string) => Promise; + onDelete: (tag: string) => Promise; +}) { + return ( +
+

+ 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. +

+
+ {entries.map((entry) => ( + + ))} +
+
+ ); +} + 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() {

+ {exploreMode === "tags" && hasEntries ? ( + + ) : null}
- {/* Custom tooltip — fades in ~100ms instead of the native ~500ms delay. */} - - Click to update — v{updateVersion} - +
+ {/* Instant tooltip (delay 0) — this affordance should read immediately. */} + + +
) : (
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 56e021e..a6d9969 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -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 */}
- { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> + { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); setColorFilter(null); }} /> { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> + {showAlbumScope ? ( + setSimilarScope("current_album")} /> + ) : null} setSimilarScope("current_folder")} /> setSimilarScope("all_media")} /> {hasAnyFailedEmbeddings ? ( @@ -468,7 +481,8 @@ export function Toolbar() { onClick={() => setFailedTaggingOnly(!failedTaggingOnly)} /> ) : null} - {isSimilarResults ? Current similar scope: {similarScope === "current_folder" ? "current folder" : "all media"} : null} + + {isSimilarResults ? Current similar scope: {similarScope === "current_album" ? "this album" : similarScope === "current_folder" ? "current folder" : "all media"} : null}
); diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx new file mode 100644 index 0000000..c345c4a --- /dev/null +++ b/src/components/Tooltip.tsx @@ -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 | undefined>(undefined); + const frame = useRef(undefined); + const tooltipRef = useRef(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) => { + 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) => { + 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 ( + + {children} + {followCursor ? ( + + {label} + + ) : ( + + {label} + + )} + + ); +} diff --git a/src/components/WhatsNewModal.tsx b/src/components/WhatsNewModal.tsx index 5971ce9..c1015eb 100644 --- a/src/components/WhatsNewModal.tsx +++ b/src/components/WhatsNewModal.tsx @@ -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() {
- + + + + + + {suggestions.length > 0 ? ( +
+ {suggestions.map((suggestion) => ( + + ))} +
+ ) : null} + + {appliedTags.length > 0 ? ( +
+ {appliedTags.map((tag) => ( + + {tag} + + + ))} +
+ ) : null} +
+ ); +} diff --git a/src/components/bulk/BulkTagPopover.tsx b/src/components/bulk/BulkTagPopover.tsx new file mode 100644 index 0000000..fe345b0 --- /dev/null +++ b/src/components/bulk/BulkTagPopover.tsx @@ -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 ( +
event.stopPropagation()} + > +
+

Add tags

+ +
+ +
+ ); +} diff --git a/src/components/bulk/useBulkTagEditor.ts b/src/components/bulk/useBulkTagEditor.ts new file mode 100644 index 0000000..676ad13 --- /dev/null +++ b/src/components/bulk/useBulkTagEditor.ts @@ -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([]); + const [appliedTags, setAppliedTags] = useState([]); + const [pending, setPending] = useState(false); + const debounceRef = useRef | 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("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 }; +} diff --git a/src/store.ts b/src/store.ts index 24436de..2270547 100644 --- a/src/store.ts +++ b/src/store.ts @@ -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>; 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; + + // Albums (Feature B) + albums: Album[]; + albumsLoaded: boolean; + selectedAlbumId: number | null; + loadFolders: () => Promise; loadBackgroundJobProgress: () => Promise; addFolder: (path: string) => Promise; @@ -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; showVisualCluster: (imageIds: number[]) => Promise; searchForTag: (tag: string) => void; - loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null) => Promise; - loadSimilarByRegion: (imageId: number, crop: { x: number; y: number; w: number; h: number }, folderId?: number | null, sourceFolderId?: number | null) => Promise; + loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null, albumId?: number | null) => Promise; + loadSimilarByRegion: (imageId: number, crop: { x: number; y: number; w: number; h: number }, folderId?: number | null, sourceFolderId?: number | null, albumId?: number | null) => Promise; + // Entry points that decide scope (album when launched from an album, else folder/all per similarScope). + findSimilar: (imageId: number, sourceFolderId: number | null) => Promise; + findSimilarByRegion: (imageId: number, crop: { x: number; y: number; w: number; h: number }, sourceFolderId: number | null) => Promise; setSimilarScope: (scope: SimilarScope) => void; suggestImageTags: (imageId: number) => Promise; loadCaptionModelStatus: () => Promise; @@ -531,6 +571,30 @@ interface GalleryState { getImageTags: (imageId: number) => Promise; addUserTag: (imageId: number, tag: string) => Promise; removeTag: (tagId: number) => Promise; + getImageExif: (imageId: number) => Promise; + renameTag: (from: string, to: string) => Promise; + deleteTag: (tag: string) => Promise; + + // Gallery multi-select (Feature A) + toggleGallerySelected: (imageId: number) => void; + selectAllGallery: () => void; + clearGallerySelection: () => void; + bulkSetFavorite: (favorite: boolean) => Promise; + bulkSetRating: (rating: number) => Promise; + bulkAddTags: (tags: string[]) => Promise; + bulkRemoveTag: (tag: string) => Promise; + bulkDeleteSelected: () => Promise; + + // Albums (Feature B) + loadAlbums: () => Promise; + createAlbum: (name: string) => Promise; + renameAlbum: (albumId: number, name: string) => Promise; + deleteAlbum: (albumId: number) => Promise; + deleteAlbums: (albumIds: number[]) => Promise; + reorderAlbums: (albumIds: number[]) => Promise; + addToAlbum: (albumId: number, imageIds: number[]) => Promise; + removeFromAlbum: (albumId: number, imageIds: number[]) => Promise; + viewAlbum: (albumId: number) => void; } const PAGE_SIZE = 200; @@ -773,11 +837,14 @@ export const useGalleryStore = create((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((set, get) => ({ workerPaused: {}, appVersion: null, + buildVariant: null, updateStatus: "idle", updateVersion: null, updateProgress: null, @@ -849,6 +917,12 @@ export const useGalleryStore = create((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((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((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() } : {}) }); 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("semantic_search_images", { params: { @@ -1078,6 +1181,7 @@ export const useGalleryStore = create((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((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((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((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((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((set, get) => ({ return; } } - set({ activeView }); + set({ activeView, similarSourceAlbumId: null, similarScope: similarScopeReset }); }, setExploreMode: (exploreMode) => set({ exploreMode }), @@ -1305,6 +1439,8 @@ export const useGalleryStore = create((set, get) => ({ similarSourceFolderId: null, similarHasMore: false, similarFolderId: null, + gallerySelectedIds: new Set(), + selectedAlbumId: null, galleryScrollResetKey: state.galleryScrollResetKey + 1, })); @@ -1339,10 +1475,12 @@ export const useGalleryStore = create((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((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() : state.gallerySelectedIds, + selectedAlbumId: null, galleryScrollResetKey: reset ? state.galleryScrollResetKey + 1 : state.galleryScrollResetKey, })); @@ -1360,7 +1503,8 @@ export const useGalleryStore = create((set, get) => ({ const result = await invoke("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((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((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((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(), + selectedAlbumId: null, galleryScrollResetKey: state.galleryScrollResetKey + 1, selectedImage: null, })); @@ -1433,7 +1583,8 @@ export const useGalleryStore = create((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((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((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((set, get) => ({ } catch { // leave null; the UI falls back to a dash } + try { + const variant = await invoke("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((set, get) => ({ set({ exploreTagsFolderId: undefined }); }, + getImageExif: async (imageId) => { + return invoke("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("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("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("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("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("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( + get().images.filter((image) => get().gallerySelectedIds.has(image.id)).map((image) => image.folder_id), + ); + const succeededIds = await invoke("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("list_albums"); + set({ albums, albumsLoaded: true }); + }, + + createAlbum: async (name) => { + const album = await invoke("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("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(), + 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("load_duplicate_scan_cache", { folderId: folderId ?? null }); @@ -2219,7 +2679,13 @@ export const useGalleryStore = create((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((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((set, get) => ({ const batch = event.payload; set((state) => { + 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; + + // 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") { - 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; - return { selectedImage }; + return { selectedImage: selectedImageUpdate }; } const visibleImages = batch.images.filter((image) => @@ -2407,18 +2891,13 @@ export const useGalleryStore = create((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((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((set, get) => ({ unlistenWatcherDeleted(); unlistenFolderCounts(); unlistenFfmpegProgress(); + unlistenColorBackfill(); }; }, }));