2 Commits
Author SHA1 Message Date
nils 5cb00f3ef0 Bulk delete, editable share links, gallery filter fix
ci / docker (push) Successful in 9s
- 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
2026-07-17 16:30:26 +02:00
nils 30d0b3064e Client accept/reject votes with keyboard-driven culling
ci / docker (push) Successful in 13s
- verdicts table (per link, like ratings), PUT verdict endpoint, typed Verdict enum
- share page: thumbs up/down, verdict filter with counts, view-scoped selection bar
- lightbox: per-page shortcut table (P/X/U, 1-5/0, S), ? help overlay, action
  toast when a keyboard vote auto-advances
- shared useLightbox hook; single keydown subscription reading live state via ref
- album view: per-link thumbs and vote counts; share list shows accept/reject totals
- feedback queries deduped and run concurrently; verdict counts in one scan
2026-07-17 15:55:46 +02:00
17 changed files with 771 additions and 142 deletions
+13 -8
View File
@@ -2,7 +2,8 @@
Self-hosted client gallery for photographers. Upload RAWs/JPGs into albums,
share them with clients via private (optionally password-protected) links,
collect ratings and tags, and let clients download originals.
collect accept/reject votes, ratings and tags, and let clients download
originals.
## Architecture
@@ -36,8 +37,10 @@ collect ratings and tags, and let clients download originals.
revokes access immediately. Clients use unguessable share tokens, optionally
gated by an argon2-hashed password (10 wrong guesses lock the link for
15 minutes).
- **Frontend**: React + Vite SPA — justified gallery, lightbox with rating
stars and tag chips, drag-and-drop multi-file upload with progress.
- **Frontend**: React + Vite SPA — justified gallery, lightbox with
accept/reject thumbs, rating stars and tag chips, keyboard-driven culling
(`P`/`X`/`U`, `1``5`, `?` shows all shortcuts), drag-and-drop multi-file
upload with progress.
## Local development
@@ -135,9 +138,11 @@ Notes:
- Each album can have any number of share links (`/s/<24-char-token>`), each
with its own label (e.g. the client's name), optional password, optional
expiry, and a per-link download toggle.
- Ratings (15 stars) and free-form tags are stored **per link**, so create
one link per client to keep feedback separate. The album view shows all
feedback grouped by link label.
- Accept/reject votes (👍/👎), ratings (15 stars) and free-form tags are
stored **per link**, so create one link per client to keep feedback
separate. The album view shows all feedback grouped by link label, and
clients can filter their gallery by verdict (e.g. review only what's still
undecided, or select-all + download the accepted set).
- Clients (and you) can multi-select photos and download them — or the whole
album — as a ZIP. Archives are streamed (each file spools briefly through a
temp file for its checksum, then pipelines while the next one prefetches),
@@ -150,8 +155,8 @@ Notes:
- 10 wrong passwords lock a link for 15 minutes (fresh attempts after the
window). A locked link shows in the album's share list with an Unlock
button.
- Deleting a link removes its ratings/tags; deleting photos or albums cleans
up S3 objects via background jobs.
- Deleting a link removes its votes/ratings/tags; deleting photos or albums
cleans up S3 objects via background jobs.
## Known limitations / deliberate v1 cuts
+12 -8
View File
@@ -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)
}}
>
+88 -9
View File
@@ -1,18 +1,71 @@
import { useEffect } from 'react'
import { useEffect, useRef, useState } from 'react'
import { imgUrl } from '../api'
export default function Lightbox({ photos, index, onClose, onNav, footer }) {
const BASE_SHORTCUTS = [
['← / →', 'previous / next photo'],
['Space', 'next photo (Shift+Space back)'],
['?', 'show / hide shortcuts'],
['Esc', 'close'],
]
// `actions` defines the page's shortcuts as one table — display and dispatch
// come from the same entry, so the help overlay can't drift from behavior:
// { keys: ['p'], help: ['P', 'accept…'], run: (photo, key) => … }.
// Keys fire only outside text inputs and while the help overlay is closed.
export default function Lightbox({ photos, index, onClose, onNav, footer, actions }) {
const photo = photos[index]
const [showHelp, setShowHelp] = useState(false)
// Handlers and view state live in a ref, updated every render, so the
// window listener is attached once yet always dispatches against current
// values — re-subscribing per render leaves a gap until effects re-run in
// which a fast second keystroke hits a stale closure (and e.g. re-votes
// the previous photo).
const live = useRef({})
live.current = { index, count: photos.length, photo, onClose, onNav, actions, showHelp }
useEffect(() => {
const onKey = (e) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowRight' && index < photos.length - 1) onNav(index + 1)
if (e.key === 'ArrowLeft' && index > 0) onNav(index - 1)
// Only text entry captures keys (Escape leaves the field); focus on a
// checkbox or button must not disable lightbox navigation.
const isTyping = (el) =>
el.tagName === 'TEXTAREA' ||
el.isContentEditable ||
(el.tagName === 'INPUT' && !['checkbox', 'radio', 'button'].includes(el.type))
const handler = (e) => {
const s = live.current
if (isTyping(e.target)) {
if (e.key === 'Escape') e.target.blur()
return
}
if (e.metaKey || e.ctrlKey || e.altKey) return
if (e.key === 'Escape') {
if (s.showHelp) setShowHelp(false)
else s.onClose()
return
}
if (e.key === '?') {
setShowHelp((h) => !h)
return
}
// With the help overlay up, keys must not act on the photo behind it.
if (s.showHelp) return
if (e.key === 'ArrowRight' || (e.key === ' ' && !e.shiftKey)) {
e.preventDefault()
if (s.index < s.count - 1) s.onNav(s.index + 1)
return
}
if (e.key === 'ArrowLeft' || (e.key === ' ' && e.shiftKey)) {
e.preventDefault()
if (s.index > 0) s.onNav(s.index - 1)
return
}
const key = e.key.toLowerCase()
const action = s.actions?.find((a) => a.keys.includes(key))
if (action && s.photo) action.run(s.photo, key)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [index, photos.length, onClose, onNav])
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [])
useEffect(() => {
document.body.style.overflow = 'hidden'
@@ -30,6 +83,13 @@ export default function Lightbox({ photos, index, onClose, onNav, footer }) {
<span className="lb-count">
{index + 1} / {photos.length}
</span>
<button
className="lb-btn"
onClick={() => setShowHelp((h) => !h)}
title="Keyboard shortcuts (?)"
>
?
</button>
<button className="lb-btn" onClick={onClose} title="Close (Esc)">
</button>
@@ -67,6 +127,25 @@ export default function Lightbox({ photos, index, onClose, onNav, footer }) {
{footer(photo)}
</div>
)}
{showHelp && (
<div
className="lb-help"
onClick={(e) => {
e.stopPropagation()
setShowHelp(false)
}}
>
<div className="lb-help-card">
<h3>Keyboard shortcuts</h3>
{[...(actions?.map((a) => a.help) || []), ...BASE_SHORTCUTS].map(([keys, label]) => (
<div key={keys} className="lb-help-row">
<kbd>{keys}</kbd>
<span className="muted">{label}</span>
</div>
))}
</div>
</div>
)}
</div>
)
}
+6
View File
@@ -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>
)
}
+24
View File
@@ -0,0 +1,24 @@
// Accept/reject vote. `value` is 'accept', 'reject' or null; clicking the
// active thumb clears it. Without onChange it renders read-only.
export default function Thumbs({ value, onChange, small }) {
const thumb = (verdict, glyph, label) => (
<button
type="button"
className={`thumb${value === verdict ? ' active' : ''}`}
disabled={!onChange}
onClick={(e) => {
e.stopPropagation()
onChange(value === verdict ? null : verdict)
}}
title={onChange ? label : undefined}
>
{glyph}
</button>
)
return (
<span className={`thumbs${small ? ' thumbs-small' : ''}`}>
{thumb('accept', '👍', 'Accept (P)')}
{thumb('reject', '👎', 'Reject (X)')}
</span>
)
}
+76 -27
View File
@@ -5,6 +5,8 @@ import Gallery from '../components/Gallery'
import Lightbox from '../components/Lightbox'
import SelectionBar, { fmtBytes } from '../components/SelectionBar'
import Stars from '../components/Stars'
import Thumbs from '../components/Thumbs'
import useLightbox from '../useLightbox'
import useSelection from '../useSelection'
function fmtEta(seconds) {
@@ -217,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>
@@ -231,15 +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.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"
@@ -320,9 +357,6 @@ export default function AlbumPage() {
const navigate = useNavigate()
const [detail, setDetail] = useState(null)
const [error, setError] = useState(null)
// Track the open photo by id, not index — the polling refetch can reorder
// the array underneath an open lightbox.
const [lightboxId, setLightboxId] = useState(null)
const load = useCallback(
() => api(`/api/albums/${id}`).then(setDetail).catch((e) => setError(e.message)),
@@ -342,14 +376,7 @@ export default function AlbumPage() {
const ready = (detail?.photos ?? []).filter((p) => p.status === 'ready')
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(ready)
const lightboxIndex = ready.findIndex((p) => p.id === lightboxId)
// If the open photo leaves the ready list (deleted elsewhere, reprocess),
// close for good — otherwise the lightbox would pop back open when the
// photo returns to ready.
useEffect(() => {
if (lightboxId && lightboxIndex < 0) setLightboxId(null)
}, [lightboxId, lightboxIndex])
const lightbox = useLightbox(ready)
if (error) return <p className="error">{error}</p>
if (!detail) return <p className="muted">Loading</p>
@@ -379,11 +406,19 @@ export default function AlbumPage() {
const removePhoto = async (photoId) => {
if (!confirm('Delete this photo?')) return
setLightboxId(null)
lightbox.close()
await api(`/api/photos/${photoId}`, { method: 'DELETE' })
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">
@@ -436,15 +471,20 @@ export default function AlbumPage() {
<Gallery
photos={ready}
onOpen={(i) => setLightboxId(ready[i].id)}
onOpen={lightbox.openAt}
selected={selected}
onToggleSelect={toggle}
overlay={(p) => {
const avg = avgRating(p.id)
const tagCount = feedback[p.id]?.tags.length || 0
if (avg === null && tagCount === 0) return null
const verdicts = feedback[p.id]?.verdicts || []
const accepts = verdicts.filter((v) => v.verdict === 'accept').length
const rejects = verdicts.length - accepts
if (avg === null && tagCount === 0 && accepts === 0 && rejects === 0) return null
return (
<div className="g-overlay">
{accepts > 0 && <span>👍 {accepts}</span>}
{rejects > 0 && <span>👎 {rejects}</span>}
{avg !== null && <span> {avg.toFixed(1)}</span>}
{tagCount > 0 && <span># {tagCount}</span>}
</div>
@@ -460,26 +500,35 @@ export default function AlbumPage() {
total={ready.length}
selectedBytes={selectedBytes}
totalBytes={totalBytes}
onSelectAll={selectAll}
onSelectAll={() => selectAll(ready)}
onClear={clear}
onDownload={() => postDownload(`/api/albums/${id}/zip`, [...selected].join(','))}
onDownloadAll={() => postDownload(`/api/albums/${id}/zip`)}
onDelete={removeSelected}
/>
{lightboxIndex >= 0 && (
{lightbox.index >= 0 && (
<Lightbox
photos={ready}
index={lightboxIndex}
onClose={() => setLightboxId(null)}
onNav={(i) => setLightboxId(ready[i].id)}
index={lightbox.index}
onClose={lightbox.close}
onNav={lightbox.openAt}
actions={[
{ keys: ['s'], help: ['S', 'select for download'], run: (p) => toggle(p.id) },
]}
footer={(p) => {
const fb = feedback[p.id] || { ratings: [], tags: [] }
const fb = feedback[p.id] || { ratings: [], verdicts: [], tags: [] }
return (
<div className="admin-footer">
<div className="feedback">
{fb.ratings.length === 0 && fb.tags.length === 0 && (
{fb.ratings.length === 0 && fb.verdicts.length === 0 && fb.tags.length === 0 && (
<span className="muted">No client feedback yet</span>
)}
{fb.verdicts.map((v, i) => (
<span key={`v${i}`} className="feedback-item">
{v.share_label || 'client'}: <Thumbs value={v.verdict} small />
</span>
))}
{fb.ratings.map((r, i) => (
<span key={`r${i}`} className="feedback-item">
{r.share_label || 'client'}: <Stars value={r.rating} small />
+143 -40
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'react-router-dom'
import { api, postDownload } from '../api'
import Gallery from '../components/Gallery'
@@ -6,18 +6,43 @@ import Lightbox from '../components/Lightbox'
import SelectionBar from '../components/SelectionBar'
import Stars from '../components/Stars'
import TagEditor from '../components/TagEditor'
import Thumbs from '../components/Thumbs'
import useLightbox from '../useLightbox'
import useSelection from '../useSelection'
const FILTERS = [
{ key: 'all', label: 'All' },
{ key: 'accept', label: '👍' },
{ key: 'reject', label: '👎' },
{ key: 'undecided', label: 'Undecided' },
]
const matchesFilter = (photo, key) =>
key === 'all' || (key === 'undecided' ? !photo.my_verdict : photo.my_verdict === key)
export default function SharePage() {
const { token } = useParams()
const [view, setView] = useState(null)
const [error, setError] = useState(null)
const [password, setPassword] = useState('')
const [unlockError, setUnlockError] = useState(null)
const [lightbox, setLightbox] = useState(-1)
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(
view?.photos ?? [],
)
const [filter, setFilter] = useState('all')
const photos = view?.photos ?? []
const visible = useMemo(() => photos.filter((p) => matchesFilter(p, filter)), [photos, filter])
const filterCounts = useMemo(() => {
const counts = { all: photos.length, accept: 0, reject: 0, undecided: 0 }
for (const p of photos) counts[p.my_verdict ?? 'undecided'] += 1
return counts
}, [photos])
// Selection spans the whole album (so switching filters keeps it), while
// the selection bar describes only the current view: its counts, bytes and
// downloads cover the visible photos, and "select all" adds them.
const { selected, toggle, selectAll, clear } = useSelection(photos)
const visibleSelected = visible.filter((p) => selected.has(p.id))
const sumBytes = (list) => list.reduce((sum, p) => sum + p.size_bytes, 0)
const lightbox = useLightbox(visible)
const load = useCallback(
() => api(`/api/share/${token}`).then(setView).catch((e) => setError(e.message)),
@@ -45,29 +70,75 @@ export default function SharePage() {
}))
}
const setRating = async (photo, rating) => {
patchPhoto(photo.id, { my_rating: rating || null })
// Optimistic write: patch local state, PUT, reload from the server on
// failure to undo the patch.
const saveFeedback = async (photo, patch, endpoint, body) => {
patchPhoto(photo.id, patch)
try {
await api(`/api/share/${token}/photos/${photo.id}/rating`, {
method: 'PUT',
body: { rating },
})
await api(`/api/share/${token}/photos/${photo.id}/${endpoint}`, { method: 'PUT', body })
} catch {
load()
}
}
const setRating = (photo, rating) =>
saveFeedback(photo, { my_rating: rating || null }, 'rating', { rating })
const setVerdict = (photo, verdict) =>
saveFeedback(photo, { my_verdict: verdict }, 'verdict', { verdict })
const setTags = (photo, tags) => saveFeedback(photo, { my_tags: tags }, 'tags', { tags })
// Keyboard votes navigate away from the photo they change, so a transient
// toast names what just happened to it — without it the jump reads as
// "did that register?".
const [flash, setFlash] = useState(null)
const flashSeq = useRef(0)
const flashTimer = useRef()
const showFlash = (text) => {
flashSeq.current += 1
setFlash({ text, key: flashSeq.current })
clearTimeout(flashTimer.current)
flashTimer.current = setTimeout(() => setFlash(null), 1400)
}
useEffect(() => () => clearTimeout(flashTimer.current), [])
// Culling flow: vote, then advance to the photo that was next in the
// current view. Under a filter that hides the voted photo, the advance
// target stays visible, so the run continues seamlessly.
const voteAndAdvance = (photo, verdict) => {
const next = visible[lightbox.index + 1]
setVerdict(photo, verdict)
showFlash(
verdict === 'accept'
? `👍 ${photo.filename}`
: verdict === 'reject'
? `👎 ${photo.filename}`
: `${photo.filename} cleared`,
)
if (next) lightbox.show(next.id)
}
const setTags = async (photo, tags) => {
patchPhoto(photo.id, { my_tags: tags })
try {
await api(`/api/share/${token}/photos/${photo.id}/tags`, {
method: 'PUT',
body: { tags },
})
} catch {
load()
}
}
const keyActions = [
{ keys: ['p'], help: ['P', 'accept and go to next'], run: (p) => voteAndAdvance(p, 'accept') },
{ keys: ['x'], help: ['X', 'reject and go to next'], run: (p) => voteAndAdvance(p, 'reject') },
{
keys: ['u'],
help: ['U', 'clear accept / reject'],
// When clearing hides the photo from the current filter, advance like
// a vote so the lightbox doesn't just close.
run: (p) =>
matchesFilter({ my_verdict: null }, filter)
? setVerdict(p, null)
: voteAndAdvance(p, null),
},
{
keys: ['1', '2', '3', '4', '5'],
help: ['15', 'star rating'],
run: (p, key) => setRating(p, Number(key)),
},
{ keys: ['0'], help: ['0', 'clear star rating'], run: (p) => setRating(p, 0) },
...(view?.allow_download
? [{ keys: ['s'], help: ['S', 'select for download'], run: (p) => toggle(p.id) }]
: []),
]
if (error) return <div className="center-page">{error}</div>
if (!view) return <div className="center-page">Loading</div>
@@ -100,49 +171,76 @@ export default function SharePage() {
<h1>{view.album_name}</h1>
{view.album_description && <p className="muted">{view.album_description}</p>}
<p className="muted">
{view.photos.length} photo{view.photos.length === 1 ? '' : 's'} · click a photo to view,
rate and tag
{photos.length} photo{photos.length === 1 ? '' : 's'} · click a photo to view, rate and
tag · press <kbd>?</kbd> in the viewer for shortcuts
</p>
{photos.length > 0 && (
<div className="filter-bar">
{FILTERS.map((f) => (
<button
key={f.key}
className={`filter-chip${filter === f.key ? ' active' : ''}`}
onClick={() => setFilter(f.key)}
>
{f.label} {filterCounts[f.key]}
</button>
))}
</div>
)}
</header>
<main className="page">
<Gallery
photos={view.photos}
onOpen={setLightbox}
photos={visible}
onOpen={lightbox.openAt}
selected={view.allow_download ? selected : undefined}
onToggleSelect={view.allow_download ? toggle : undefined}
overlay={(p) =>
p.my_rating || p.my_tags.length > 0 ? (
p.my_verdict || p.my_rating || p.my_tags.length > 0 ? (
<div className="g-overlay">
{p.my_verdict && <span>{p.my_verdict === 'accept' ? '👍' : '👎'}</span>}
{p.my_rating && <span> {p.my_rating}</span>}
{p.my_tags.length > 0 && <span># {p.my_tags.length}</span>}
</div>
) : null
}
/>
{view.photos.length === 0 && (
{photos.length === 0 && (
<p className="center-page muted">Nothing here yet check back soon.</p>
)}
{photos.length > 0 && visible.length === 0 && (
<p className="center-page muted">No photos match this filter.</p>
)}
</main>
{view.allow_download && (
<SelectionBar
count={selected.size}
total={view.photos.length}
selectedBytes={selectedBytes}
totalBytes={totalBytes}
onSelectAll={selectAll}
count={visibleSelected.length}
total={visible.length}
selectedBytes={sumBytes(visibleSelected)}
totalBytes={sumBytes(visible)}
onSelectAll={() => selectAll(visible)}
onClear={clear}
onDownload={() => postDownload(`/api/share/${token}/zip`, [...selected].join(','))}
onDownloadAll={() => postDownload(`/api/share/${token}/zip`)}
onDownload={() =>
postDownload(`/api/share/${token}/zip`, visibleSelected.map((p) => p.id).join(','))
}
onDownloadAll={() =>
// Under a filter, "download all" means all photos shown.
postDownload(
`/api/share/${token}/zip`,
filter === 'all' ? '' : visible.map((p) => p.id).join(','),
)
}
/>
)}
{lightbox >= 0 && (
{lightbox.index >= 0 && (
<Lightbox
photos={view.photos}
index={lightbox}
onClose={() => setLightbox(-1)}
onNav={setLightbox}
photos={visible}
index={lightbox.index}
onClose={lightbox.close}
onNav={lightbox.openAt}
actions={keyActions}
footer={(p) => (
<div className="client-footer">
<Thumbs value={p.my_verdict} onChange={(v) => setVerdict(p, v)} />
<Stars value={p.my_rating || 0} onChange={(r) => setRating(p, r)} />
<TagEditor tags={p.my_tags} onChange={(tags) => setTags(p, tags)} />
{view.allow_download && (
@@ -164,6 +262,11 @@ export default function SharePage() {
)}
/>
)}
{flash && (
<div key={flash.key} className="action-flash">
{flash.text}
</div>
)}
</>
)
}
+124
View File
@@ -534,6 +534,130 @@ progress {
cursor: default;
}
/* thumbs (accept / reject) */
.thumbs {
display: inline-flex;
gap: 0.15rem;
}
.thumb {
background: none;
border: none;
font-size: 1.35rem;
line-height: 1;
padding: 0 0.15rem;
cursor: pointer;
filter: grayscale(1);
opacity: 0.4;
transition: opacity 0.12s;
}
.thumb:hover:enabled {
opacity: 0.8;
}
.thumb.active {
filter: none;
opacity: 1;
}
.thumb:disabled {
cursor: default;
}
.thumbs-small .thumb {
font-size: 0.95rem;
}
/* verdict filter */
.filter-bar {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.9rem;
}
.filter-chip {
background: var(--panel);
border: 1px solid var(--panel-2);
border-radius: 999px;
color: var(--muted);
padding: 0.25rem 0.8rem;
font-size: 0.85rem;
cursor: pointer;
}
.filter-chip.active {
background: var(--panel-2);
color: var(--text);
border-color: var(--accent);
}
/* transient action feedback (keyboard votes that navigate away) */
.action-flash {
position: fixed;
top: 3rem;
left: 50%;
transform: translateX(-50%);
z-index: 110;
background: var(--panel);
border: 1px solid var(--panel-2);
border-radius: 999px;
padding: 0.35rem 1rem;
font-size: 0.9rem;
white-space: nowrap;
pointer-events: none;
animation: flash-fade 1.4s ease forwards;
}
@keyframes flash-fade {
0% {
opacity: 0;
transform: translate(-50%, -6px);
}
8%,
70% {
opacity: 1;
transform: translate(-50%, 0);
}
100% {
opacity: 0;
transform: translate(-50%, 0);
}
}
/* lightbox shortcut help */
.lb-help {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(8, 9, 11, 0.6);
z-index: 102;
}
.lb-help-card {
background: var(--panel);
border: 1px solid var(--panel-2);
border-radius: 12px;
padding: 1.1rem 1.5rem 1.25rem;
min-width: 280px;
cursor: default;
}
.lb-help-card h3 {
font-size: 0.95rem;
margin: 0 0 0.6rem;
}
.lb-help-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1.5rem;
padding: 0.18rem 0;
font-size: 0.88rem;
}
kbd {
background: var(--panel-2);
border-radius: 4px;
padding: 0.08rem 0.45rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8rem;
white-space: nowrap;
}
/* tags */
.tag-editor {
display: inline-flex;
+21
View File
@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react'
// Lightbox state tracked by photo id, not index — the list can reorder
// (polling refetch) or shrink (filter change, delete) underneath an open
// lightbox. When the open photo leaves the list, close for good — otherwise
// the lightbox would pop back open when the photo returns to the list.
export default function useLightbox(photos) {
const [openId, setOpenId] = useState(null)
const index = openId ? photos.findIndex((p) => p.id === openId) : -1
useEffect(() => {
if (openId && index < 0) setOpenId(null)
}, [openId, index])
return {
index,
openAt: (i) => setOpenId(photos[i].id),
show: (id) => setOpenId(id),
close: () => setOpenId(null),
}
}
+20 -3
View File
@@ -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,15 +17,30 @@ 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
})
const selectAll = () => setSelected(new Set(photos.map((p) => p.id)))
// 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 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)
+13
View File
@@ -0,0 +1,13 @@
-- Client accept/reject votes, one per (link, photo) — a separate axis from
-- the 1-5 star rating so a photo can be e.g. accepted but unrated.
create table verdicts (
share_id uuid not null references shares(id) on delete cascade,
photo_id uuid not null references photos(id) on delete cascade,
verdict text not null check (verdict in ('accept', 'reject')),
updated_at timestamptz not null default now(),
primary key (share_id, photo_id)
);
-- Cascaded photo deletes fire per-row FK triggers; without this each one
-- sequential-scans the table.
create index verdicts_photo_idx on verdicts (photo_id);
+20 -1
View File
@@ -1,5 +1,5 @@
use chrono::{DateTime, Utc};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Stored as text in Postgres; decoded via TryFrom so an unknown value is a
@@ -47,6 +47,25 @@ impl TryFrom<String> for PhotoStatus {
}
}
/// Client accept/reject vote. Same convention as PhotoStatus: text in
/// Postgres (check-constrained), this enum everywhere Rust touches the value
/// — serde rejects anything but "accept"/"reject" at the API boundary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Verdict {
Accept,
Reject,
}
impl Verdict {
pub fn as_str(self) -> &'static str {
match self {
Self::Accept => "accept",
Self::Reject => "reject",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JobKind {
ProcessPhoto,
+70 -34
View File
@@ -79,12 +79,40 @@ pub struct ShareTag {
pub tag: String,
}
#[derive(Serialize)]
pub struct ShareVerdict {
pub share_label: String,
pub verdict: String,
}
#[derive(Serialize, Default)]
pub struct PhotoFeedback {
pub ratings: Vec<ShareRating>,
pub verdicts: Vec<ShareVerdict>,
pub tags: Vec<ShareTag>,
}
async fn feedback_rows<T>(
db: &sqlx::PgPool,
sql: &str,
album_id: Uuid,
) -> Result<Vec<(Uuid, String, T)>, sqlx::Error>
where
(Uuid, String, T): for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow> + Send + Unpin,
{
sqlx::query_as(sql).bind(album_id).fetch_all(db).await
}
fn fold_feedback<T>(
feedback: &mut HashMap<Uuid, PhotoFeedback>,
rows: Vec<(Uuid, String, T)>,
push: impl Fn(&mut PhotoFeedback, String, T),
) {
for (photo_id, share_label, value) in rows {
push(feedback.entry(photo_id).or_default(), share_label, value);
}
}
#[derive(Serialize)]
pub struct AlbumDetail {
pub album: Album,
@@ -108,41 +136,49 @@ pub async fn get_one(
.fetch_all(&state.db)
.await?;
// The three feedback kinds are independent (photo_id, share label, value)
// queries — run them concurrently and fold with one shared shape.
let (ratings, verdicts, tags) = tokio::try_join!(
feedback_rows::<i32>(
&state.db,
"select r.photo_id, s.label, r.rating
from ratings r join shares s on s.id = r.share_id
where s.album_id = $1",
album_id,
),
feedback_rows::<String>(
&state.db,
"select v.photo_id, s.label, v.verdict
from verdicts v join shares s on s.id = v.share_id
where s.album_id = $1",
album_id,
),
feedback_rows::<String>(
&state.db,
"select t.photo_id, s.label, t.tag
from tags t join shares s on s.id = t.share_id
where s.album_id = $1
order by t.created_at",
album_id,
),
)?;
let mut feedback: HashMap<Uuid, PhotoFeedback> = HashMap::new();
let ratings: Vec<(Uuid, String, i32)> = sqlx::query_as(
"select r.photo_id, s.label, r.rating
from ratings r join shares s on s.id = r.share_id
where s.album_id = $1",
)
.bind(album_id)
.fetch_all(&state.db)
.await?;
for (photo_id, share_label, rating) in ratings {
feedback
.entry(photo_id)
.or_default()
.ratings
.push(ShareRating {
share_label,
rating,
});
}
let tags: Vec<(Uuid, String, String)> = sqlx::query_as(
"select t.photo_id, s.label, t.tag
from tags t join shares s on s.id = t.share_id
where s.album_id = $1
order by t.created_at",
)
.bind(album_id)
.fetch_all(&state.db)
.await?;
for (photo_id, share_label, tag) in tags {
feedback
.entry(photo_id)
.or_default()
.tags
.push(ShareTag { share_label, tag });
}
fold_feedback(&mut feedback, ratings, |f, share_label, rating| {
f.ratings.push(ShareRating {
share_label,
rating,
})
});
fold_feedback(&mut feedback, verdicts, |f, share_label, verdict| {
f.verdicts.push(ShareVerdict {
share_label,
verdict,
})
});
fold_feedback(&mut feedback, tags, |f, share_label, tag| {
f.tags.push(ShareTag { share_label, tag })
});
Ok(Json(AlbumDetail {
album,
+40 -2
View File
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::models::{PhotoStatus, Share};
use crate::models::{PhotoStatus, Share, Verdict};
use crate::state::AppState;
pub async fn load_share(state: &AppState, token: &str) -> Result<Share, ApiError> {
@@ -137,6 +137,7 @@ struct ClientPhotoRow {
taken_at: Option<DateTime<Utc>>,
processed_at: Option<DateTime<Utc>>,
my_rating: Option<i32>,
my_verdict: Option<String>,
}
#[derive(Serialize)]
@@ -185,9 +186,10 @@ pub async fn get_share(
let jar = grant_access(&state, jar, share.id);
let rows: Vec<ClientPhotoRow> = sqlx::query_as(
"select p.id, p.filename, p.size_bytes, p.width, p.height, p.taken_at, p.processed_at, r.rating as my_rating
"select p.id, p.filename, p.size_bytes, p.width, p.height, p.taken_at, p.processed_at, r.rating as my_rating, v.verdict as my_verdict
from photos p
left join ratings r on r.photo_id = p.id and r.share_id = $2
left join verdicts v on v.photo_id = p.id and v.share_id = $2
where p.album_id = $1 and p.status = $3
order by coalesce(p.taken_at, p.created_at), p.filename",
)
@@ -352,6 +354,42 @@ pub async fn set_rating(
Ok(Json(serde_json::json!({ "ok": true })))
}
#[derive(Deserialize)]
pub struct VerdictBody {
verdict: Option<Verdict>,
}
pub async fn set_verdict(
State(state): State<AppState>,
Path((token, photo_id)): Path<(String, Uuid)>,
jar: SignedCookieJar,
Json(body): Json<VerdictBody>,
) -> ApiResult<Json<serde_json::Value>> {
let share = share_photo(&state, &jar, &token, photo_id).await?;
match body.verdict {
None => {
sqlx::query("delete from verdicts where share_id = $1 and photo_id = $2")
.bind(share.id)
.bind(photo_id)
.execute(&state.db)
.await?;
}
Some(verdict) => {
sqlx::query(
"insert into verdicts (share_id, photo_id, verdict) values ($1, $2, $3)
on conflict (share_id, photo_id)
do update set verdict = excluded.verdict, updated_at = now()",
)
.bind(share.id)
.bind(photo_id)
.bind(verdict.as_str())
.execute(&state.db)
.await?;
}
}
Ok(Json(serde_json::json!({ "ok": true })))
}
#[derive(Deserialize)]
pub struct TagsBody {
tags: Vec<String>,
+9 -1
View File
@@ -78,9 +78,13 @@ pub fn router(state: &AppState) -> Router<AppState> {
)
.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(),
@@ -100,6 +104,10 @@ pub fn router(state: &AppState) -> Router<AppState> {
"/api/share/{token}/photos/{photo_id}/rating",
put(client::set_rating),
)
.route(
"/api/share/{token}/photos/{photo_id}/verdict",
put(client::set_verdict),
)
.route(
"/api/share/{token}/photos/{photo_id}/tags",
put(client::set_tags),
+29
View File
@@ -211,6 +211,35 @@ pub async fn delete(
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(
State(state): State<AppState>,
Path(photo_id): Path<Uuid>,
+63 -9
View File
@@ -23,6 +23,8 @@ struct ShareAdminRow {
created_at: DateTime<Utc>,
rating_count: i64,
tag_count: i64,
accept_count: i64,
reject_count: i64,
}
fn share_json(state: &AppState, row: &ShareAdminRow) -> serde_json::Value {
@@ -38,20 +40,30 @@ fn share_json(state: &AppState, row: &ShareAdminRow) -> serde_json::Value {
"created_at": row.created_at,
"rating_count": row.rating_count,
"tag_count": row.tag_count,
"accept_count": row.accept_count,
"reject_count": row.reject_count,
})
}
const SHARE_COLUMNS: &str = "s.id, s.token, s.label, s.password_hash, s.allow_download,
// Aggregates run as laterals so each feedback table is scanned once per
// share (the verdict lateral yields both counts from a single pass).
const SHARE_SELECT: &str = "select s.id, s.token, s.label, s.password_hash, s.allow_download,
s.expires_at, s.locked_until, s.created_at,
(select count(*) from ratings r where r.share_id = s.id) as rating_count,
(select count(*) from tags t where t.share_id = s.id) as tag_count";
rc.rating_count, tc.tag_count, vc.accept_count, vc.reject_count
from shares s
cross join lateral (select count(*) as rating_count from ratings r where r.share_id = s.id) rc
cross join lateral (select count(*) as tag_count from tags t where t.share_id = s.id) tc
cross join lateral (
select count(*) filter (where v.verdict = 'accept') as accept_count,
count(*) filter (where v.verdict = 'reject') as reject_count
from verdicts v where v.share_id = s.id) vc";
pub async fn list(
State(state): State<AppState>,
Path(album_id): Path<Uuid>,
) -> ApiResult<Json<Vec<serde_json::Value>>> {
let rows: Vec<ShareAdminRow> = sqlx::query_as(&format!(
"select {SHARE_COLUMNS} from shares s where s.album_id = $1 order by s.created_at desc"
"{SHARE_SELECT} where s.album_id = $1 order by s.created_at desc"
))
.bind(album_id)
.fetch_all(&state.db)
@@ -119,11 +131,53 @@ pub async fn create(
.fetch_one(&state.db)
.await?;
let row: ShareAdminRow =
sqlx::query_as(&format!("select {SHARE_COLUMNS} from shares s where s.id = $1"))
.bind(share_id)
.fetch_one(&state.db)
.await?;
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)))
}
/// 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)))
}