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
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useRef, useState } from 'react'
import { ACCEPT_GLYPH, REJECT_GLYPH } from './components/Thumbs'
// Shared culling interaction for a lightbox over a (possibly filtered) photo
// list: the keyboard table (P/X/U, stars, select), swipe gestures, the
// action toast, and the modal fade-out after voting the last photo.
export default function useCulling({
visible,
lightbox,
setVerdict,
setRating,
toggle,
canSelect = true,
}) {
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), [])
const [fading, setFading] = useState(false)
useEffect(() => {
if (lightbox.index < 0) setFading(false)
}, [lightbox.index])
// Keyboard votes stay on the photo (the footer controls show the result);
// only touch gestures advance, where the fly-off animation carries the
// context.
const vote = (photo, verdict) => {
setVerdict(photo, verdict)
showFlash(
verdict === 'accept' ? `${ACCEPT_GLYPH} ${photo.filename}` : `${REJECT_GLYPH} ${photo.filename}`,
)
}
const voteAndAdvance = (photo, verdict) => {
const next = visible[lightbox.index + 1]
setVerdict(photo, verdict)
showFlash(
verdict === 'accept'
? `${ACCEPT_GLYPH} ${photo.filename}`
: verdict === 'reject'
? `${REJECT_GLYPH} ${photo.filename}`
: `${photo.filename} cleared`,
)
if (next) lightbox.show(next.id)
else setFading(true)
}
const keyActions = [
{ keys: ['p'], help: ['P', 'accept'], run: (p) => vote(p, 'accept') },
{ keys: ['x'], help: ['X', 'reject'], run: (p) => vote(p, 'reject') },
{ keys: ['u'], help: ['U', 'clear accept / reject'], run: (p) => setVerdict(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) },
...(canSelect
? [{ keys: ['s'], help: ['S', 'select for download'], run: (p) => toggle(p.id) }]
: []),
]
return {
flash,
keyActions,
lightboxProps: {
actions: keyActions,
gestures: {
up: (p) => voteAndAdvance(p, 'accept'),
down: (p) => voteAndAdvance(p, 'reject'),
},
closing: fading,
onClosed: () => {
setFading(false)
lightbox.close()
},
},
}
}