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:
@@ -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: ['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) }]
|
||||
: []),
|
||||
]
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user