- shift-click selects ranges in the gallery; selection bar gains
'Delete N' with a single confirm (POST /api/photos/delete)
- PATCH /api/shares/{id}: allow_download and expiry editable in place,
token and client feedback preserved
- Gallery: re-attach ResizeObserver via callback ref — after a filter
with zero matches the gallery stayed blank at width 0
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { imgUrl } from '../api'
|
||||
|
||||
// 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 }) {
|
||||
const containerRef = useRef(null)
|
||||
const [width, setWidth] = useState(0)
|
||||
const observerRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
// Callback ref instead of mount effect: the container div unmounts whenever
|
||||
// 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
|
||||
const observer = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width))
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
observerRef.current = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width))
|
||||
observerRef.current.observe(el)
|
||||
}, [])
|
||||
|
||||
if (photos.length === 0) return null
|
||||
@@ -69,7 +73,7 @@ export default function Gallery({ photos, onOpen, overlay, selected, onToggleSel
|
||||
title={isSelected ? 'Deselect' : 'Select'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleSelect(p.id)
|
||||
onToggleSelect(p.id, e.shiftKey)
|
||||
}}
|
||||
>
|
||||
✓
|
||||
|
||||
@@ -15,6 +15,7 @@ export default function SelectionBar({
|
||||
onClear,
|
||||
onDownload,
|
||||
onDownloadAll,
|
||||
onDelete,
|
||||
}) {
|
||||
if (total === 0) return null
|
||||
|
||||
@@ -47,6 +48,11 @@ export default function SelectionBar({
|
||||
<button className="btn btn-primary" onClick={onDownload}>
|
||||
Download {count} as ZIP ({fmtBytes(selectedBytes)})
|
||||
</button>
|
||||
{onDelete && (
|
||||
<button className="btn btn-danger" onClick={onDelete}>
|
||||
Delete {count}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -219,6 +219,22 @@ function SharesPanel({ albumId }) {
|
||||
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 (
|
||||
<section className="panel">
|
||||
<h2>Client links</h2>
|
||||
@@ -233,16 +249,34 @@ function SharesPanel({ albumId }) {
|
||||
<span className="muted">
|
||||
{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.reject_count}
|
||||
</span>
|
||||
</div>
|
||||
<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 && (
|
||||
<button
|
||||
className="btn"
|
||||
@@ -377,6 +411,14 @@ export default function AlbumPage() {
|
||||
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 (
|
||||
<>
|
||||
<div className="page-head">
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user