- verdicts table (per link, like ratings), PUT verdict endpoint, typed Verdict enum - share page: thumbs up/down, verdict filter with counts, view-scoped selection bar - lightbox: per-page shortcut table (P/X/U, 1-5/0, S), ? help overlay, action toast when a keyboard vote auto-advances - shared useLightbox hook; single keydown subscription reading live state via ref - album view: per-link thumbs and vote counts; share list shows accept/reject totals - feedback queries deduped and run concurrently; verdict counts in one scan
This commit is contained in:
@@ -1,18 +1,71 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { imgUrl } from '../api'
|
||||
|
||||
export default function Lightbox({ photos, index, onClose, onNav, footer }) {
|
||||
const BASE_SHORTCUTS = [
|
||||
['← / →', 'previous / next photo'],
|
||||
['Space', 'next photo (Shift+Space back)'],
|
||||
['?', 'show / hide shortcuts'],
|
||||
['Esc', 'close'],
|
||||
]
|
||||
|
||||
// `actions` defines the page's shortcuts as one table — display and dispatch
|
||||
// come from the same entry, so the help overlay can't drift from behavior:
|
||||
// { keys: ['p'], help: ['P', 'accept…'], run: (photo, key) => … }.
|
||||
// Keys fire only outside text inputs and while the help overlay is closed.
|
||||
export default function Lightbox({ photos, index, onClose, onNav, footer, actions }) {
|
||||
const photo = photos[index]
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
|
||||
// Handlers and view state live in a ref, updated every render, so the
|
||||
// window listener is attached once yet always dispatches against current
|
||||
// values — re-subscribing per render leaves a gap until effects re-run in
|
||||
// which a fast second keystroke hits a stale closure (and e.g. re-votes
|
||||
// the previous photo).
|
||||
const live = useRef({})
|
||||
live.current = { index, count: photos.length, photo, onClose, onNav, actions, showHelp }
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'ArrowRight' && index < photos.length - 1) onNav(index + 1)
|
||||
if (e.key === 'ArrowLeft' && index > 0) onNav(index - 1)
|
||||
// Only text entry captures keys (Escape leaves the field); focus on a
|
||||
// checkbox or button must not disable lightbox navigation.
|
||||
const isTyping = (el) =>
|
||||
el.tagName === 'TEXTAREA' ||
|
||||
el.isContentEditable ||
|
||||
(el.tagName === 'INPUT' && !['checkbox', 'radio', 'button'].includes(el.type))
|
||||
const handler = (e) => {
|
||||
const s = live.current
|
||||
if (isTyping(e.target)) {
|
||||
if (e.key === 'Escape') e.target.blur()
|
||||
return
|
||||
}
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return
|
||||
if (e.key === 'Escape') {
|
||||
if (s.showHelp) setShowHelp(false)
|
||||
else s.onClose()
|
||||
return
|
||||
}
|
||||
if (e.key === '?') {
|
||||
setShowHelp((h) => !h)
|
||||
return
|
||||
}
|
||||
// With the help overlay up, keys must not act on the photo behind it.
|
||||
if (s.showHelp) return
|
||||
if (e.key === 'ArrowRight' || (e.key === ' ' && !e.shiftKey)) {
|
||||
e.preventDefault()
|
||||
if (s.index < s.count - 1) s.onNav(s.index + 1)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowLeft' || (e.key === ' ' && e.shiftKey)) {
|
||||
e.preventDefault()
|
||||
if (s.index > 0) s.onNav(s.index - 1)
|
||||
return
|
||||
}
|
||||
const key = e.key.toLowerCase()
|
||||
const action = s.actions?.find((a) => a.keys.includes(key))
|
||||
if (action && s.photo) action.run(s.photo, key)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [index, photos.length, onClose, onNav])
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden'
|
||||
@@ -30,6 +83,13 @@ export default function Lightbox({ photos, index, onClose, onNav, footer }) {
|
||||
<span className="lb-count">
|
||||
{index + 1} / {photos.length}
|
||||
</span>
|
||||
<button
|
||||
className="lb-btn"
|
||||
onClick={() => setShowHelp((h) => !h)}
|
||||
title="Keyboard shortcuts (?)"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
<button className="lb-btn" onClick={onClose} title="Close (Esc)">
|
||||
✕
|
||||
</button>
|
||||
@@ -67,6 +127,25 @@ export default function Lightbox({ photos, index, onClose, onNav, footer }) {
|
||||
{footer(photo)}
|
||||
</div>
|
||||
)}
|
||||
{showHelp && (
|
||||
<div
|
||||
className="lb-help"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowHelp(false)
|
||||
}}
|
||||
>
|
||||
<div className="lb-help-card">
|
||||
<h3>Keyboard shortcuts</h3>
|
||||
{[...(actions?.map((a) => a.help) || []), ...BASE_SHORTCUTS].map(([keys, label]) => (
|
||||
<div key={keys} className="lb-help-row">
|
||||
<kbd>{keys}</kbd>
|
||||
<span className="muted">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user