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 && (
+
- {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