Owner feedback, server-side aggregation, grid keyboard culling, XMP sources
ci / docker (push) Successful in 11m52s
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:
@@ -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: ['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()
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user