Grid cursor: always-valid index with reveal-on-first-key
ci / docker (push) Successful in 16s

First key press (arrow or action) targets photo 1 and shows the cursor
without moving it; movement starts with the second arrow. Replaces the
-1 sentinel plus visibility flag double-state.
This commit is contained in:
2026-07-18 01:32:27 +02:00
parent cb79da89fb
commit daadd6a91e
2 changed files with 30 additions and 22 deletions
+3 -3
View File
@@ -112,8 +112,8 @@ The prebuilt image is at `git.draic.info/nils/photos` (single image contains
`server`, `worker`, and the built frontend). To build your own instead: `server`, `worker`, and the built frontend). To build your own instead:
```sh ```sh
docker build -t registry.example.com/you/photos:0.5.0 . docker build -t registry.example.com/you/photos:0.5.1 .
docker push registry.example.com/you/photos:0.5.0 docker push registry.example.com/you/photos:0.5.1
``` ```
Install the chart, pointing it at your existing Postgres and S3: Install the chart, pointing it at your existing Postgres and S3:
@@ -121,7 +121,7 @@ Install the chart, pointing it at your existing Postgres and S3:
```sh ```sh
helm install photos deploy/chart \ helm install photos deploy/chart \
--set image.repository=git.draic.info/nils/photos \ --set image.repository=git.draic.info/nils/photos \
--set image.tag=0.5.0 \ --set image.tag=0.5.1 \
--set publicUrl=https://photos.example.com \ --set publicUrl=https://photos.example.com \
--set ingress.host=photos.example.com \ --set ingress.host=photos.example.com \
--set config.oidcIssuer=https://auth.example.com \ --set config.oidcIssuer=https://auth.example.com \
+27 -19
View File
@@ -24,24 +24,21 @@ export default function Gallery({
}) { }) {
const selecting = selected && selected.size > 0 const selecting = selected && selected.size > 0
const containerRef = useRef(null) const containerRef = useRef(null)
const [cursor, setCursor] = useState(-1) // The cursor is always a valid index; `revealed` says whether the user has
// The outline only shows once the keyboard (or the viewer) was actually // interacted yet. The first key press reveals the cursor where it is
// used a fresh page must not present a photo as pre-marked. // (nothing pre-marked on a fresh page, first arrow doesn't skip a photo).
const [cursorVisible, setCursorVisible] = useState(false) const [cursor, setCursor] = useState(0)
const [revealed, setRevealed] = useState(false)
const live = useRef({}) const live = useRef({})
live.current = { cursor, photos, onOpen, onToggleSelect, actions } live.current = { cursor, revealed, photos, onOpen, onToggleSelect, actions }
useEffect(() => { useEffect(() => {
if (externalIndex >= 0) { if (externalIndex >= 0) {
setCursor(externalIndex) setCursor(externalIndex)
setCursorVisible(true) setRevealed(true)
} }
}, [externalIndex]) }, [externalIndex])
useEffect(() => {
if (keyboard && cursor < 0 && photos.length > 0) setCursor(0)
}, [keyboard, cursor, photos.length])
useEffect(() => { useEffect(() => {
if (cursor >= photos.length) setCursor(Math.max(0, photos.length - 1)) if (cursor >= photos.length) setCursor(Math.max(0, photos.length - 1))
}, [cursor, photos.length]) }, [cursor, photos.length])
@@ -58,37 +55,48 @@ export default function Gallery({
if (e.metaKey || e.ctrlKey || e.altKey) return if (e.metaKey || e.ctrlKey || e.altKey) return
const s = live.current const s = live.current
if (s.photos.length === 0) return if (s.photos.length === 0) return
const photo = s.cursor >= 0 ? s.photos[s.cursor] : null const photo = s.photos[s.cursor]
const el = containerRef.current const el = containerRef.current
const columns = el ? getComputedStyle(el).gridTemplateColumns.split(' ').length : 1 const columns = el ? getComputedStyle(el).gridTemplateColumns.split(' ').length : 1
const count = s.photos.length const count = s.photos.length
let next = null let next = null
if (e.key === 'ArrowRight') next = Math.min(count - 1, Math.max(0, s.cursor + 1)) if (e.key === 'ArrowRight') next = Math.min(count - 1, s.cursor + 1)
else if (e.key === 'ArrowLeft') next = Math.max(0, s.cursor - 1) else if (e.key === 'ArrowLeft') next = Math.max(0, s.cursor - 1)
else if (e.key === 'ArrowDown') next = s.cursor < 0 ? 0 : Math.min(count - 1, s.cursor + columns) else if (e.key === 'ArrowDown') next = Math.min(count - 1, s.cursor + columns)
else if (e.key === 'ArrowUp') next = s.cursor < 0 ? 0 : Math.max(0, s.cursor - columns) else if (e.key === 'ArrowUp') next = Math.max(0, s.cursor - columns)
else if (e.key === ' ') { else if (e.key === ' ') {
if (photo) { if (photo) {
e.preventDefault() e.preventDefault()
setRevealed(true)
s.onOpen?.(s.cursor) s.onOpen?.(s.cursor)
} }
return return
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
if (photo && s.onToggleSelect) { if (photo && s.onToggleSelect) {
e.preventDefault() e.preventDefault()
setRevealed(true)
s.onToggleSelect(photo.id) s.onToggleSelect(photo.id)
} }
return return
} else { } else {
const key = e.key.toLowerCase() const key = e.key.toLowerCase()
const action = s.actions?.find((a) => a.keys.includes(key)) const action = s.actions?.find((a) => a.keys.includes(key))
if (action && photo) action.run(photo, key) if (action && photo) {
setRevealed(true)
action.run(photo, key)
}
return return
} }
e.preventDefault() e.preventDefault()
setCursor(next) setRevealed(true)
setCursorVisible(true) // The first arrow press only reveals the cursor; movement starts with
el?.children[next]?.scrollIntoView({ block: 'nearest' }) // the second.
if (s.revealed) {
setCursor(next)
el?.children[next]?.scrollIntoView({ block: 'nearest' })
} else {
el?.children[s.cursor]?.scrollIntoView({ block: 'nearest' })
}
} }
window.addEventListener('keydown', handler) window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler)
@@ -101,7 +109,7 @@ export default function Gallery({
return ( return (
<div <div
key={p.id} key={p.id}
className={`g-item${isSelected ? ' selected' : ''}${cursorVisible && index === cursor ? ' focused' : ''}`} className={`g-item${isSelected ? ' selected' : ''}${revealed && index === cursor ? ' focused' : ''}`}
onClick={() => onOpen && onOpen(index)} onClick={() => onOpen && onOpen(index)}
> >
<img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} /> <img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} />