Owner feedback, server-side aggregation, grid keyboard culling, XMP sources
ci / docker (push) Successful in 11m52s

- photographer verdict/rating on photos (migration 0005), PUT endpoints
  returning the fresh per-photo aggregate; FeedbackAggregate in models is
  the single home of the merge policy (max rating, accept beats reject,
  owner counts), used by album detail, XMP export and vote responses
- feedback rows carry share_id; per-client filter pills (incl. 'you')
  scope filters/counts/overlays by id — labels are display-only
- XMP export modal with per-source checkboxes (?shares=…&own=…), share
  validation, id dedup, and basename merging so RAW+JPEG pairs share one
  sidecar instead of losing feedback to an unmatchable name
- grid keyboard culling: arrow cursor (clamped, outline after first use),
  Space opens / closes the viewer, Enter/S toggle select, P/X/U and star
  keys act on the cursor photo; keyboard votes never auto-navigate
- lightbox freezes the visible list while open, so voting a photo out of
  the active filter no longer closes the viewer mid-run
- perf: memoized per-scope derivation map, content-visibility on grid
  cells, lightweight /pending poll decoupled from vote patches, aggregate
  recompute in one SQL statement, out-of-order response guard
- structure: AlbumPage split into components (Modal, UploadZone +
  ActivityOverlay with failed-count, SharesPanel, XmpModal), shared
  useEscape with typing guard, expiry year guard in endOfDayIso
This commit is contained in:
2026-07-18 01:15:15 +02:00
parent 46dd8df55c
commit cb79da89fb
20 changed files with 1988 additions and 764 deletions
+22 -80
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom'
import { api, postDownload } from '../api'
import Gallery from '../components/Gallery'
@@ -6,14 +6,15 @@ 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 Thumbs, { ACCEPT_GLYPH, REJECT_GLYPH } from '../components/Thumbs'
import useCulling from '../useCulling'
import useLightbox from '../useLightbox'
import useSelection from '../useSelection'
const FILTERS = [
{ key: 'all', label: 'All' },
{ key: 'accept', label: '👍' },
{ key: 'reject', label: '👎' },
{ key: 'accept', label: ACCEPT_GLYPH },
{ key: 'reject', label: REJECT_GLYPH },
{ key: 'undecided', label: 'Undecided' },
]
@@ -43,12 +44,6 @@ export default function SharePage() {
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)
// Voting the last photo ends the run: fade the modal out over the gallery
// instead of cutting hard.
const [lightboxFading, setLightboxFading] = useState(false)
useEffect(() => {
if (lightbox.index < 0) setLightboxFading(false)
}, [lightbox.index])
const load = useCallback(
() => api(`/api/share/${token}`).then(setView).catch((e) => setError(e.message)),
@@ -92,61 +87,14 @@ export default function SharePage() {
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)
// Last photo voted: the run is done — fade back to the gallery.
else setLightboxFading(true)
}
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) }]
: []),
]
const culling = useCulling({
visible: lightbox.view,
lightbox,
setVerdict,
setRating,
toggle,
canSelect: !!view?.allow_download,
})
if (error) return <div className="center-page">{error}</div>
if (!view) return <div className="center-page">Loading</div>
@@ -206,11 +154,14 @@ export default function SharePage() {
onToggleSelect={
view.allow_download ? (id, shift) => toggle(id, shift, visible) : undefined
}
keyboard={lightbox.index < 0}
externalIndex={lightbox.index}
actions={culling.keyActions}
overlay={(p) =>
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_rating && <span>{'★'.repeat(p.my_rating)}</span>}
{p.my_tags.length > 0 && <span># {p.my_tags.length}</span>}
</div>
) : null
@@ -245,20 +196,11 @@ export default function SharePage() {
)}
{lightbox.index >= 0 && (
<Lightbox
photos={visible}
photos={lightbox.view}
index={lightbox.index}
onClose={lightbox.close}
onNav={lightbox.openAt}
actions={keyActions}
closing={lightboxFading}
onClosed={() => {
setLightboxFading(false)
lightbox.close()
}}
gestures={{
up: (p) => voteAndAdvance(p, 'accept'),
down: (p) => voteAndAdvance(p, 'reject'),
}}
{...culling.lightboxProps}
footer={(p) => (
<div className="client-footer">
<Thumbs value={p.my_verdict} onChange={(v) => setVerdict(p, v)} />
@@ -283,9 +225,9 @@ export default function SharePage() {
)}
/>
)}
{flash && (
<div key={flash.key} className="action-flash">
{flash.text}
{culling.flash && (
<div key={culling.flash.key} className="action-flash">
{culling.flash.text}
</div>
)}
</>