Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cb00f3ef0 |
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
import { imgUrl } from '../api'
|
import { imgUrl } from '../api'
|
||||||
|
|
||||||
// True justified layout: pack photos greedily into rows at their real aspect
|
// True justified layout: pack photos greedily into rows at their real aspect
|
||||||
@@ -32,15 +32,19 @@ function layoutRows(photos, containerWidth, targetHeight, gap) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Gallery({ photos, onOpen, overlay, selected, onToggleSelect }) {
|
export default function Gallery({ photos, onOpen, overlay, selected, onToggleSelect }) {
|
||||||
const containerRef = useRef(null)
|
|
||||||
const [width, setWidth] = useState(0)
|
const [width, setWidth] = useState(0)
|
||||||
|
const observerRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
// Callback ref instead of mount effect: the container div unmounts whenever
|
||||||
const el = containerRef.current
|
// the photo list is empty (e.g. a filter with no matches), so the observer
|
||||||
|
// must re-attach to each new element — a once-per-mount effect would leave
|
||||||
|
// the re-rendered gallery unobserved at width 0, rendering nothing.
|
||||||
|
const containerRef = useCallback((el) => {
|
||||||
|
observerRef.current?.disconnect()
|
||||||
|
observerRef.current = null
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const observer = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width))
|
observerRef.current = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width))
|
||||||
observer.observe(el)
|
observerRef.current.observe(el)
|
||||||
return () => observer.disconnect()
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (photos.length === 0) return null
|
if (photos.length === 0) return null
|
||||||
@@ -69,7 +73,7 @@ export default function Gallery({ photos, onOpen, overlay, selected, onToggleSel
|
|||||||
title={isSelected ? 'Deselect' : 'Select'}
|
title={isSelected ? 'Deselect' : 'Select'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onToggleSelect(p.id)
|
onToggleSelect(p.id, e.shiftKey)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
✓
|
✓
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export default function SelectionBar({
|
|||||||
onClear,
|
onClear,
|
||||||
onDownload,
|
onDownload,
|
||||||
onDownloadAll,
|
onDownloadAll,
|
||||||
|
onDelete,
|
||||||
}) {
|
}) {
|
||||||
if (total === 0) return null
|
if (total === 0) return null
|
||||||
|
|
||||||
@@ -47,6 +48,11 @@ export default function SelectionBar({
|
|||||||
<button className="btn btn-primary" onClick={onDownload}>
|
<button className="btn btn-primary" onClick={onDownload}>
|
||||||
Download {count} as ZIP ({fmtBytes(selectedBytes)})
|
Download {count} as ZIP ({fmtBytes(selectedBytes)})
|
||||||
</button>
|
</button>
|
||||||
|
{onDelete && (
|
||||||
|
<button className="btn btn-danger" onClick={onDelete}>
|
||||||
|
Delete {count}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,6 +219,22 @@ function SharesPanel({ albumId }) {
|
|||||||
setTimeout(() => setCopied(null), 1500)
|
setTimeout(() => setCopied(null), 1500)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const update = async (shareId, patch) => {
|
||||||
|
try {
|
||||||
|
await api(`/api/shares/${shareId}`, { method: 'PATCH', body: patch })
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO timestamp -> local yyyy-mm-dd for the date input.
|
||||||
|
const localDate = (iso) => {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<h2>Client links</h2>
|
<h2>Client links</h2>
|
||||||
@@ -233,16 +249,34 @@ function SharesPanel({ albumId }) {
|
|||||||
<span className="muted">
|
<span className="muted">
|
||||||
{s.has_password ? '🔒 password' : 'no password'}
|
{s.has_password ? '🔒 password' : 'no password'}
|
||||||
{' · '}
|
{' · '}
|
||||||
{s.allow_download ? 'downloads on' : 'downloads off'}
|
|
||||||
{s.expires_at
|
|
||||||
? ` · expires ${new Date(s.expires_at).toLocaleDateString()}`
|
|
||||||
: ' · never expires'}
|
|
||||||
{' · '}
|
|
||||||
{s.rating_count} ratings, {s.tag_count} tags, 👍 {s.accept_count} 👎{' '}
|
{s.rating_count} ratings, {s.tag_count} tags, 👍 {s.accept_count} 👎{' '}
|
||||||
{s.reject_count}
|
{s.reject_count}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
|
<label className="select-toggle" title="Allow this link to download originals/ZIPs">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={s.allow_download}
|
||||||
|
onChange={(e) => update(s.id, { allow_download: e.target.checked })}
|
||||||
|
/>
|
||||||
|
downloads
|
||||||
|
</label>
|
||||||
|
<label className="row field-label" title="Expiry date — empty means the link never expires">
|
||||||
|
<span className="muted">expires</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={s.expires_at ? localDate(s.expires_at) : ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
update(s.id, {
|
||||||
|
// End of the chosen day in local time, like the create form.
|
||||||
|
expires_at: e.target.value
|
||||||
|
? new Date(`${e.target.value}T23:59:59`).toISOString()
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
{s.locked && (
|
{s.locked && (
|
||||||
<button
|
<button
|
||||||
className="btn"
|
className="btn"
|
||||||
@@ -377,6 +411,14 @@ export default function AlbumPage() {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removeSelected = async () => {
|
||||||
|
if (!confirm(`Delete ${selected.size} selected photo${selected.size === 1 ? '' : 's'}? This cannot be undone.`))
|
||||||
|
return
|
||||||
|
await api('/api/photos/delete', { method: 'POST', body: { ids: [...selected] } })
|
||||||
|
clear()
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
@@ -462,6 +504,7 @@ export default function AlbumPage() {
|
|||||||
onClear={clear}
|
onClear={clear}
|
||||||
onDownload={() => postDownload(`/api/albums/${id}/zip`, [...selected].join(','))}
|
onDownload={() => postDownload(`/api/albums/${id}/zip`, [...selected].join(','))}
|
||||||
onDownloadAll={() => postDownload(`/api/albums/${id}/zip`)}
|
onDownloadAll={() => postDownload(`/api/albums/${id}/zip`)}
|
||||||
|
onDelete={removeSelected}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{lightbox.index >= 0 && (
|
{lightbox.index >= 0 && (
|
||||||
|
|||||||
@@ -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)
|
// Multi-select over a photo list. Selection lives here (not in the gallery)
|
||||||
// so it survives lightbox open/close, and is pruned automatically when
|
// so it survives lightbox open/close, and is pruned automatically when
|
||||||
// photos disappear from the list (deletes, polling refreshes).
|
// photos disappear from the list (deletes, polling refreshes).
|
||||||
export default function useSelection(photos) {
|
export default function useSelection(photos) {
|
||||||
const [selected, setSelected] = useState(() => new Set())
|
const [selected, setSelected] = useState(() => new Set())
|
||||||
|
// Anchor for shift-click range selection: the photo last toggled.
|
||||||
|
const lastToggled = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
@@ -15,11 +17,23 @@ export default function useSelection(photos) {
|
|||||||
})
|
})
|
||||||
}, [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) => {
|
setSelected((prev) => {
|
||||||
const next = new Set(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)
|
if (next.has(photoId)) next.delete(photoId)
|
||||||
else next.add(photoId)
|
else next.add(photoId)
|
||||||
|
lastToggled.current = photoId
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -78,9 +78,13 @@ pub fn router(state: &AppState) -> Router<AppState> {
|
|||||||
)
|
)
|
||||||
.route("/api/albums/{id}/zip", post(zip::album_zip))
|
.route("/api/albums/{id}/zip", post(zip::album_zip))
|
||||||
.route("/api/albums/{id}/photos/by-hash/{sha256}", get(photos::by_hash))
|
.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}", delete(photos::delete))
|
||||||
.route("/api/photos/{id}/reprocess", post(photos::reprocess))
|
.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("/api/shares/{id}/reset-lock", post(shares::reset_lock))
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
|
|||||||
@@ -211,6 +211,35 @@ pub async fn delete(
|
|||||||
Ok(Json(serde_json::json!({ "ok": true })))
|
Ok(Json(serde_json::json!({ "ok": true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct DeleteManyBody {
|
||||||
|
ids: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<AppState>,
|
||||||
|
Json(body): Json<DeleteManyBody>,
|
||||||
|
) -> ApiResult<Json<serde_json::Value>> {
|
||||||
|
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(
|
pub async fn reprocess(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(photo_id): Path<Uuid>,
|
Path(photo_id): Path<Uuid>,
|
||||||
|
|||||||
@@ -138,6 +138,49 @@ pub async fn create(
|
|||||||
Ok(Json(share_json(&state, &row)))
|
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<Option<Option<DateTime<Utc>>>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
serde::Deserialize::deserialize(de).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateShare {
|
||||||
|
allow_download: Option<bool>,
|
||||||
|
#[serde(default, deserialize_with = "double_option")]
|
||||||
|
expires_at: Option<Option<DateTime<Utc>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(share_id): Path<Uuid>,
|
||||||
|
Json(body): Json<UpdateShare>,
|
||||||
|
) -> ApiResult<Json<serde_json::Value>> {
|
||||||
|
let updated = sqlx::query(
|
||||||
|
"update shares set
|
||||||
|
allow_download = coalesce($2, allow_download),
|
||||||
|
expires_at = case when $3 then $4 else expires_at end
|
||||||
|
where id = $1",
|
||||||
|
)
|
||||||
|
.bind(share_id)
|
||||||
|
.bind(body.allow_download)
|
||||||
|
.bind(body.expires_at.is_some())
|
||||||
|
.bind(body.expires_at.flatten())
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
if updated.rows_affected() == 0 {
|
||||||
|
return Err(ApiError::not_found());
|
||||||
|
}
|
||||||
|
let row: ShareAdminRow = sqlx::query_as(&format!("{SHARE_SELECT} where s.id = $1"))
|
||||||
|
.bind(share_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(share_json(&state, &row)))
|
||||||
|
}
|
||||||
|
|
||||||
/// Clear a share's password-lockout state (e.g. after a client fat-fingered
|
/// Clear a share's password-lockout state (e.g. after a client fat-fingered
|
||||||
/// their way into the 15-minute lock).
|
/// their way into the 15-minute lock).
|
||||||
pub async fn reset_lock(
|
pub async fn reset_lock(
|
||||||
|
|||||||
Reference in New Issue
Block a user