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
This commit is contained in:
2026-07-17 15:55:46 +02:00
parent 6259ca84d8
commit 30d0b3064e
14 changed files with 612 additions and 126 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
+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
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [index, photos.length, onClose, onNav])
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', 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>
)
}
+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>
)
}
+28 -22
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) {
@@ -236,7 +238,8 @@ function SharesPanel({ albumId }) {
? ` · 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">
@@ -320,9 +323,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 +342,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,7 +372,7 @@ 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()
}
@@ -436,15 +429,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 +458,34 @@ 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`)}
/>
{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),
}
}
+4 -1
View File
@@ -23,7 +23,10 @@ export default function useSelection(photos) {
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,
+64 -28
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?;
let mut feedback: HashMap<Uuid, PhotoFeedback> = HashMap::new();
let ratings: Vec<(Uuid, String, i32)> = sqlx::query_as(
// 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",
)
.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(
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",
)
.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 });
}
album_id,
),
)?;
let mut feedback: HashMap<Uuid, PhotoFeedback> = HashMap::new();
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>,
+4
View File
@@ -100,6 +100,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),
+17 -6
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,8 +131,7 @@ 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"))
let row: ShareAdminRow = sqlx::query_as(&format!("{SHARE_SELECT} where s.id = $1"))
.bind(share_id)
.fetch_one(&state.db)
.await?;