diff --git a/CHANGELOG.md b/CHANGELOG.md index 0641712..9ed16e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### Added +- **Albums** — curate your own collections. A new Albums section in the sidebar + (with cover thumbnails, kept visually distinct from Libraries) lets you create, + rename, 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. - **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 +32,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/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f6c0da1..ca05086 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}; @@ -1647,6 +1649,17 @@ 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() + } +} + // ── 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, +} + +#[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 // --------------------------------------------------------------------------- diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 05d8d04..f6282f0 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,30 @@ 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); ", )?; @@ -1586,6 +1622,256 @@ 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(()) +} + pub fn repair_deferred_embedding_jobs(conn: &Connection) -> Result { let pattern = "No thumbnail available yet for%"; let repaired = conn.execute( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dec5ccc..eceb248 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -185,6 +185,19 @@ pub fn run() { commands::get_image_tags, commands::add_user_tag, commands::remove_tag, + 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, 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..2f32637 --- /dev/null +++ b/src/components/BulkActionBar.tsx @@ -0,0 +1,269 @@ +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 [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) return; + const album = await createAlbum(name); + await addToAlbum(album.id, ids); + setNewAlbumName(""); + setPanel(null); + }; + + const btn = "rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors"; + const btnIdle = `${btn} text-gray-300 hover:bg-white/10 hover:text-white`; + const btnActive = `${btn} bg-white/10 text-white`; + + 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)} + /> + +
+
+ ) : 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/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} +
+ {albumMenuOpen ? ( +
+
+ {albums.length === 0 ? ( +

No albums yet — create one below.

+ ) : ( + albums.map((album) => ( + + )) + )} +
+
{ + e.preventDefault(); + const name = newAlbumName.trim(); + if (!name) return; + void createAlbum(name).then((album) => { + void addToAlbum(album.id, [selectedImage.id]); + setAlbumAddedTo(album.id); + }); + setNewAlbumName(""); + }} + > + setNewAlbumName(e.target.value)} + /> + +
+
+ ) : 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..d57a8fd 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,207 @@ 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, +}: { + album: Album; + manageMode?: boolean; + selectedForManage?: boolean; + onToggleManage?: () => void; +}) { + 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; + + return ( +
{ + if (manageMode) { + onToggleManage?.(); + } else if (!renaming) { + 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} + + {/* 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} +
+ ); +} + export function Sidebar() { const folders = useGalleryStore((state) => state.folders); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); @@ -348,6 +550,15 @@ 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 [creatingAlbum, setCreatingAlbum] = 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 [librarySort, setLibrarySortState] = useState(() => { const saved = window.localStorage.getItem(LIBRARY_SORT_KEY); return saved === "za" || saved === "custom" ? saved : "az"; @@ -463,6 +674,47 @@ 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; + } + const album = await createAlbum(name); + setNewAlbumName(""); + setCreatingAlbum(false); + useGalleryStore.getState().viewAlbum(album.id); + }; + + 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/bulk/BulkTagFields.tsx b/src/components/bulk/BulkTagFields.tsx new file mode 100644 index 0000000..4e7a9dd --- /dev/null +++ b/src/components/bulk/BulkTagFields.tsx @@ -0,0 +1,73 @@ +import { useBulkTagEditor } from "./useBulkTagEditor"; + +// Presentational tag-editing fields shared by the popover and modal surfaces. +export function BulkTagFields({ autoFocus = false }: { autoFocus?: boolean }) { + const { selectedCount, input, setInput, suggestions, appliedTags, pending, addTag, removeTag } = + useBulkTagEditor(); + + return ( +
+
{ + event.preventDefault(); + void addTag(input); + }} + > + {/* eslint-disable-next-line jsx-a11y/no-autofocus */} + setInput(event.target.value)} + disabled={pending} + /> + +
+ + {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..7ce327e --- /dev/null +++ b/src/components/bulk/useBulkTagEditor.ts @@ -0,0 +1,67 @@ +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); + + useEffect(() => { + const query = input.trim(); + if (!query) { + setSuggestions([]); + return; + } + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(async () => { + try { + const results = await invoke("search_tags_autocomplete", { + params: { query, folder_id: selectedFolderId ?? null, limit: 8 }, + }); + setSuggestions(results); + } catch { + setSuggestions([]); + } + }, 120); + return () => { + 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..ccaa6dd 100644 --- a/src/store.ts +++ b/src/store.ts @@ -176,7 +176,18 @@ 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 TagCloudEntry { count: number; @@ -374,6 +385,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 +423,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; @@ -531,6 +551,27 @@ interface GalleryState { getImageTags: (imageId: number) => Promise; addUserTag: (imageId: number, tag: string) => Promise; removeTag: (tagId: number) => 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; @@ -815,6 +856,7 @@ export const useGalleryStore = create((set, get) => ({ workerPaused: {}, appVersion: null, + buildVariant: null, updateStatus: "idle", updateVersion: null, updateProgress: null, @@ -849,6 +891,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 +1005,7 @@ 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 }); + set({ selectedFolderId: folderId, selectedAlbumId: null, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, failedTaggingOnly: false, imageLoadError: null }); void get().loadImages(true); }, @@ -993,9 +1041,35 @@ export const useGalleryStore = create((set, get) => ({ const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly, failedTaggingOnly, 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: { @@ -1107,6 +1181,27 @@ 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; + } if (collectionTitle === "Similar Images" && similarSourceImageId !== null) { if (!similarHasMore) return; await get().loadSimilarImages(similarSourceImageId, similarFolderId, false, get().similarSourceFolderId ?? null); @@ -1305,6 +1400,8 @@ export const useGalleryStore = create((set, get) => ({ similarSourceFolderId: null, similarHasMore: false, similarFolderId: null, + gallerySelectedIds: new Set(), + selectedAlbumId: null, galleryScrollResetKey: state.galleryScrollResetKey + 1, })); @@ -1353,6 +1450,11 @@ export const useGalleryStore = create((set, get) => ({ similarSourceFolderId: sourceFolderId, similarFolderId: folderId ?? null, 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, })); @@ -1421,6 +1523,11 @@ export const useGalleryStore = create((set, get) => ({ similarFolderId: folderId ?? null, 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, })); @@ -1781,6 +1888,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 +2210,237 @@ export const useGalleryStore = create((set, get) => ({ set({ exploreTagsFolderId: undefined }); }, + // ── 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, + 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 +2563,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 +2700,14 @@ 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" + ) { return state; } @@ -2386,12 +2743,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 +2774,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, }; }); });