feat: manual albums + gallery multi-select with bulk actions

Albums (manual collections):
- New albums/album_images tables with FK cascades; DB functions and Tauri
  commands for create/rename/delete/delete-many/reorder/list, add/remove
  images, and paginated get_album_images.
- Distinct sidebar "ALBUMS" section with cover thumbnails, create/rename/
  delete, and a Manage multi-select mode for bulk album deletion.
- Album view reuses the gallery grid (activeView "album" + selectedAlbumId);
  spans folders; add from the bulk bar or the lightbox, remove from within.

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

Also:
- Duplicate Finder delete now requires confirmation with clear "from disk"
  wording (was single-click fire-and-forget).
- CPU/CUDA build-variant badge in Settings (get_build_variant).
- Rating/favorite no longer re-sorts derived collections (similar/region/
  semantic/tag/album results); single and bulk paths replace in place there.
- Album-aware indexed-images/media-updated handlers so thumbnails paint and
  newly-indexed files don't leak into an album view.
- CHANGELOG updated.
This commit is contained in:
2026-06-27 15:23:54 +01:00
parent 55cd3b5aa7
commit 6bef90b7fb
15 changed files with 1901 additions and 44 deletions
+192 -1
View File
@@ -2,7 +2,9 @@ use crate::captioner::{
self, CaptionAcceleration, CaptionDetail, CaptionModelStatus, CaptionRuntimeProbe,
CaptionVisionProbe,
};
use crate::db::{self, DbPool, ExploreTagEntry, Folder, FolderJobProgress, ImageRecord, ImageTag};
use crate::db::{
self, Album, DbPool, ExploreTagEntry, Folder, FolderJobProgress, ImageRecord, ImageTag,
};
use crate::embedder;
use crate::hnsw_index;
use crate::indexer::{self, WatcherHandle};
@@ -1647,6 +1649,17 @@ pub async fn get_images_by_ids(
db::get_images_by_ids(&conn, &params.image_ids).map_err(|e| e.to_string())
}
/// Which acceleration variant this binary was compiled with. Used to badge the
/// version in Settings so it's clear whether the CPU or CUDA build is running.
#[tauri::command]
pub fn get_build_variant() -> String {
if cfg!(feature = "candle-cuda") {
"cuda".to_string()
} else {
"cpu".to_string()
}
}
// ── k-means with cosine similarity (all vectors assumed to be unit-normalized) ──
fn dot(a: &[f32], b: &[f32]) -> f32 {
@@ -2061,6 +2074,184 @@ pub async fn remove_tag(db: State<'_, DbState>, params: RemoveTagParams) -> Resu
db::remove_tag(&conn, params.tag_id).map_err(|e| e.to_string())
}
// ---------------------------------------------------------------------------
// Albums
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct CreateAlbumParams {
pub name: String,
}
#[derive(Deserialize)]
pub struct RenameAlbumParams {
pub album_id: i64,
pub new_name: String,
}
#[derive(Deserialize)]
pub struct DeleteAlbumParams {
pub album_id: i64,
}
#[derive(Deserialize)]
pub struct ReorderAlbumsParams {
pub album_ids: Vec<i64>,
}
#[derive(Deserialize)]
pub struct DeleteAlbumsParams {
pub album_ids: Vec<i64>,
}
#[derive(Deserialize)]
pub struct AlbumImagesParams {
pub album_id: i64,
pub image_ids: Vec<i64>,
}
#[derive(Deserialize)]
pub struct GetAlbumImagesParams {
pub album_id: i64,
pub sort: Option<String>,
pub offset: Option<i64>,
pub limit: Option<i64>,
}
#[tauri::command]
pub async fn list_albums(db: State<'_, DbState>) -> Result<Vec<Album>, String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::list_albums(&conn).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn create_album(db: State<'_, DbState>, params: CreateAlbumParams) -> Result<Album, String> {
let conn = db.get().map_err(|e| e.to_string())?;
let name = params.name.trim();
if name.is_empty() {
return Err("Album name cannot be empty".to_string());
}
db::create_album(&conn, name).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn rename_album(db: State<'_, DbState>, params: RenameAlbumParams) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
let name = params.new_name.trim();
if name.is_empty() {
return Err("Album name cannot be empty".to_string());
}
db::rename_album(&conn, params.album_id, name).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_album(db: State<'_, DbState>, params: DeleteAlbumParams) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::delete_album(&conn, params.album_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn reorder_albums(db: State<'_, DbState>, params: ReorderAlbumsParams) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::reorder_albums(&conn, &params.album_ids).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_albums(db: State<'_, DbState>, params: DeleteAlbumsParams) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::delete_albums(&conn, &params.album_ids).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn add_images_to_album(
db: State<'_, DbState>,
params: AlbumImagesParams,
) -> Result<i64, String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::add_images_to_album(&conn, params.album_id, &params.image_ids).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn remove_images_from_album(
db: State<'_, DbState>,
params: AlbumImagesParams,
) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::remove_images_from_album(&conn, params.album_id, &params.image_ids)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_album_images(
db: State<'_, DbState>,
params: GetAlbumImagesParams,
) -> Result<ImagesPage, String> {
let conn = db.get().map_err(|e| e.to_string())?;
let sort = params.sort.as_deref().unwrap_or("position");
let offset = params.offset.unwrap_or(0);
let limit = params.limit.unwrap_or(100);
let total = db::count_album_images(&conn, params.album_id).map_err(|e| e.to_string())?;
let images = db::get_album_images(&conn, params.album_id, sort, offset, limit)
.map_err(|e| e.to_string())?;
Ok(ImagesPage {
images,
total,
offset,
limit,
})
}
// ---------------------------------------------------------------------------
// Bulk image operations
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct BulkUpdateDetailsParams {
pub image_ids: Vec<i64>,
pub favorite: Option<bool>,
pub rating: Option<i64>,
}
#[derive(Deserialize)]
pub struct BulkAddTagsParams {
pub image_ids: Vec<i64>,
pub tags: Vec<String>,
}
#[derive(Deserialize)]
pub struct BulkRemoveTagParams {
pub image_ids: Vec<i64>,
pub tag: String,
}
#[tauri::command]
pub async fn bulk_update_details(
db: State<'_, DbState>,
params: BulkUpdateDetailsParams,
) -> Result<Vec<ImageRecord>, String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::bulk_update_details(&conn, &params.image_ids, params.favorite, params.rating)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn bulk_add_tags(
db: State<'_, DbState>,
params: BulkAddTagsParams,
) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::bulk_add_tags(&conn, &params.image_ids, &params.tags).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn bulk_remove_tag(
db: State<'_, DbState>,
params: BulkRemoveTagParams,
) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::bulk_remove_tag_by_name(&conn, &params.image_ids, &params.tag).map_err(|e| e.to_string())
}
// ---------------------------------------------------------------------------
// Queue scope / folder-id persistence
// ---------------------------------------------------------------------------