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
87 lines
2.6 KiB
JavaScript
87 lines
2.6 KiB
JavaScript
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: ['1–5', '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()
|
||
},
|
||
},
|
||
}
|
||
}
|