diff --git a/frontend/src/useSelection.js b/frontend/src/useSelection.js
index 0d5de4d..e82fd09 100644
--- a/frontend/src/useSelection.js
+++ b/frontend/src/useSelection.js
@@ -18,30 +18,42 @@ export default function useSelection(photos) {
}, [photos])
// Shift-toggle selects the whole range from the previously toggled photo
- // (both directions), so contiguous runs don't need per-photo clicks.
- const toggle = (photoId, shift = false) =>
+ // (both directions), so contiguous runs don't need per-photo clicks. The
+ // range walks `list` — the photo order the user is actually looking at —
+ // which callers with a filtered view must pass explicitly, so hidden
+ // photos are never swept into the selection. Anchor updates and range
+ // computation stay out of the setSelected updater: React may re-invoke
+ // updaters (StrictMode does), so they must be pure.
+ const toggle = (photoId, shift = false, list = photos) => {
+ const anchor = lastToggled.current
+ lastToggled.current = photoId
+ if (shift && anchor) {
+ const a = list.findIndex((p) => p.id === anchor)
+ const b = list.findIndex((p) => p.id === photoId)
+ if (a >= 0 && b >= 0) {
+ const range = list.slice(Math.min(a, b), Math.max(a, b) + 1).map((p) => p.id)
+ setSelected((prev) => new Set([...prev, ...range]))
+ return
+ }
+ }
setSelected((prev) => {
const next = new Set(prev)
- if (shift && lastToggled.current) {
- const a = photos.findIndex((p) => p.id === lastToggled.current)
- const b = photos.findIndex((p) => p.id === photoId)
- if (a >= 0 && b >= 0) {
- for (const p of photos.slice(Math.min(a, b), Math.max(a, b) + 1)) next.add(p.id)
- lastToggled.current = photoId
- return next
- }
- }
if (next.has(photoId)) next.delete(photoId)
else next.add(photoId)
- lastToggled.current = photoId
return next
})
+ }
// Adds `list` (the caller's currently visible photos) to the selection —
// additive, so selecting all of one filtered view keeps picks from another.
const selectAll = (list) =>
setSelected((prev) => new Set([...prev, ...list.map((p) => p.id)]))
- const clear = () => setSelected(new Set())
+ const clear = () => {
+ // Reset the anchor too — a shift-click after Clear must start fresh, not
+ // extend a range from a photo selected before the wipe.
+ lastToggled.current = null
+ setSelected(new Set())
+ }
const selectedBytes = photos.reduce((sum, p) => sum + (selected.has(p.id) ? p.size_bytes : 0), 0)
const totalBytes = photos.reduce((sum, p) => sum + p.size_bytes, 0)
diff --git a/src/routes/albums.rs b/src/routes/albums.rs
index a0c24e4..0261226 100644
--- a/src/routes/albums.rs
+++ b/src/routes/albums.rs
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
-use crate::models::{Album, JobKind, Photo, PhotoStatus};
+use crate::models::{Album, Photo, PhotoStatus};
use crate::state::AppState;
#[derive(Serialize, sqlx::FromRow)]
@@ -232,17 +232,13 @@ pub async fn delete(
if locked.is_none() {
return Err(ApiError::not_found());
}
- // Delete photos and enqueue their S3 cleanup atomically, in one statement.
- sqlx::query(
- "with deleted as (delete from photos where album_id = $1 returning id)
- insert into jobs (kind, payload)
- select $2, jsonb_build_object('prefix', 'photos/' || id || '/')
- from deleted",
- )
- .bind(album_id)
- .bind(JobKind::DeleteS3Prefix.as_str())
- .execute(&mut *tx)
- .await?;
+ // Delete photos through the shared path so the S3 cleanup convention
+ // lives in one place; the album lock above keeps this set complete.
+ let photo_ids: Vec
= sqlx::query_scalar("select id from photos where album_id = $1")
+ .bind(album_id)
+ .fetch_all(&mut *tx)
+ .await?;
+ super::photos::delete_with_cleanup(&mut tx, &photo_ids).await?;
sqlx::query("delete from albums where id = $1")
.bind(album_id)
.execute(&mut *tx)
diff --git a/src/routes/photos.rs b/src/routes/photos.rs
index 0b59ce3..d056874 100644
--- a/src/routes/photos.rs
+++ b/src/routes/photos.rs
@@ -189,24 +189,42 @@ pub async fn by_hash(
photo.map(Json).ok_or_else(ApiError::not_found)
}
+/// The one deletion path: delete the given photos and enqueue their S3
+/// cleanup jobs in the same transaction. Prefixes come from s3::photo_prefix
+/// so the key layout has a single source of truth. Returns how many photos
+/// were actually deleted.
+pub(super) async fn delete_with_cleanup(
+ tx: &mut sqlx::PgConnection,
+ ids: &[Uuid],
+) -> Result {
+ let deleted: Vec = sqlx::query_scalar("delete from photos where id = any($1) returning id")
+ .bind(ids)
+ .fetch_all(&mut *tx)
+ .await?;
+ if deleted.is_empty() {
+ return Ok(0);
+ }
+ let prefixes: Vec = deleted.iter().map(|id| s3::photo_prefix(*id)).collect();
+ sqlx::query(
+ "insert into jobs (kind, payload)
+ select $1, jsonb_build_object('prefix', p)
+ from unnest($2::text[]) as p",
+ )
+ .bind(JobKind::DeleteS3Prefix.as_str())
+ .bind(&prefixes)
+ .execute(&mut *tx)
+ .await?;
+ Ok(deleted.len() as u64)
+}
+
pub async fn delete(
State(state): State,
Path(photo_id): Path,
) -> ApiResult> {
let mut tx = state.db.begin().await?;
- let deleted = sqlx::query("delete from photos where id = $1")
- .bind(photo_id)
- .execute(&mut *tx)
- .await?;
- if deleted.rows_affected() == 0 {
+ if delete_with_cleanup(&mut tx, &[photo_id]).await? == 0 {
return Err(ApiError::not_found());
}
- jobs::enqueue(
- &mut *tx,
- JobKind::DeleteS3Prefix,
- serde_json::json!({ "prefix": s3::photo_prefix(photo_id) }),
- )
- .await?;
tx.commit().await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
@@ -216,8 +234,6 @@ pub struct DeleteManyBody {
ids: Vec,
}
-/// Bulk delete: photos vanish and their S3 cleanup jobs are enqueued in one
-/// statement, same as album deletion.
pub async fn delete_many(
State(state): State,
Json(body): Json,
@@ -226,18 +242,14 @@ pub async fn delete_many(
return Err(ApiError::bad_request("ids must not be empty"));
}
let mut tx = state.db.begin().await?;
- let jobs = sqlx::query(
- "with deleted as (delete from photos where id = any($1) returning id)
- insert into jobs (kind, payload)
- select $2, jsonb_build_object('prefix', 'photos/' || id || '/')
- from deleted",
- )
- .bind(&body.ids)
- .bind(JobKind::DeleteS3Prefix.as_str())
- .execute(&mut *tx)
- .await?;
+ let deleted = delete_with_cleanup(&mut tx, &body.ids).await?;
+ // Consistent with the single-photo route: deleting nothing is an error,
+ // not a silent success (e.g. a stale tab re-deleting already-gone photos).
+ if deleted == 0 {
+ return Err(ApiError::not_found());
+ }
tx.commit().await?;
- Ok(Json(serde_json::json!({ "ok": true, "deleted": jobs.rows_affected() })))
+ Ok(Json(serde_json::json!({ "ok": true, "deleted": deleted })))
}
pub async fn reprocess(