ci / docker (push) Successful in 13m22s
- 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
152 lines
4.7 KiB
React
152 lines
4.7 KiB
React
import { useEffect, useRef, useState } from 'react'
|
||
import { imgUrl } from '../api'
|
||
|
||
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(() => {
|
||
// 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', handler)
|
||
return () => window.removeEventListener('keydown', handler)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
document.body.style.overflow = 'hidden'
|
||
return () => {
|
||
document.body.style.overflow = ''
|
||
}
|
||
}, [])
|
||
|
||
if (!photo) return null
|
||
|
||
return (
|
||
<div className="lightbox" onClick={onClose}>
|
||
<div className="lb-top" onClick={(e) => e.stopPropagation()}>
|
||
<span className="lb-name">{photo.filename}</span>
|
||
<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>
|
||
</div>
|
||
<button
|
||
className="lb-nav lb-prev"
|
||
disabled={index === 0}
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onNav(index - 1)
|
||
}}
|
||
>
|
||
‹
|
||
</button>
|
||
<div className="lb-stage">
|
||
<img
|
||
className="lb-img"
|
||
src={imgUrl(photo, 'preview')}
|
||
alt={photo.filename}
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
</div>
|
||
<button
|
||
className="lb-nav lb-next"
|
||
disabled={index === photos.length - 1}
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onNav(index + 1)
|
||
}}
|
||
>
|
||
›
|
||
</button>
|
||
{footer && (
|
||
<div className="lb-footer" onClick={(e) => e.stopPropagation()}>
|
||
{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>
|
||
)
|
||
}
|