@@ -462,6 +504,7 @@ export default function AlbumPage() {
onClear={clear}
onDownload={() => postDownload(`/api/albums/${id}/zip`, [...selected].join(','))}
onDownloadAll={() => postDownload(`/api/albums/${id}/zip`)}
+ onDelete={removeSelected}
/>
{lightbox.index >= 0 && (
diff --git a/frontend/src/useSelection.js b/frontend/src/useSelection.js
index 8210bac..0d5de4d 100644
--- a/frontend/src/useSelection.js
+++ b/frontend/src/useSelection.js
@@ -1,10 +1,12 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
// Multi-select over a photo list. Selection lives here (not in the gallery)
// so it survives lightbox open/close, and is pruned automatically when
// photos disappear from the list (deletes, polling refreshes).
export default function useSelection(photos) {
const [selected, setSelected] = useState(() => new Set())
+ // Anchor for shift-click range selection: the photo last toggled.
+ const lastToggled = useRef(null)
useEffect(() => {
setSelected((prev) => {
@@ -15,11 +17,23 @@ export default function useSelection(photos) {
})
}, [photos])
- const toggle = (photoId) =>
+ // 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) =>
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
})
diff --git a/src/routes/mod.rs b/src/routes/mod.rs
index 4c64249..294e0a4 100644
--- a/src/routes/mod.rs
+++ b/src/routes/mod.rs
@@ -78,9 +78,13 @@ pub fn router(state: &AppState) -> Router
{
)
.route("/api/albums/{id}/zip", post(zip::album_zip))
.route("/api/albums/{id}/photos/by-hash/{sha256}", get(photos::by_hash))
+ .route("/api/photos/delete", post(photos::delete_many))
.route("/api/photos/{id}", delete(photos::delete))
.route("/api/photos/{id}/reprocess", post(photos::reprocess))
- .route("/api/shares/{id}", delete(shares::delete))
+ .route(
+ "/api/shares/{id}",
+ delete(shares::delete).patch(shares::update),
+ )
.route("/api/shares/{id}/reset-lock", post(shares::reset_lock))
.route_layer(middleware::from_fn_with_state(
state.clone(),
diff --git a/src/routes/photos.rs b/src/routes/photos.rs
index 50d6e2e..0b59ce3 100644
--- a/src/routes/photos.rs
+++ b/src/routes/photos.rs
@@ -211,6 +211,35 @@ pub async fn delete(
Ok(Json(serde_json::json!({ "ok": true })))
}
+#[derive(Deserialize)]
+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,
+) -> ApiResult> {
+ if body.ids.is_empty() {
+ 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?;
+ tx.commit().await?;
+ Ok(Json(serde_json::json!({ "ok": true, "deleted": jobs.rows_affected() })))
+}
+
pub async fn reprocess(
State(state): State,
Path(photo_id): Path,
diff --git a/src/routes/shares.rs b/src/routes/shares.rs
index c798062..9a56366 100644
--- a/src/routes/shares.rs
+++ b/src/routes/shares.rs
@@ -138,6 +138,49 @@ pub async fn create(
Ok(Json(share_json(&state, &row)))
}
+/// Distinguishes an absent JSON field (keep current value) from an explicit
+/// null (clear the expiry): absent โ None, present โ Some(inner).
+fn double_option<'de, D>(de: D) -> Result