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
+23
View File
@@ -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.
+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
// ---------------------------------------------------------------------------
+286
View File
@@ -34,6 +34,18 @@ pub struct Folder {
pub sort_order: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Album {
pub id: i64,
pub name: String,
pub cover_image_id: Option<i64>,
pub cover_thumbnail_path: Option<String>,
pub image_count: i64,
pub sort_order: i64,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageRecord {
pub id: i64,
@@ -285,6 +297,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<Album> {
Ok(Album {
id: row.get(0)?,
name: row.get(1)?,
cover_image_id: row.get(2)?,
cover_thumbnail_path: row.get(3)?,
image_count: row.get(4)?,
sort_order: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
})
}
/// SELECT that resolves each album's cover thumbnail (explicit cover, else the
/// first member by position) and live image count. Shared by list/get-one.
const ALBUM_SELECT: &str = "
SELECT a.id, a.name, a.cover_image_id,
(SELECT ci.thumbnail_path FROM images ci
WHERE ci.id = COALESCE(
a.cover_image_id,
(SELECT ai.image_id FROM album_images ai
WHERE ai.album_id = a.id
ORDER BY ai.position, ai.added_at LIMIT 1)
)) AS cover_thumbnail_path,
(SELECT COUNT(*) FROM album_images ai WHERE ai.album_id = a.id) AS image_count,
a.sort_order, a.created_at, a.updated_at
FROM albums a";
pub fn list_albums(conn: &Connection) -> Result<Vec<Album>> {
let sql = format!("{ALBUM_SELECT} ORDER BY a.sort_order, a.id");
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([], map_album_row)?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
pub fn get_album(conn: &Connection, album_id: i64) -> Result<Album> {
let sql = format!("{ALBUM_SELECT} WHERE a.id = ?1");
conn.query_row(&sql, [album_id], map_album_row)
.map_err(Into::into)
}
pub fn create_album(conn: &Connection, name: &str) -> Result<Album> {
let id: i64 = conn.query_row(
"INSERT INTO albums (name, sort_order)
VALUES (?1, COALESCE((SELECT MAX(sort_order) + 1 FROM albums), 1))
RETURNING id",
params![name],
|row| row.get(0),
)?;
get_album(conn, id)
}
pub fn rename_album(conn: &Connection, album_id: i64, new_name: &str) -> Result<()> {
conn.execute(
"UPDATE albums SET name = ?2, updated_at = datetime('now') WHERE id = ?1",
params![album_id, new_name],
)?;
Ok(())
}
pub fn delete_album(conn: &Connection, album_id: i64) -> Result<()> {
// album_images rows cascade away via the FK.
conn.execute("DELETE FROM albums WHERE id = ?1", [album_id])?;
Ok(())
}
/// Delete many albums at once (membership rows cascade away via the FK).
pub fn delete_albums(conn: &Connection, album_ids: &[i64]) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for album_id in album_ids {
tx.execute("DELETE FROM albums WHERE id = ?1", [album_id])?;
}
tx.commit()?;
Ok(())
}
pub fn reorder_albums(conn: &Connection, album_ids: &[i64]) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for (index, album_id) in album_ids.iter().enumerate() {
tx.execute(
"UPDATE albums SET sort_order = ?2 WHERE id = ?1",
params![album_id, index as i64 + 1],
)?;
}
tx.commit()?;
Ok(())
}
/// Append images to an album (idempotent). Returns the number newly added.
pub fn add_images_to_album(conn: &Connection, album_id: i64, image_ids: &[i64]) -> Result<i64> {
let tx = conn.unchecked_transaction()?;
let mut next_position: i64 = tx.query_row(
"SELECT COALESCE(MAX(position) + 1, 0) FROM album_images WHERE album_id = ?1",
[album_id],
|row| row.get(0),
)?;
let mut added = 0i64;
for image_id in image_ids {
let changed = tx.execute(
"INSERT INTO album_images (album_id, image_id, position)
VALUES (?1, ?2, ?3)
ON CONFLICT(album_id, image_id) DO NOTHING",
params![album_id, image_id, next_position],
)?;
if changed > 0 {
next_position += 1;
added += 1;
}
}
tx.execute(
"UPDATE albums SET updated_at = datetime('now') WHERE id = ?1",
[album_id],
)?;
tx.commit()?;
Ok(added)
}
pub fn remove_images_from_album(
conn: &Connection,
album_id: i64,
image_ids: &[i64],
) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for image_id in image_ids {
tx.execute(
"DELETE FROM album_images WHERE album_id = ?1 AND image_id = ?2",
params![album_id, image_id],
)?;
}
tx.execute(
"UPDATE albums SET updated_at = datetime('now') WHERE id = ?1",
[album_id],
)?;
tx.commit()?;
Ok(())
}
pub fn count_album_images(conn: &Connection, album_id: i64) -> Result<i64> {
let count = conn.query_row(
"SELECT COUNT(*) FROM album_images WHERE album_id = ?1",
[album_id],
|row| row.get(0),
)?;
Ok(count)
}
pub fn get_album_images(
conn: &Connection,
album_id: i64,
sort: &str,
offset: i64,
limit: i64,
) -> Result<Vec<ImageRecord>> {
// Default to curated order (album_images.position); otherwise honor the same
// sort vocabulary as get_images. Albums span folders, so no folder filter.
let order = match sort {
"name_asc" => "i.filename ASC",
"name_desc" => "i.filename DESC",
"date_asc" => "i.modified_at ASC NULLS LAST",
"date_desc" => "i.modified_at DESC NULLS LAST",
"size_asc" => "i.file_size ASC",
"size_desc" => "i.file_size DESC",
"rating_asc" => "i.rating ASC, i.modified_at DESC NULLS LAST",
"rating_desc" => "i.rating DESC, i.modified_at DESC NULLS LAST",
"duration_asc" => "i.duration_ms ASC NULLS LAST",
"duration_desc" => "i.duration_ms DESC NULLS LAST",
"taken_asc" => "COALESCE(i.taken_at, i.modified_at) ASC NULLS LAST",
"taken_desc" => "COALESCE(i.taken_at, i.modified_at) DESC NULLS LAST",
_ => "ai.position ASC",
};
let sql = format!(
"SELECT i.id, i.folder_id, i.path, i.filename, i.thumbnail_path, i.width, i.height, i.file_size, i.created_at, i.modified_at, i.taken_at, i.mime_type,
i.media_kind, i.duration_ms, i.video_codec, i.audio_codec, i.metadata_updated_at, i.metadata_error,
i.favorite, i.rating, i.embedding_status, i.embedding_model, i.embedding_updated_at, i.embedding_error,
i.generated_caption, i.caption_model, i.caption_updated_at, i.caption_error,
i.ai_rating, i.ai_tagger_model, i.ai_tagged_at, i.ai_tagger_error
FROM images i
JOIN album_images ai ON ai.image_id = i.id
WHERE ai.album_id = ?1
ORDER BY {order}
LIMIT ?2 OFFSET ?3"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params![album_id, limit, offset], map_image_row)?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
// ── Bulk image operations ──────────────────────────────────────────────────────
/// Apply favorite and/or rating to many images at once; returns the updated rows.
pub fn bulk_update_details(
conn: &Connection,
image_ids: &[i64],
favorite: Option<bool>,
rating: Option<i64>,
) -> Result<Vec<ImageRecord>> {
let tx = conn.unchecked_transaction()?;
for image_id in image_ids {
tx.execute(
"UPDATE images
SET favorite = COALESCE(?2, favorite),
rating = COALESCE(?3, rating)
WHERE id = ?1",
params![image_id, favorite, rating],
)?;
}
tx.commit()?;
get_images_by_ids(conn, image_ids)
}
/// Add one or more user tags to many images at once.
pub fn bulk_add_tags(conn: &Connection, image_ids: &[i64], tags: &[String]) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for image_id in image_ids {
for tag in tags {
let trimmed = tag.trim();
if trimmed.is_empty() {
continue;
}
tx.execute(
"INSERT INTO image_tags (image_id, tag, source, ai_model, confidence, created_at)
VALUES (?1, ?2, 'user', NULL, NULL, datetime('now'))
ON CONFLICT(image_id, tag) DO UPDATE SET
source = 'user',
ai_model = NULL,
confidence = NULL
WHERE source = 'ai'",
params![image_id, trimmed],
)?;
}
}
tx.commit()?;
Ok(())
}
/// Remove a tag (by name) from many images at once.
pub fn bulk_remove_tag_by_name(conn: &Connection, image_ids: &[i64], tag: &str) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for image_id in image_ids {
tx.execute(
"DELETE FROM image_tags WHERE image_id = ?1 AND tag = ?2",
params![image_id, tag],
)?;
}
tx.commit()?;
Ok(())
}
pub fn repair_deferred_embedding_jobs(conn: &Connection) -> Result<usize> {
let pattern = "No thumbnail available yet for%";
let repaired = conn.execute(
+13
View File
@@ -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,
+2
View File
@@ -24,6 +24,7 @@ export default function App() {
const loadImages = useGalleryStore((state) => state.loadImages);
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus);
const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache);
const loadAlbums = useGalleryStore((state) => state.loadAlbums);
const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds);
const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused);
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
@@ -51,6 +52,7 @@ export default function App() {
void loadBackgroundJobProgress();
void loadCaptionModelStatus();
void loadDuplicateScanCache();
void loadAlbums();
return loadImages(true);
});
let unlisten: (() => void) | undefined;
+269
View File
@@ -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<Panel>(null);
const [deleting, setDeleting] = useState(false);
const [newAlbumName, setNewAlbumName] = useState("");
const barRef = useRef<HTMLDivElement>(null);
// Close any open popover when clicking outside the bar.
useEffect(() => {
const onPointerDown = (event: PointerEvent) => {
if (barRef.current?.contains(event.target as Node)) return;
setPanel(null);
};
window.addEventListener("pointerdown", onPointerDown);
return () => window.removeEventListener("pointerdown", onPointerDown);
}, []);
// Reset transient UI whenever the selection empties.
useEffect(() => {
if (selectedCount === 0) {
setPanel(null);
setNewAlbumName("");
}
}, [selectedCount]);
if (selectedCount === 0) return null;
const ids = Array.from(selectedIds);
const inAlbumView = activeView === "album" && selectedAlbumId !== null;
const togglePanel = (next: Exclude<Panel, null>) => setPanel((current) => (current === next ? null : next));
const handleDelete = async () => {
setDeleting(true);
try {
await bulkDeleteSelected();
} finally {
setDeleting(false);
setPanel(null);
}
};
const handlePickAlbum = async (albumId: number) => {
await addToAlbum(albumId, ids);
setPanel(null);
};
const handleCreateAlbum = async () => {
const name = newAlbumName.trim();
if (!name) 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 (
<div
ref={barRef}
className="pointer-events-auto absolute bottom-6 left-1/2 z-30 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-white/10 bg-gray-950/95 px-2 py-1.5 shadow-2xl shadow-black/50 backdrop-blur"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center gap-2 px-1.5">
<span className="text-xs font-medium text-white">{selectedCount} selected</span>
{loadedCount < totalImages || loadedCount > selectedCount ? (
<button
className="text-[11px] text-gray-500 transition-colors hover:text-gray-300"
onClick={selectAllGallery}
title={loadedCount < totalImages ? "Selects loaded items only" : "Select all loaded"}
>
Select all{loadedCount < totalImages ? " loaded" : ""}
</button>
) : null}
</div>
<div className="h-5 w-px bg-white/10" />
<div className="relative">
<button className={panel === "tag" ? btnActive : btnIdle} onClick={() => togglePanel("tag")}>
Tag
</button>
{panel === "tag" ? <BulkTagPopover onClose={() => setPanel(null)} /> : null}
</div>
<div className="relative">
<button className={panel === "rating" ? btnActive : btnIdle} onClick={() => togglePanel("rating")}>
Rating
</button>
{panel === "rating" ? (
<div
data-bulk-popover
className="absolute bottom-full left-1/2 mb-2 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-white/10 bg-gray-950/98 p-2 shadow-2xl backdrop-blur"
>
{Array.from({ length: 5 }, (_, index) => {
const rating = index + 1;
return (
<button
key={rating}
className="rounded-md p-1 text-white/25 transition-colors hover:bg-white/5 hover:text-amber-300"
onClick={async () => {
await bulkSetRating(rating);
setPanel(null);
}}
title={`Set ${rating} star${rating === 1 ? "" : "s"}`}
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</button>
);
})}
<button
className="ml-1 rounded-md border border-white/10 px-2 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/5 hover:text-white"
onClick={async () => {
await bulkSetRating(0);
setPanel(null);
}}
>
Clear
</button>
</div>
) : null}
</div>
<button className={btnIdle} onClick={() => void bulkSetFavorite(true)} title="Mark as favorite">
Favorite
</button>
<div className="relative">
<button className={panel === "album" ? btnActive : btnIdle} onClick={() => togglePanel("album")}>
Add to album
</button>
{panel === "album" ? (
<div
data-bulk-popover
className="absolute bottom-full left-1/2 mb-2 w-60 -translate-x-1/2 rounded-xl border border-white/10 bg-gray-950/98 p-2 shadow-2xl backdrop-blur"
>
<div className="max-h-48 overflow-y-auto">
{albums.length === 0 ? (
<p className="px-2 py-2 text-[11px] text-gray-600">No albums yet create one below.</p>
) : (
albums.map((album) => (
<button
key={album.id}
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs text-gray-300 transition-colors hover:bg-white/5 hover:text-white"
onClick={() => void handlePickAlbum(album.id)}
>
<span className="truncate">{album.name}</span>
<span className="shrink-0 text-[10px] text-gray-600">{album.image_count}</span>
</button>
))
)}
</div>
<form
className="mt-1 flex gap-1 border-t border-white/[0.06] pt-2"
onSubmit={(event) => {
event.preventDefault();
void handleCreateAlbum();
}}
>
<input
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white placeholder-gray-600 focus:border-white/20 focus:outline-none"
placeholder="New album…"
value={newAlbumName}
onChange={(event) => setNewAlbumName(event.target.value)}
/>
<button
type="submit"
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50"
disabled={!newAlbumName.trim()}
>
Add
</button>
</form>
</div>
) : null}
</div>
{inAlbumView ? (
<button
className={`${btn} text-amber-300/90 hover:bg-amber-500/10 hover:text-amber-200`}
onClick={() => void removeFromAlbum(selectedAlbumId, ids)}
>
Remove from album
</button>
) : null}
<div className="h-5 w-px bg-white/10" />
<div className="relative">
<button
className={panel === "delete" ? `${btn} bg-red-500/15 text-red-300` : `${btn} text-gray-300 hover:bg-red-500/10 hover:text-red-300`}
onClick={() => togglePanel("delete")}
disabled={deleting}
title="Delete files from disk"
>
{deleting ? "Deleting…" : "Delete"}
</button>
{panel === "delete" ? (
<div
data-bulk-popover
className="absolute bottom-full left-1/2 mb-2 w-64 -translate-x-1/2 rounded-xl border border-red-500/30 bg-gray-950/98 p-3 shadow-2xl backdrop-blur"
>
<div className="mb-1 flex items-center gap-1.5 text-red-300">
<svg className="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<p className="text-xs font-semibold">Delete from disk</p>
</div>
<p className="mb-2.5 text-[11px] leading-relaxed text-gray-400">
Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer.
This removes the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone.
</p>
<div className="flex justify-end gap-1.5">
<button
className="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setPanel(null)}
>
Cancel
</button>
<button
className="rounded-md bg-red-500/20 px-2.5 py-1 text-[11px] font-medium text-red-300 transition-colors hover:bg-red-500/30 hover:text-red-200 disabled:opacity-50"
onClick={() => void handleDelete()}
disabled={deleting}
>
{deleting ? "Deleting…" : `Delete ${selectedCount} from disk`}
</button>
</div>
</div>
) : null}
</div>
<button
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={clearGallerySelection}
title="Clear selection"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
}
+44 -7
View File
@@ -129,6 +129,7 @@ export function DuplicateFinder() {
const deleteSelectedDuplicates = useGalleryStore((state) => state.deleteSelectedDuplicates);
const [deleting, setDeleting] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [deleteResult, setDeleteResult] = useState<string | null>(null);
// Virtualize the group list so a large result set (e.g. thousands of pairs)
@@ -153,6 +154,7 @@ export function DuplicateFinder() {
const handleDelete = async () => {
setDeleting(true);
setConfirmingDelete(false);
setDeleteResult(null);
try {
const deleted = await deleteSelectedDuplicates();
@@ -222,13 +224,48 @@ export function DuplicateFinder() {
>
Deselect all
</button>
<button
className="rounded-lg border border-red-400/25 bg-red-500/10 px-3 py-1.5 text-xs text-red-300 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-40"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? "Deleting…" : `Delete ${selectedCount} file${selectedCount === 1 ? "" : "s"}`}
</button>
<div className="relative">
<button
className="rounded-lg border border-red-400/25 bg-red-500/10 px-3 py-1.5 text-xs text-red-300 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => setConfirmingDelete((v) => !v)}
disabled={deleting}
>
{deleting ? "Deleting…" : `Delete ${selectedCount} file${selectedCount === 1 ? "" : "s"}`}
</button>
{confirmingDelete && !deleting ? (
<>
{/* Click-away backdrop */}
<div className="fixed inset-0 z-40" onClick={() => setConfirmingDelete(false)} />
<div className="absolute right-0 top-full z-50 mt-2 w-72 rounded-xl border border-red-500/30 bg-gray-950/98 p-3 text-left shadow-2xl backdrop-blur">
<div className="mb-1 flex items-center gap-1.5 text-red-300">
<svg className="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<p className="text-xs font-semibold">Delete from disk</p>
</div>
<p className="mb-2.5 text-[11px] leading-relaxed text-gray-400">
Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer.
This removes the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone.
</p>
<div className="flex justify-end gap-1.5">
<button
className="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setConfirmingDelete(false)}
>
Cancel
</button>
<button
className="rounded-md bg-red-500/20 px-2.5 py-1 text-[11px] font-medium text-red-300 transition-colors hover:bg-red-500/30 hover:text-red-200"
onClick={handleDelete}
>
Delete {selectedCount} from disk
</button>
</div>
</div>
</>
) : null}
</div>
</>
) : null}
<button
+60 -18
View File
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useCallback, useMemo, useState } fr
import { useVirtualizer } from "@tanstack/react-virtual";
import { convertFileSrc } from "@tauri-apps/api/core";
import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store";
import { BulkActionBar } from "./BulkActionBar";
const GAP = 6;
@@ -118,18 +119,57 @@ export function ImageTile({
const [errored, setErrored] = useState(false);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
const similarScope = useGalleryStore((state) => state.similarScope);
const selected = useGalleryStore((state) => state.gallerySelectedIds.has(image.id));
const selectionActive = useGalleryStore((state) => state.gallerySelectedIds.size > 0);
const toggleGallerySelected = useGalleryStore((state) => state.toggleGallerySelected);
const canFindSimilar = image.embedding_status === "ready";
const src = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) : null;
return (
<button
className="media-dark-surface group relative overflow-hidden rounded-xl bg-white/[0.04] text-left focus:outline-none"
className={`media-dark-surface group relative overflow-hidden rounded-xl bg-white/[0.04] text-left focus:outline-none transition-shadow ${
selected ? "ring-2 ring-inset ring-blue-400/80" : ""
}`}
style={{ width: "100%", aspectRatio: "1 / 1" }}
onClick={onClick}
onClick={() => {
// In selection mode a plain click toggles; otherwise it opens.
if (selectionActive) toggleGallerySelected(image.id);
else onClick();
}}
onDoubleClick={(event) => {
// Double-click always opens, even in selection mode.
event.stopPropagation();
onClick();
}}
onContextMenu={onContextMenu}
title={image.filename}
>
{/* Selection corner a top-left zone that reveals the checkbox only when
hovered (not the whole tile) and toggles selection on click. The
checkbox stays visible once the item is selected. */}
<div
role="checkbox"
aria-checked={selected}
aria-label={selected ? "Deselect" : "Select"}
className="group/cb absolute top-0 left-0 z-20 h-11 w-11 cursor-pointer"
onClick={(event) => {
event.stopPropagation();
toggleGallerySelected(image.id);
}}
>
<div
className={`absolute top-2 left-2 flex h-5 w-5 items-center justify-center rounded-full border transition-all duration-150 ${
selected
? "border-blue-400 bg-blue-500 text-white opacity-100"
: "border-white/70 bg-black/40 text-transparent opacity-0 backdrop-blur-sm group-hover/cb:opacity-100"
}`}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
{/* Image / placeholder */}
{src && !errored ? (
<>
@@ -173,6 +213,17 @@ export function ImageTile({
{/* Persistent badges — only shown when meaningful */}
<div className="absolute top-2 right-2 flex flex-col items-end gap-1 pointer-events-none">
{image.embedding_status === "failed" && (
<div
className="flex items-center gap-1 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 backdrop-blur-sm"
title={image.embedding_error ?? "Embedding failed"}
>
<svg className="h-2.5 w-2.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
</div>
)}
{image.favorite && (
<div className="rounded-full bg-black/50 p-1 text-rose-400 backdrop-blur-sm">
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 20 20">
@@ -196,21 +247,6 @@ export function ImageTile({
)}
</div>
{/* Embedding failed badge — top-left */}
{image.embedding_status === "failed" && (
<div
className="absolute top-2 left-2 pointer-events-none"
title={image.embedding_error ?? "Embedding failed"}
>
<div className="flex items-center gap-1 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 backdrop-blur-sm">
<svg className="h-2.5 w-2.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
</div>
</div>
)}
{/* Hover overlay — slides up from bottom */}
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" />
@@ -338,7 +374,8 @@ export function Gallery() {
}, []);
return (
<div ref={parentRef} className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-gray-950">
<div className="relative flex-1 min-h-0">
<div ref={parentRef} className="absolute inset-0 overflow-y-auto overflow-x-hidden bg-gray-950">
{images.length === 0 && loadingImages ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 min-w-72">
@@ -448,5 +485,10 @@ export function Gallery() {
/>
) : null}
</div>
{/* Pinned to the bottom of the gallery viewport outside the scroll
container so it stays put while the grid scrolls. */}
<BulkActionBar />
</div>
);
}
+72
View File
@@ -153,6 +153,9 @@ export function Lightbox() {
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);
// Tracks the image id that is currently displayed, used to discard async
// tag mutations that resolve after the user has navigated to another image.
@@ -170,6 +173,9 @@ export function Lightbox() {
const [taggingQueued, setTaggingQueued] = useState(false);
// Region selection state
const [albumMenuOpen, setAlbumMenuOpen] = useState(false);
const [albumAddedTo, setAlbumAddedTo] = useState<number | null>(null);
const [newAlbumName, setNewAlbumName] = useState("");
const [regionSelectMode, setRegionSelectMode] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [dragRect, setDragRect] = useState<DragRect | null>(null);
@@ -780,6 +786,72 @@ export function Lightbox() {
</form>
</div>
<div className="relative">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs uppercase tracking-wider text-gray-500">Albums</p>
<button
className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => { setAlbumMenuOpen((v) => !v); setAlbumAddedTo(null); }}
>
Add to album
</button>
</div>
{albumMenuOpen ? (
<div className="rounded-lg border border-white/10 bg-white/[0.03] p-1.5">
<div className="max-h-40 overflow-y-auto">
{albums.length === 0 ? (
<p className="px-2 py-1.5 text-[11px] text-gray-600">No albums yet create one below.</p>
) : (
albums.map((album) => (
<button
key={album.id}
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1 text-left text-xs text-gray-300 transition-colors hover:bg-white/5 hover:text-white"
onClick={() => {
void addToAlbum(album.id, [selectedImage.id]);
setAlbumAddedTo(album.id);
}}
>
<span className="truncate">{album.name}</span>
{albumAddedTo === album.id ? (
<span className="shrink-0 text-[10px] text-emerald-400">Added</span>
) : (
<span className="shrink-0 text-[10px] text-gray-600">{album.image_count}</span>
)}
</button>
))
)}
</div>
<form
className="mt-1 flex gap-1 border-t border-white/[0.06] pt-1.5"
onSubmit={(e) => {
e.preventDefault();
const name = newAlbumName.trim();
if (!name) return;
void createAlbum(name).then((album) => {
void addToAlbum(album.id, [selectedImage.id]);
setAlbumAddedTo(album.id);
});
setNewAlbumName("");
}}
>
<input
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white placeholder-gray-600 focus:border-white/20 focus:outline-none"
placeholder="New album…"
value={newAlbumName}
onChange={(e) => setNewAlbumName(e.target.value)}
/>
<button
type="submit"
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50"
disabled={!newAlbumName.trim()}
>
Add
</button>
</form>
</div>
) : null}
</div>
<div>
<div className="mb-1 flex items-center justify-between">
<p className="text-xs uppercase tracking-wider text-gray-500">Path</p>
+6
View File
@@ -191,6 +191,7 @@ export function SettingsModal() {
const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo);
const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails);
const appVersion = useGalleryStore((state) => state.appVersion);
const buildVariant = useGalleryStore((state) => state.buildVariant);
const updateStatus = useGalleryStore((state) => state.updateStatus);
const updateVersion = useGalleryStore((state) => state.updateVersion);
const updateProgress = useGalleryStore((state) => state.updateProgress);
@@ -662,6 +663,11 @@ export function SettingsModal() {
label={
<span className="inline-flex items-center gap-2.5">
<span>Phokus {appVersion ? `v${appVersion}` : "—"}</span>
{buildVariant ? (
<StatusPill tone={buildVariant === "cuda" ? "ready" : "muted"}>
{buildVariant === "cuda" ? "CUDA" : "CPU"}
</StatusPill>
) : null}
{updateStatus === "available" || updateStatus === "downloading" || updateStatus === "installing" ? (
<StatusPill tone="busy">v{updateVersion} available</StatusPill>
) : updateStatus === "upToDate" ? (
+387 -1
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Reorder, useDragControls } from "framer-motion";
import { open } from "@tauri-apps/plugin-dialog";
import { useGalleryStore, Folder, IndexProgress } from "../store";
import { convertFileSrc } from "@tauri-apps/api/core";
import { useGalleryStore, Folder, Album, IndexProgress } from "../store";
import { ThemedDropdown } from "./ThemedDropdown";
interface ContextMenuState {
@@ -339,6 +340,207 @@ function FolderItem({
);
}
function AlbumContextMenu({
x,
y,
onClose,
onRename,
onDelete,
}: {
x: number;
y: number;
onClose: () => void;
onRename: () => void;
onDelete: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", handleDown);
document.addEventListener("keydown", handleKey);
return () => {
document.removeEventListener("mousedown", handleDown);
document.removeEventListener("keydown", handleKey);
};
}, [onClose]);
const item = (label: string, onClick: () => void, danger = false) => (
<button
className={`w-full text-left px-3 py-1.5 text-[12px] rounded-md transition-colors ${
danger
? "text-red-400 hover:bg-red-500/15 hover:text-red-300"
: "text-gray-300 hover:bg-white/8 hover:text-white"
}`}
onClick={() => {
onClick();
onClose();
}}
>
{label}
</button>
);
return (
<div
ref={ref}
className="fixed z-50 min-w-[160px] py-1 px-1 rounded-lg bg-gray-900 border border-white/10 shadow-xl shadow-black/50"
style={{ left: x, top: y }}
>
{item("Rename", onRename)}
<div className="my-1 border-t border-white/[0.06]" />
{item("Delete album", onDelete, true)}
</div>
);
}
function AlbumItem({
album,
manageMode = false,
selectedForManage = false,
onToggleManage,
}: {
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<HTMLInputElement>(null);
useEffect(() => {
if (renaming) {
setRenameValue(album.name);
setTimeout(() => renameInputRef.current?.select(), 0);
}
}, [renaming, album.name]);
const commitRename = async () => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== album.name) {
await renameAlbum(album.id, trimmed);
}
setRenaming(false);
};
const cover = album.cover_thumbnail_path ? convertFileSrc(album.cover_thumbnail_path) : null;
return (
<div
className={`group relative flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-150 ${
selectedForManage
? "bg-blue-500/10 text-white ring-1 ring-inset ring-blue-400/50"
: selected
? "bg-white/8 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
onClick={() => {
if (manageMode) {
onToggleManage?.();
} else if (!renaming) {
viewAlbum(album.id);
}
}}
onContextMenu={(e) => {
if (manageMode) return;
e.preventDefault();
e.stopPropagation();
setMenu({ x: Math.min(e.clientX, window.innerWidth - 180), y: Math.min(e.clientY, window.innerHeight - 120) });
}}
>
{/* Manage-mode selection checkbox */}
{manageMode ? (
<div
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
selectedForManage ? "border-blue-400 bg-blue-500 text-white" : "border-white/30 text-transparent"
}`}
>
<svg className="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
) : null}
{/* Cover thumbnail — distinguishes albums from folder rows */}
<div className="h-7 w-7 shrink-0 overflow-hidden rounded-md bg-white/[0.05] ring-1 ring-white/10">
{cover ? (
<img src={cover} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center text-white/20">
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
</div>
<div className="min-w-0 flex-1">
{renaming ? (
<input
ref={renameInputRef}
className="w-full bg-white/10 text-white text-[13px] font-medium rounded px-1 py-0 outline-none ring-1 ring-blue-500/60 leading-tight"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); void commitRename(); }
if (e.key === "Escape") setRenaming(false);
}}
onBlur={() => void commitRename()}
onClick={(e) => e.stopPropagation()}
/>
) : (
<div className={`truncate text-[13px] font-medium leading-tight ${selected ? "text-white" : ""}`}>
{album.name}
</div>
)}
<div className="text-[11px] text-gray-600 mt-0.5">{album.image_count.toLocaleString()}</div>
</div>
{!renaming && confirmingRemoval ? (
<div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
<button
className="px-1.5 py-0.5 text-[10px] rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 hover:text-red-300 transition-colors"
onClick={() => { void deleteAlbum(album.id); setConfirmingRemoval(false); }}
>
Confirm
</button>
<button
className="px-1.5 py-0.5 text-[10px] rounded bg-white/5 text-gray-500 hover:bg-white/10 hover:text-gray-300 transition-colors"
onClick={() => setConfirmingRemoval(false)}
>
Cancel
</button>
</div>
) : null}
{menu ? (
<AlbumContextMenu
x={menu.x}
y={menu.y}
onClose={() => setMenu(null)}
onRename={() => setRenaming(true)}
onDelete={() => setConfirmingRemoval(true)}
/>
) : null}
</div>
);
}
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<HTMLInputElement>(null);
const [manageAlbums, setManageAlbums] = useState(false);
const [manageSelectedIds, setManageSelectedIds] = useState<Set<number>>(new Set());
const [confirmingAlbumDelete, setConfirmingAlbumDelete] = useState(false);
const [librarySort, setLibrarySortState] = useState<LibrarySort>(() => {
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 (
<aside className="w-60 shrink-0 flex flex-col bg-gray-950 border-r border-white/[0.06]">
{/* Header */}
@@ -601,6 +853,140 @@ export function Sidebar() {
))
)}
</Reorder.Group>
{/* Albums a visually distinct block, separated from Libraries by a
heavier divider and given cover-thumbnail rows so the two never
read as one list. */}
<div className="shrink-0 border-t-2 border-white/[0.08] bg-white/[0.015]">
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-1">
<span className="text-[10px] font-semibold uppercase tracking-[0.15em] text-gray-600">
{manageAlbums ? `${manageSelectedIds.size} selected` : "Albums"}
</span>
{manageAlbums ? (
<button
onClick={exitManageAlbums}
className="rounded px-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500 transition-colors hover:text-gray-200"
>
Done
</button>
) : (
<div className="flex items-center gap-0.5">
{albums.length > 0 ? (
<button
onClick={() => setManageAlbums(true)}
className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200"
title="Manage albums"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
) : null}
<button
onClick={startCreatingAlbum}
className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200"
title="New album"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
)}
</div>
{/* Manage action row */}
{manageAlbums ? (
<div className="px-3 pb-1.5">
{confirmingAlbumDelete ? (
<div className="rounded-lg border border-red-500/25 bg-red-500/[0.06] p-2">
<p className="mb-2 text-[11px] leading-relaxed text-gray-400">
Delete {manageSelectedIds.size} album{manageSelectedIds.size === 1 ? "" : "s"}? Your images stay in
the library only the album{manageSelectedIds.size === 1 ? "" : "s"} {manageSelectedIds.size === 1 ? "is" : "are"} removed.
</p>
<div className="flex justify-end gap-1.5">
<button
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-[11px] text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setConfirmingAlbumDelete(false)}
>
Cancel
</button>
<button
className="rounded-md bg-red-500/20 px-2 py-1 text-[11px] font-medium text-red-300 transition-colors hover:bg-red-500/30 hover:text-red-200"
onClick={() => void handleDeleteSelectedAlbums()}
>
Delete {manageSelectedIds.size}
</button>
</div>
</div>
) : (
<div className="flex items-center justify-between gap-2">
<button
className="text-[11px] text-gray-500 transition-colors hover:text-gray-300"
onClick={() =>
setManageSelectedIds((prev) =>
prev.size === albums.length ? new Set() : new Set(albums.map((a) => a.id)),
)
}
>
{manageSelectedIds.size === albums.length ? "Deselect all" : "Select all"}
</button>
<button
className="rounded-md border border-red-400/25 bg-red-500/10 px-2 py-1 text-[11px] text-red-300 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => setConfirmingAlbumDelete(true)}
disabled={manageSelectedIds.size === 0}
>
Delete {manageSelectedIds.size > 0 ? manageSelectedIds.size : ""}
</button>
</div>
)}
</div>
) : null}
<div className="max-h-52 overflow-y-auto px-2 pb-2 space-y-px">
{creatingAlbum ? (
<form
className="flex gap-1 px-1 py-1"
onSubmit={(e) => {
e.preventDefault();
void handleCreateAlbum();
}}
>
<input
ref={newAlbumInputRef}
className="min-w-0 flex-1 rounded border border-white/10 bg-white/10 px-1.5 py-1 text-[13px] text-white outline-none ring-1 ring-blue-500/40 placeholder-gray-600"
placeholder="Album name…"
value={newAlbumName}
onChange={(e) => setNewAlbumName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setCreatingAlbum(false);
setNewAlbumName("");
}
}}
onBlur={() => void handleCreateAlbum()}
/>
</form>
) : null}
{albums.length === 0 && !creatingAlbum ? (
<p className="px-3 py-3 text-center text-[11px] leading-relaxed text-gray-700">
Select images and Add to album to start curating
</p>
) : (
albums.map((album) => (
<AlbumItem
key={album.id}
album={album}
manageMode={manageAlbums}
selectedForManage={manageSelectedIds.has(album.id)}
onToggleManage={() => toggleManageSelected(album.id)}
/>
))
)}
</div>
</div>
</aside>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useBulkTagEditor } from "./useBulkTagEditor";
// Presentational tag-editing fields shared by the popover and modal surfaces.
export function BulkTagFields({ autoFocus = false }: { autoFocus?: boolean }) {
const { selectedCount, input, setInput, suggestions, appliedTags, pending, addTag, removeTag } =
useBulkTagEditor();
return (
<div className="space-y-2">
<form
className="flex gap-1.5"
onSubmit={(event) => {
event.preventDefault();
void addTag(input);
}}
>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input
autoFocus={autoFocus}
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white placeholder-gray-600 focus:border-white/20 focus:outline-none"
placeholder={`Add tag to ${selectedCount} item${selectedCount === 1 ? "" : "s"}`}
value={input}
onChange={(event) => setInput(event.target.value)}
disabled={pending}
/>
<button
type="submit"
className="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
disabled={pending || !input.trim()}
>
Add
</button>
</form>
{suggestions.length > 0 ? (
<div className="flex flex-wrap gap-1">
{suggestions.map((suggestion) => (
<button
key={suggestion.tag}
className="rounded-md border border-white/10 bg-white/[0.03] px-2 py-0.5 text-[11px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => void addTag(suggestion.tag)}
title={`${suggestion.count.toLocaleString()} tagged`}
>
{suggestion.tag}
</button>
))}
</div>
) : null}
{appliedTags.length > 0 ? (
<div className="flex flex-wrap gap-1 border-t border-white/[0.06] pt-2">
{appliedTags.map((tag) => (
<span
key={tag}
className="group/chip flex items-center gap-1 rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-gray-300"
>
{tag}
<button
className="text-gray-600 transition-colors hover:text-white"
title="Remove from selected"
onClick={() => void removeTag(tag)}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
))}
</div>
) : null}
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { BulkTagFields } from "./BulkTagFields";
// Inline popover surface for bulk tagging — the default editing surface.
// Anchored above the bar by the parent; closes on outside click via the
// data-bulk-popover guard handled in BulkActionBar.
export function BulkTagPopover({ onClose }: { onClose: () => void }) {
return (
<div
data-bulk-popover
className="absolute bottom-full left-1/2 mb-2 w-72 -translate-x-1/2 rounded-xl border border-white/10 bg-gray-950/98 p-3 shadow-2xl backdrop-blur"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-2 flex items-center justify-between">
<p className="text-[11px] uppercase tracking-wider text-gray-500">Add tags</p>
<button
className="text-gray-600 transition-colors hover:text-white"
onClick={onClose}
title="Close"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<BulkTagFields autoFocus />
</div>
);
}
+67
View File
@@ -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<ExploreTagEntry[]>([]);
const [appliedTags, setAppliedTags] = useState<string[]>([]);
const [pending, setPending] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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<ExploreTagEntry[]>("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 };
}
+379 -17
View File
@@ -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<number, Record<WorkerKey, boolean>>;
appVersion: string | null;
buildVariant: "cpu" | "cuda" | null;
updateStatus: UpdateStatus;
updateVersion: string | null;
updateProgress: number | null; // 0..1 download progress, null while size unknown
@@ -411,6 +423,14 @@ interface GalleryState {
duplicateLastScanned: number | null; // Unix timestamp (seconds)
duplicateScanFolderId: number | null | undefined; // undefined = never scanned
// Gallery multi-select (Feature A)
gallerySelectedIds: Set<number>;
// Albums (Feature B)
albums: Album[];
albumsLoaded: boolean;
selectedAlbumId: number | null;
loadFolders: () => Promise<void>;
loadBackgroundJobProgress: () => Promise<void>;
addFolder: (path: string) => Promise<void>;
@@ -531,6 +551,27 @@ interface GalleryState {
getImageTags: (imageId: number) => Promise<ImageTag[]>;
addUserTag: (imageId: number, tag: string) => Promise<ImageTag>;
removeTag: (tagId: number) => Promise<void>;
// Gallery multi-select (Feature A)
toggleGallerySelected: (imageId: number) => void;
selectAllGallery: () => void;
clearGallerySelection: () => void;
bulkSetFavorite: (favorite: boolean) => Promise<void>;
bulkSetRating: (rating: number) => Promise<void>;
bulkAddTags: (tags: string[]) => Promise<void>;
bulkRemoveTag: (tag: string) => Promise<void>;
bulkDeleteSelected: () => Promise<number>;
// Albums (Feature B)
loadAlbums: () => Promise<void>;
createAlbum: (name: string) => Promise<Album>;
renameAlbum: (albumId: number, name: string) => Promise<void>;
deleteAlbum: (albumId: number) => Promise<void>;
deleteAlbums: (albumIds: number[]) => Promise<void>;
reorderAlbums: (albumIds: number[]) => Promise<void>;
addToAlbum: (albumId: number, imageIds: number[]) => Promise<number>;
removeFromAlbum: (albumId: number, imageIds: number[]) => Promise<void>;
viewAlbum: (albumId: number) => void;
}
const PAGE_SIZE = 200;
@@ -815,6 +856,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
workerPaused: {},
appVersion: null,
buildVariant: null,
updateStatus: "idle",
updateVersion: null,
updateProgress: null,
@@ -849,6 +891,12 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
duplicateLastScanned: null,
duplicateScanFolderId: undefined,
gallerySelectedIds: new Set(),
albums: [],
albumsLoaded: false,
selectedAlbumId: null,
setCacheDir: (cacheDir) => set({ cacheDir }),
loadFolders: async () => {
@@ -957,7 +1005,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
},
selectFolder: (folderId) => {
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, failedTaggingOnly: false, imageLoadError: null });
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<GalleryState>((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<number>() } : {}) });
try {
// Album view loads from the album membership, honoring sort changes from
// the Toolbar while staying within the album (ignores folder/search/filters).
if (activeView === "album") {
const albumId = get().selectedAlbumId;
if (albumId === null) {
set({ loadingImages: false });
return;
}
const offset = reset ? 0 : loadedCount;
const result = await invoke<{ images: ImageRecord[]; total: number; offset: number; limit: number }>("get_album_images", {
params: { album_id: albumId, sort, offset, limit: PAGE_SIZE },
});
if (requestToken !== galleryRequestToken) return;
const albumName = get().albums.find((entry) => entry.id === albumId)?.name ?? "Album";
set((state) => ({
images: reset ? result.images : [...state.images, ...result.images],
totalImages: result.total,
loadedCount: reset ? result.images.length : state.loadedCount + result.images.length,
loadingImages: false,
collectionTitle: albumName,
}));
return;
}
if (parsedSearch.mode === "semantic" && parsedSearch.query) {
const images = await invoke<ImageRecord[]>("semantic_search_images", {
params: {
@@ -1107,6 +1181,27 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
const { loadedCount, totalImages, loadingImages, collectionTitle, similarSourceImageId, similarHasMore, similarFolderId, similarCrop } = get();
if (loadingImages || loadedCount >= totalImages) return;
if (collectionTitle === "Explore Cluster") return;
const { activeView, selectedAlbumId, sort } = get();
if (activeView === "album" && selectedAlbumId !== null) {
const requestToken = ++galleryRequestToken;
set({ loadingImages: true });
try {
const result = await invoke<{ images: ImageRecord[]; total: number; offset: number; limit: number }>("get_album_images", {
params: { album_id: selectedAlbumId, sort, offset: loadedCount, limit: PAGE_SIZE },
});
if (requestToken !== galleryRequestToken) return;
set((state) => ({
images: [...state.images, ...result.images],
loadedCount: state.loadedCount + result.images.length,
totalImages: result.total,
loadingImages: false,
}));
} catch {
if (requestToken !== galleryRequestToken) return;
set({ loadingImages: false });
}
return;
}
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<GalleryState>((set, get) => ({
similarSourceFolderId: null,
similarHasMore: false,
similarFolderId: null,
gallerySelectedIds: new Set<number>(),
selectedAlbumId: null,
galleryScrollResetKey: state.galleryScrollResetKey + 1,
}));
@@ -1353,6 +1450,11 @@ export const useGalleryStore = create<GalleryState>((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<number>() : state.gallerySelectedIds,
selectedAlbumId: null,
galleryScrollResetKey: reset ? state.galleryScrollResetKey + 1 : state.galleryScrollResetKey,
}));
@@ -1421,6 +1523,11 @@ export const useGalleryStore = create<GalleryState>((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<number>(),
selectedAlbumId: null,
galleryScrollResetKey: state.galleryScrollResetKey + 1,
selectedImage: null,
}));
@@ -1781,6 +1888,12 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
} catch {
// leave null; the UI falls back to a dash
}
try {
const variant = await invoke<string>("get_build_variant");
set({ buildVariant: variant === "cuda" ? "cuda" : "cpu" });
} catch {
// leave null; the badge is hidden until known
}
},
checkForUpdates: async (options) => {
@@ -2097,6 +2210,237 @@ export const useGalleryStore = create<GalleryState>((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<ImageRecord[]>("bulk_update_details", {
params: { image_ids: ids, favorite, rating: null },
});
set((state) => {
const match = state.selectedImage && updated.find((image) => image.id === state.selectedImage!.id);
// Derived collections keep their relevance order (replace in place); only
// the real sorted gallery re-sorts.
return {
images: isDerivedCollectionTitle(state.collectionTitle)
? replaceExistingImages(state.images, updated)
: mergeImages(state.images, updated, state.sort),
selectedImage: match ?? state.selectedImage,
};
});
},
bulkSetRating: async (rating) => {
const ids = Array.from(get().gallerySelectedIds);
if (ids.length === 0) return;
const updated = await invoke<ImageRecord[]>("bulk_update_details", {
params: { image_ids: ids, favorite: null, rating },
});
set((state) => {
const match = state.selectedImage && updated.find((image) => image.id === state.selectedImage!.id);
return {
images: isDerivedCollectionTitle(state.collectionTitle)
? replaceExistingImages(state.images, updated)
: mergeImages(state.images, updated, state.sort),
selectedImage: match ?? state.selectedImage,
};
});
},
bulkAddTags: async (tags) => {
const ids = Array.from(get().gallerySelectedIds);
const cleaned = tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0);
if (ids.length === 0 || cleaned.length === 0) return;
await invoke<void>("bulk_add_tags", { params: { image_ids: ids, tags: cleaned } });
// New tags landed — invalidate Explore tag caches.
set({ exploreTagsFolderId: undefined, tagCloudFolderId: undefined, tagCloudEntries: [] });
},
bulkRemoveTag: async (tag) => {
const ids = Array.from(get().gallerySelectedIds);
if (ids.length === 0 || !tag.trim()) return;
await invoke<void>("bulk_remove_tag", { params: { image_ids: ids, tag: tag.trim() } });
set({ exploreTagsFolderId: undefined, tagCloudFolderId: undefined, tagCloudEntries: [] });
},
bulkDeleteSelected: async () => {
const ids = Array.from(get().gallerySelectedIds);
if (ids.length === 0) return 0;
const affectedFolderIds = new Set<number>(
get().images.filter((image) => get().gallerySelectedIds.has(image.id)).map((image) => image.folder_id),
);
const succeededIds = await invoke<number[]>("delete_images_from_disk", { params: { image_ids: ids } });
const succeededSet = new Set(succeededIds);
set((state) => ({
// Only remove images confirmed deleted — failed files remain selected for retry.
images: state.images.filter((image) => !succeededSet.has(image.id)),
loadedCount: state.images.filter((image) => !succeededSet.has(image.id)).length,
totalImages: Math.max(0, state.totalImages - succeededIds.length),
gallerySelectedIds: new Set([...state.gallerySelectedIds].filter((id) => !succeededSet.has(id))),
// Deletion changes tag/duplicate/album aggregates.
tagCloudFolderId: undefined,
tagCloudEntries: [],
exploreTagsFolderId: undefined,
}));
// The DB cascade already removed these from album_images; refresh counts/covers.
void get().loadAlbums();
await invoke("invalidate_duplicate_scan_cache", { folderId: null });
for (const folderId of affectedFolderIds) {
await invoke("invalidate_duplicate_scan_cache", { folderId });
}
return succeededIds.length;
},
// ── Albums (Feature B) ────────────────────────────────────────────────────
loadAlbums: async () => {
const albums = await invoke<Album[]>("list_albums");
set({ albums, albumsLoaded: true });
},
createAlbum: async (name) => {
const album = await invoke<Album>("create_album", { params: { name } });
await get().loadAlbums();
return album;
},
renameAlbum: async (albumId, name) => {
await invoke("rename_album", { params: { album_id: albumId, new_name: name } });
await get().loadAlbums();
},
deleteAlbum: async (albumId) => {
await invoke("delete_album", { params: { album_id: albumId } });
// If the deleted album is being viewed, drop back to All Media.
if (get().activeView === "album" && get().selectedAlbumId === albumId) {
set({ activeView: "gallery", selectedAlbumId: null, collectionTitle: null });
void get().loadImages(true);
}
await get().loadAlbums();
},
deleteAlbums: async (albumIds) => {
if (albumIds.length === 0) return;
await invoke("delete_albums", { params: { album_ids: albumIds } });
// If a deleted album is being viewed, drop back to All Media.
if (get().activeView === "album" && get().selectedAlbumId !== null && albumIds.includes(get().selectedAlbumId!)) {
set({ activeView: "gallery", selectedAlbumId: null, collectionTitle: null });
void get().loadImages(true);
}
await get().loadAlbums();
},
reorderAlbums: async (albumIds) => {
const previous = get().albums;
const byId = new Map(previous.map((album) => [album.id, album]));
const albums = albumIds
.map((id, index) => {
const album = byId.get(id);
return album ? { ...album, sort_order: index + 1 } : null;
})
.filter((album): album is Album => album !== null);
set({ albums });
try {
await invoke("reorder_albums", { params: { album_ids: albumIds } });
} catch (error) {
set({ albums: previous });
throw error;
}
},
addToAlbum: async (albumId, imageIds) => {
if (imageIds.length === 0) return 0;
const added = await invoke<number>("add_images_to_album", {
params: { album_id: albumId, image_ids: imageIds },
});
await get().loadAlbums();
return added;
},
removeFromAlbum: async (albumId, imageIds) => {
if (imageIds.length === 0) return;
await invoke("remove_images_from_album", {
params: { album_id: albumId, image_ids: imageIds },
});
// If viewing this album, splice the removed images out immediately.
if (get().activeView === "album" && get().selectedAlbumId === albumId) {
const removed = new Set(imageIds);
set((state) => {
const nextImages = state.images.filter((image) => !removed.has(image.id));
// Decrement by what was actually on screen, not the requested count —
// some ids may live beyond the loaded page.
const removedFromView = state.images.length - nextImages.length;
return {
images: nextImages,
loadedCount: nextImages.length,
totalImages: Math.max(0, state.totalImages - removedFromView),
gallerySelectedIds: new Set([...state.gallerySelectedIds].filter((id) => !removed.has(id))),
};
});
}
await get().loadAlbums();
},
viewAlbum: (albumId) => {
const requestToken = ++galleryRequestToken;
const album = get().albums.find((entry) => entry.id === albumId);
const sort = get().sort;
set((state) => ({
activeView: "album",
selectedAlbumId: albumId,
search: "",
images: [],
totalImages: album?.image_count ?? 0,
loadedCount: 0,
loadingImages: true,
collectionTitle: album?.name ?? "Album",
imageLoadError: null,
similarSourceImageId: null,
similarSourceFolderId: null,
similarHasMore: false,
similarFolderId: null,
similarCrop: null,
gallerySelectedIds: new Set<number>(),
galleryScrollResetKey: state.galleryScrollResetKey + 1,
}));
void (async () => {
try {
const result = await invoke<{ images: ImageRecord[]; total: number; offset: number; limit: number }>("get_album_images", {
params: { album_id: albumId, sort, offset: 0, limit: PAGE_SIZE },
});
if (requestToken !== galleryRequestToken) return;
set({
images: result.images,
totalImages: result.total,
loadedCount: result.images.length,
loadingImages: false,
imageLoadError: null,
collectionTitle: album?.name ?? "Album",
});
} catch (error) {
if (requestToken !== galleryRequestToken) return;
set({ images: [], totalImages: 0, loadedCount: 0, loadingImages: false, imageLoadError: String(error) });
}
})();
},
loadDuplicateScanCache: async (folderId = null) => {
interface CacheResult { groups: DuplicateGroup[]; scanned_at: number }
const cached = await invoke<CacheResult | null>("load_duplicate_scan_cache", { folderId: folderId ?? null });
@@ -2219,7 +2563,13 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
});
set((state) => ({
images: replaceImage(state.images, updatedImage, state.sort),
// Derived collections (similar / region / semantic / tag / album results)
// are ordered by relevance, not `state.sort` — re-sorting them on a
// favorite/rating change would scramble the results. Replace in place
// there; only the real sorted gallery re-sorts.
images: isDerivedCollectionTitle(state.collectionTitle)
? replaceExistingImages(state.images, [updatedImage])
: replaceImage(state.images, updatedImage, state.sort),
selectedImage: state.selectedImage?.id === updatedImage.id ? updatedImage : state.selectedImage,
}));
},
@@ -2350,7 +2700,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
const batch = event.payload;
set((state) => {
if (isDerivedCollectionTitle(state.collectionTitle) || state.activeView === "explore") {
// Album view holds a fixed membership set; newly-indexed files never
// auto-join it. Guarding on activeView also covers the brief window
// where collectionTitle is null mid sort-change in an album.
if (
isDerivedCollectionTitle(state.collectionTitle) ||
state.activeView === "explore" ||
state.activeView === "album"
) {
return state;
}
@@ -2386,12 +2743,22 @@ export const useGalleryStore = create<GalleryState>((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<GalleryState>((set, get) => ({
),
);
const selectedImage =
state.selectedImage && batch.images.some((image) => image.id === state.selectedImage?.id)
? batch.images.find((image) => image.id === state.selectedImage?.id) ?? state.selectedImage
: state.selectedImage;
if (visibleImages.length === 0) {
return { selectedImage };
return { selectedImage: selectedImageUpdate };
}
return {
images: replaceExistingImages(state.images, visibleImages),
selectedImage,
selectedImage: selectedImageUpdate,
};
});
});