From 30d0b3064e88d7d766ad2be674d71133b93f302f Mon Sep 17 00:00:00 2001 From: nils Date: Fri, 17 Jul 2026 15:55:46 +0200 Subject: [PATCH] Client accept/reject votes with keyboard-driven culling - 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 --- README.md | 21 +-- frontend/src/components/Lightbox.jsx | 97 ++++++++++++-- frontend/src/components/Thumbs.jsx | 24 ++++ frontend/src/pages/AlbumPage.jsx | 50 ++++---- frontend/src/pages/SharePage.jsx | 183 +++++++++++++++++++++------ frontend/src/styles.css | 124 ++++++++++++++++++ frontend/src/useLightbox.js | 21 +++ frontend/src/useSelection.js | 5 +- migrations/0004_verdicts.sql | 13 ++ src/models.rs | 21 ++- src/routes/albums.rs | 104 ++++++++++----- src/routes/client.rs | 42 +++++- src/routes/mod.rs | 4 + src/routes/shares.rs | 29 +++-- 14 files changed, 612 insertions(+), 126 deletions(-) create mode 100644 frontend/src/components/Thumbs.jsx create mode 100644 frontend/src/useLightbox.js create mode 100644 migrations/0004_verdicts.sql diff --git a/README.md b/README.md index 4742380..bc230b5 100644 --- a/README.md +++ b/README.md @@ -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 (1–5 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 (1–5 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 diff --git a/frontend/src/components/Lightbox.jsx b/frontend/src/components/Lightbox.jsx index 63ebc84..95fde5e 100644 --- a/frontend/src/components/Lightbox.jsx +++ b/frontend/src/components/Lightbox.jsx @@ -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 }) { {index + 1} / {photos.length} + @@ -67,6 +127,25 @@ export default function Lightbox({ photos, index, onClose, onNav, footer }) { {footer(photo)} )} + {showHelp && ( +
{ + e.stopPropagation() + setShowHelp(false) + }} + > +
+

Keyboard shortcuts

+ {[...(actions?.map((a) => a.help) || []), ...BASE_SHORTCUTS].map(([keys, label]) => ( +
+ {keys} + {label} +
+ ))} +
+
+ )} ) } diff --git a/frontend/src/components/Thumbs.jsx b/frontend/src/components/Thumbs.jsx new file mode 100644 index 0000000..6a4159f --- /dev/null +++ b/frontend/src/components/Thumbs.jsx @@ -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) => ( + + ) + return ( + + {thumb('accept', 'πŸ‘', 'Accept (P)')} + {thumb('reject', 'πŸ‘Ž', 'Reject (X)')} + + ) +} diff --git a/frontend/src/pages/AlbumPage.jsx b/frontend/src/pages/AlbumPage.jsx index 816b742..caa56a3 100644 --- a/frontend/src/pages/AlbumPage.jsx +++ b/frontend/src/pages/AlbumPage.jsx @@ -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}
@@ -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

{error}

if (!detail) return

Loading…

@@ -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() { 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 (
+ {accepts > 0 && πŸ‘ {accepts}} + {rejects > 0 && πŸ‘Ž {rejects}} {avg !== null && β˜… {avg.toFixed(1)}} {tagCount > 0 && # {tagCount}}
@@ -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 && ( 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 (
- {fb.ratings.length === 0 && fb.tags.length === 0 && ( + {fb.ratings.length === 0 && fb.verdicts.length === 0 && fb.tags.length === 0 && ( No client feedback yet )} + {fb.verdicts.map((v, i) => ( + + {v.share_label || 'client'}: + + ))} {fb.ratings.map((r, i) => ( {r.share_label || 'client'}: diff --git a/frontend/src/pages/SharePage.jsx b/frontend/src/pages/SharePage.jsx index 6cba6d1..644a516 100644 --- a/frontend/src/pages/SharePage.jsx +++ b/frontend/src/pages/SharePage.jsx @@ -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: ['1–5', '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
{error}
if (!view) return
Loading…
@@ -100,49 +171,76 @@ export default function SharePage() {

{view.album_name}

{view.album_description &&

{view.album_description}

}

- {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 ? in the viewer for shortcuts

+ {photos.length > 0 && ( +
+ {FILTERS.map((f) => ( + + ))} +
+ )}
- p.my_rating || p.my_tags.length > 0 ? ( + p.my_verdict || p.my_rating || p.my_tags.length > 0 ? (
+ {p.my_verdict && {p.my_verdict === 'accept' ? 'πŸ‘' : 'πŸ‘Ž'}} {p.my_rating && β˜… {p.my_rating}} {p.my_tags.length > 0 && # {p.my_tags.length}}
) : null } /> - {view.photos.length === 0 && ( + {photos.length === 0 && (

Nothing here yet β€” check back soon.

)} + {photos.length > 0 && visible.length === 0 && ( +

No photos match this filter.

+ )}
{view.allow_download && ( 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 && ( setLightbox(-1)} - onNav={setLightbox} + photos={visible} + index={lightbox.index} + onClose={lightbox.close} + onNav={lightbox.openAt} + actions={keyActions} footer={(p) => (
+ setVerdict(p, v)} /> setRating(p, r)} /> setTags(p, tags)} /> {view.allow_download && ( @@ -164,6 +262,11 @@ export default function SharePage() { )} /> )} + {flash && ( +
+ {flash.text} +
+ )} ) } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 09564ac..3f558d4 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; diff --git a/frontend/src/useLightbox.js b/frontend/src/useLightbox.js new file mode 100644 index 0000000..0d4e202 --- /dev/null +++ b/frontend/src/useLightbox.js @@ -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), + } +} diff --git a/frontend/src/useSelection.js b/frontend/src/useSelection.js index 8e0cb34..8210bac 100644 --- a/frontend/src/useSelection.js +++ b/frontend/src/useSelection.js @@ -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) diff --git a/migrations/0004_verdicts.sql b/migrations/0004_verdicts.sql new file mode 100644 index 0000000..d9828b9 --- /dev/null +++ b/migrations/0004_verdicts.sql @@ -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); diff --git a/src/models.rs b/src/models.rs index c7c1102..999c8d8 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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 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, diff --git a/src/routes/albums.rs b/src/routes/albums.rs index 36062d1..a0c24e4 100644 --- a/src/routes/albums.rs +++ b/src/routes/albums.rs @@ -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, + pub verdicts: Vec, pub tags: Vec, } +async fn feedback_rows( + db: &sqlx::PgPool, + sql: &str, + album_id: Uuid, +) -> Result, 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( + feedback: &mut HashMap, + 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::( + &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::( + &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::( + &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 = 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, diff --git a/src/routes/client.rs b/src/routes/client.rs index 7342345..306b09b 100644 --- a/src/routes/client.rs +++ b/src/routes/client.rs @@ -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 { @@ -137,6 +137,7 @@ struct ClientPhotoRow { taken_at: Option>, processed_at: Option>, my_rating: Option, + my_verdict: Option, } #[derive(Serialize)] @@ -185,9 +186,10 @@ pub async fn get_share( let jar = grant_access(&state, jar, share.id); let rows: Vec = 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, +} + +pub async fn set_verdict( + State(state): State, + Path((token, photo_id)): Path<(String, Uuid)>, + jar: SignedCookieJar, + Json(body): Json, +) -> ApiResult> { + 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, diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 0b1113e..4c64249 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -100,6 +100,10 @@ pub fn router(state: &AppState) -> Router { "/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), diff --git a/src/routes/shares.rs b/src/routes/shares.rs index 1e35ffc..c798062 100644 --- a/src/routes/shares.rs +++ b/src/routes/shares.rs @@ -23,6 +23,8 @@ struct ShareAdminRow { created_at: DateTime, 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, Path(album_id): Path, ) -> ApiResult>> { let rows: Vec = 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,10 @@ 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))) }