1 Commits
Author SHA1 Message Date
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
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, Self-hosted client gallery for photographers. Upload RAWs/JPGs into albums,
share them with clients via private (optionally password-protected) links, 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 ## Architecture
@@ -36,8 +37,10 @@ collect ratings and tags, and let clients download originals.
revokes access immediately. Clients use unguessable share tokens, optionally revokes access immediately. Clients use unguessable share tokens, optionally
gated by an argon2-hashed password (10 wrong guesses lock the link for gated by an argon2-hashed password (10 wrong guesses lock the link for
15 minutes). 15 minutes).
- **Frontend**: React + Vite SPA — justified gallery, lightbox with rating - **Frontend**: React + Vite SPA — justified gallery, lightbox with
stars and tag chips, drag-and-drop multi-file upload with progress. 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 ## Local development
@@ -135,9 +138,11 @@ Notes:
- Each album can have any number of share links (`/s/<24-char-token>`), each - 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 with its own label (e.g. the client's name), optional password, optional
expiry, and a per-link download toggle. expiry, and a per-link download toggle.
- Ratings (15 stars) and free-form tags are stored **per link**, so create - Accept/reject votes (👍/👎), ratings (15 stars) and free-form tags are
one link per client to keep feedback separate. The album view shows all stored **per link**, so create one link per client to keep feedback
feedback grouped by link label. 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 - 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 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), 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 - 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 window). A locked link shows in the album's share list with an Unlock
button. button.
- Deleting a link removes its ratings/tags; deleting photos or albums cleans - Deleting a link removes its votes/ratings/tags; deleting photos or albums
up S3 objects via background jobs. cleans up S3 objects via background jobs.
## Known limitations / deliberate v1 cuts ## 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' 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 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(() => { useEffect(() => {
const onKey = (e) => { // Only text entry captures keys (Escape leaves the field); focus on a
if (e.key === 'Escape') onClose() // checkbox or button must not disable lightbox navigation.
if (e.key === 'ArrowRight' && index < photos.length - 1) onNav(index + 1) const isTyping = (el) =>
if (e.key === 'ArrowLeft' && index > 0) onNav(index - 1) 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) window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', handler)
}, [index, photos.length, onClose, onNav]) }, [])
useEffect(() => { useEffect(() => {
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
@@ -30,6 +83,13 @@ export default function Lightbox({ photos, index, onClose, onNav, footer }) {
<span className="lb-count"> <span className="lb-count">
{index + 1} / {photos.length} {index + 1} / {photos.length}
</span> </span>
<button
className="lb-btn"
onClick={() => setShowHelp((h) => !h)}
title="Keyboard shortcuts (?)"
>
?
</button>
<button className="lb-btn" onClick={onClose} title="Close (Esc)"> <button className="lb-btn" onClick={onClose} title="Close (Esc)">
</button> </button>
@@ -67,6 +127,25 @@ export default function Lightbox({ photos, index, onClose, onNav, footer }) {
{footer(photo)} {footer(photo)}
</div> </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> </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 Lightbox from '../components/Lightbox'
import SelectionBar, { fmtBytes } from '../components/SelectionBar' import SelectionBar, { fmtBytes } from '../components/SelectionBar'
import Stars from '../components/Stars' import Stars from '../components/Stars'
import Thumbs from '../components/Thumbs'
import useLightbox from '../useLightbox'
import useSelection from '../useSelection' import useSelection from '../useSelection'
function fmtEta(seconds) { function fmtEta(seconds) {
@@ -236,7 +238,8 @@ function SharesPanel({ albumId }) {
? ` · expires ${new Date(s.expires_at).toLocaleDateString()}` ? ` · expires ${new Date(s.expires_at).toLocaleDateString()}`
: ' · never expires'} : ' · 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> </span>
</div> </div>
<div className="row"> <div className="row">
@@ -320,9 +323,6 @@ export default function AlbumPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [detail, setDetail] = useState(null) const [detail, setDetail] = useState(null)
const [error, setError] = 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( const load = useCallback(
() => api(`/api/albums/${id}`).then(setDetail).catch((e) => setError(e.message)), () => 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 ready = (detail?.photos ?? []).filter((p) => p.status === 'ready')
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(ready) const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(ready)
const lightboxIndex = ready.findIndex((p) => p.id === lightboxId) const lightbox = useLightbox(ready)
// 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])
if (error) return <p className="error">{error}</p> if (error) return <p className="error">{error}</p>
if (!detail) return <p className="muted">Loading</p> if (!detail) return <p className="muted">Loading</p>
@@ -379,7 +372,7 @@ export default function AlbumPage() {
const removePhoto = async (photoId) => { const removePhoto = async (photoId) => {
if (!confirm('Delete this photo?')) return if (!confirm('Delete this photo?')) return
setLightboxId(null) lightbox.close()
await api(`/api/photos/${photoId}`, { method: 'DELETE' }) await api(`/api/photos/${photoId}`, { method: 'DELETE' })
load() load()
} }
@@ -436,15 +429,20 @@ export default function AlbumPage() {
<Gallery <Gallery
photos={ready} photos={ready}
onOpen={(i) => setLightboxId(ready[i].id)} onOpen={lightbox.openAt}
selected={selected} selected={selected}
onToggleSelect={toggle} onToggleSelect={toggle}
overlay={(p) => { overlay={(p) => {
const avg = avgRating(p.id) const avg = avgRating(p.id)
const tagCount = feedback[p.id]?.tags.length || 0 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 ( return (
<div className="g-overlay"> <div className="g-overlay">
{accepts > 0 && <span>👍 {accepts}</span>}
{rejects > 0 && <span>👎 {rejects}</span>}
{avg !== null && <span> {avg.toFixed(1)}</span>} {avg !== null && <span> {avg.toFixed(1)}</span>}
{tagCount > 0 && <span># {tagCount}</span>} {tagCount > 0 && <span># {tagCount}</span>}
</div> </div>
@@ -460,26 +458,34 @@ export default function AlbumPage() {
total={ready.length} total={ready.length}
selectedBytes={selectedBytes} selectedBytes={selectedBytes}
totalBytes={totalBytes} totalBytes={totalBytes}
onSelectAll={selectAll} onSelectAll={() => selectAll(ready)}
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`)}
/> />
{lightboxIndex >= 0 && ( {lightbox.index >= 0 && (
<Lightbox <Lightbox
photos={ready} photos={ready}
index={lightboxIndex} index={lightbox.index}
onClose={() => setLightboxId(null)} onClose={lightbox.close}
onNav={(i) => setLightboxId(ready[i].id)} onNav={lightbox.openAt}
actions={[
{ keys: ['s'], help: ['S', 'select for download'], run: (p) => toggle(p.id) },
]}
footer={(p) => { footer={(p) => {
const fb = feedback[p.id] || { ratings: [], tags: [] } const fb = feedback[p.id] || { ratings: [], verdicts: [], tags: [] }
return ( return (
<div className="admin-footer"> <div className="admin-footer">
<div className="feedback"> <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> <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) => ( {fb.ratings.map((r, i) => (
<span key={`r${i}`} className="feedback-item"> <span key={`r${i}`} className="feedback-item">
{r.share_label || 'client'}: <Stars value={r.rating} small /> {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 { useParams } from 'react-router-dom'
import { api, postDownload } from '../api' import { api, postDownload } from '../api'
import Gallery from '../components/Gallery' import Gallery from '../components/Gallery'
@@ -6,18 +6,43 @@ import Lightbox from '../components/Lightbox'
import SelectionBar from '../components/SelectionBar' import SelectionBar from '../components/SelectionBar'
import Stars from '../components/Stars' import Stars from '../components/Stars'
import TagEditor from '../components/TagEditor' import TagEditor from '../components/TagEditor'
import Thumbs from '../components/Thumbs'
import useLightbox from '../useLightbox'
import useSelection from '../useSelection' 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() { export default function SharePage() {
const { token } = useParams() const { token } = useParams()
const [view, setView] = useState(null) const [view, setView] = useState(null)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [unlockError, setUnlockError] = useState(null) const [unlockError, setUnlockError] = useState(null)
const [lightbox, setLightbox] = useState(-1) const [filter, setFilter] = useState('all')
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(
view?.photos ?? [], 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( const load = useCallback(
() => api(`/api/share/${token}`).then(setView).catch((e) => setError(e.message)), () => api(`/api/share/${token}`).then(setView).catch((e) => setError(e.message)),
@@ -45,29 +70,75 @@ export default function SharePage() {
})) }))
} }
const setRating = async (photo, rating) => { // Optimistic write: patch local state, PUT, reload from the server on
patchPhoto(photo.id, { my_rating: rating || null }) // failure to undo the patch.
const saveFeedback = async (photo, patch, endpoint, body) => {
patchPhoto(photo.id, patch)
try { try {
await api(`/api/share/${token}/photos/${photo.id}/rating`, { await api(`/api/share/${token}/photos/${photo.id}/${endpoint}`, { method: 'PUT', body })
method: 'PUT',
body: { rating },
})
} catch { } catch {
load() 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) => { const keyActions = [
patchPhoto(photo.id, { my_tags: tags }) { keys: ['p'], help: ['P', 'accept and go to next'], run: (p) => voteAndAdvance(p, 'accept') },
try { { keys: ['x'], help: ['X', 'reject and go to next'], run: (p) => voteAndAdvance(p, 'reject') },
await api(`/api/share/${token}/photos/${photo.id}/tags`, { {
method: 'PUT', keys: ['u'],
body: { tags }, help: ['U', 'clear accept / reject'],
}) // When clearing hides the photo from the current filter, advance like
} catch { // a vote so the lightbox doesn't just close.
load() 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 (error) return <div className="center-page">{error}</div>
if (!view) return <div className="center-page">Loading</div> if (!view) return <div className="center-page">Loading</div>
@@ -100,49 +171,76 @@ export default function SharePage() {
<h1>{view.album_name}</h1> <h1>{view.album_name}</h1>
{view.album_description && <p className="muted">{view.album_description}</p>} {view.album_description && <p className="muted">{view.album_description}</p>}
<p className="muted"> <p className="muted">
{view.photos.length} photo{view.photos.length === 1 ? '' : 's'} · click a photo to view, {photos.length} photo{photos.length === 1 ? '' : 's'} · click a photo to view, rate and
rate and tag tag · press <kbd>?</kbd> in the viewer for shortcuts
</p> </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> </header>
<main className="page"> <main className="page">
<Gallery <Gallery
photos={view.photos} photos={visible}
onOpen={setLightbox} onOpen={lightbox.openAt}
selected={view.allow_download ? selected : undefined} selected={view.allow_download ? selected : undefined}
onToggleSelect={view.allow_download ? toggle : undefined} onToggleSelect={view.allow_download ? toggle : undefined}
overlay={(p) => 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"> <div className="g-overlay">
{p.my_verdict && <span>{p.my_verdict === 'accept' ? '👍' : '👎'}</span>}
{p.my_rating && <span> {p.my_rating}</span>} {p.my_rating && <span> {p.my_rating}</span>}
{p.my_tags.length > 0 && <span># {p.my_tags.length}</span>} {p.my_tags.length > 0 && <span># {p.my_tags.length}</span>}
</div> </div>
) : null ) : null
} }
/> />
{view.photos.length === 0 && ( {photos.length === 0 && (
<p className="center-page muted">Nothing here yet check back soon.</p> <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> </main>
{view.allow_download && ( {view.allow_download && (
<SelectionBar <SelectionBar
count={selected.size} count={visibleSelected.length}
total={view.photos.length} total={visible.length}
selectedBytes={selectedBytes} selectedBytes={sumBytes(visibleSelected)}
totalBytes={totalBytes} totalBytes={sumBytes(visible)}
onSelectAll={selectAll} onSelectAll={() => selectAll(visible)}
onClear={clear} onClear={clear}
onDownload={() => postDownload(`/api/share/${token}/zip`, [...selected].join(','))} onDownload={() =>
onDownloadAll={() => postDownload(`/api/share/${token}/zip`)} 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 <Lightbox
photos={view.photos} photos={visible}
index={lightbox} index={lightbox.index}
onClose={() => setLightbox(-1)} onClose={lightbox.close}
onNav={setLightbox} onNav={lightbox.openAt}
actions={keyActions}
footer={(p) => ( footer={(p) => (
<div className="client-footer"> <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)} /> <Stars value={p.my_rating || 0} onChange={(r) => setRating(p, r)} />
<TagEditor tags={p.my_tags} onChange={(tags) => setTags(p, tags)} /> <TagEditor tags={p.my_tags} onChange={(tags) => setTags(p, tags)} />
{view.allow_download && ( {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; 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 */ /* tags */
.tag-editor { .tag-editor {
display: inline-flex; 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 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 clear = () => setSelected(new Set())
const selectedBytes = photos.reduce((sum, p) => sum + (selected.has(p.id) ? p.size_bytes : 0), 0) 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) 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 chrono::{DateTime, Utc};
use serde::Serialize; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
/// Stored as text in Postgres; decoded via TryFrom so an unknown value is a /// 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JobKind { pub enum JobKind {
ProcessPhoto, ProcessPhoto,
+70 -34
View File
@@ -79,12 +79,40 @@ pub struct ShareTag {
pub tag: String, pub tag: String,
} }
#[derive(Serialize)]
pub struct ShareVerdict {
pub share_label: String,
pub verdict: String,
}
#[derive(Serialize, Default)] #[derive(Serialize, Default)]
pub struct PhotoFeedback { pub struct PhotoFeedback {
pub ratings: Vec<ShareRating>, pub ratings: Vec<ShareRating>,
pub verdicts: Vec<ShareVerdict>,
pub tags: Vec<ShareTag>, 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)] #[derive(Serialize)]
pub struct AlbumDetail { pub struct AlbumDetail {
pub album: Album, pub album: Album,
@@ -108,41 +136,49 @@ pub async fn get_one(
.fetch_all(&state.db) .fetch_all(&state.db)
.await?; .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 mut feedback: HashMap<Uuid, PhotoFeedback> = HashMap::new();
let ratings: Vec<(Uuid, String, i32)> = sqlx::query_as( fold_feedback(&mut feedback, ratings, |f, share_label, rating| {
"select r.photo_id, s.label, r.rating f.ratings.push(ShareRating {
from ratings r join shares s on s.id = r.share_id share_label,
where s.album_id = $1", rating,
) })
.bind(album_id) });
.fetch_all(&state.db) fold_feedback(&mut feedback, verdicts, |f, share_label, verdict| {
.await?; f.verdicts.push(ShareVerdict {
for (photo_id, share_label, rating) in ratings { share_label,
feedback verdict,
.entry(photo_id) })
.or_default() });
.ratings fold_feedback(&mut feedback, tags, |f, share_label, tag| {
.push(ShareRating { f.tags.push(ShareTag { share_label, tag })
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 });
}
Ok(Json(AlbumDetail { Ok(Json(AlbumDetail {
album, album,
+40 -2
View File
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::error::{ApiError, ApiResult}; use crate::error::{ApiError, ApiResult};
use crate::models::{PhotoStatus, Share}; use crate::models::{PhotoStatus, Share, Verdict};
use crate::state::AppState; use crate::state::AppState;
pub async fn load_share(state: &AppState, token: &str) -> Result<Share, ApiError> { pub async fn load_share(state: &AppState, token: &str) -> Result<Share, ApiError> {
@@ -137,6 +137,7 @@ struct ClientPhotoRow {
taken_at: Option<DateTime<Utc>>, taken_at: Option<DateTime<Utc>>,
processed_at: Option<DateTime<Utc>>, processed_at: Option<DateTime<Utc>>,
my_rating: Option<i32>, my_rating: Option<i32>,
my_verdict: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -185,9 +186,10 @@ pub async fn get_share(
let jar = grant_access(&state, jar, share.id); let jar = grant_access(&state, jar, share.id);
let rows: Vec<ClientPhotoRow> = sqlx::query_as( 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 from photos p
left join ratings r on r.photo_id = p.id and r.share_id = $2 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 where p.album_id = $1 and p.status = $3
order by coalesce(p.taken_at, p.created_at), p.filename", 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 }))) 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)] #[derive(Deserialize)]
pub struct TagsBody { pub struct TagsBody {
tags: Vec<String>, tags: Vec<String>,
+4
View File
@@ -100,6 +100,10 @@ pub fn router(state: &AppState) -> Router<AppState> {
"/api/share/{token}/photos/{photo_id}/rating", "/api/share/{token}/photos/{photo_id}/rating",
put(client::set_rating), put(client::set_rating),
) )
.route(
"/api/share/{token}/photos/{photo_id}/verdict",
put(client::set_verdict),
)
.route( .route(
"/api/share/{token}/photos/{photo_id}/tags", "/api/share/{token}/photos/{photo_id}/tags",
put(client::set_tags), put(client::set_tags),
+20 -9
View File
@@ -23,6 +23,8 @@ struct ShareAdminRow {
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
rating_count: i64, rating_count: i64,
tag_count: i64, tag_count: i64,
accept_count: i64,
reject_count: i64,
} }
fn share_json(state: &AppState, row: &ShareAdminRow) -> serde_json::Value { 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, "created_at": row.created_at,
"rating_count": row.rating_count, "rating_count": row.rating_count,
"tag_count": row.tag_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, s.expires_at, s.locked_until, s.created_at,
(select count(*) from ratings r where r.share_id = s.id) as rating_count, rc.rating_count, tc.tag_count, vc.accept_count, vc.reject_count
(select count(*) from tags t where t.share_id = s.id) as tag_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( pub async fn list(
State(state): State<AppState>, State(state): State<AppState>,
Path(album_id): Path<Uuid>, Path(album_id): Path<Uuid>,
) -> ApiResult<Json<Vec<serde_json::Value>>> { ) -> ApiResult<Json<Vec<serde_json::Value>>> {
let rows: Vec<ShareAdminRow> = sqlx::query_as(&format!( 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) .bind(album_id)
.fetch_all(&state.db) .fetch_all(&state.db)
@@ -119,11 +131,10 @@ pub async fn create(
.fetch_one(&state.db) .fetch_one(&state.db)
.await?; .await?;
let row: ShareAdminRow = let row: ShareAdminRow = sqlx::query_as(&format!("{SHARE_SELECT} where s.id = $1"))
sqlx::query_as(&format!("select {SHARE_COLUMNS} from shares s where s.id = $1")) .bind(share_id)
.bind(share_id) .fetch_one(&state.db)
.fetch_one(&state.db) .await?;
.await?;
Ok(Json(share_json(&state, &row))) Ok(Json(share_json(&state, &row)))
} }