1 Commits
Author SHA1 Message Date
nils daadd6a91e 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.
2026-07-18 01:32:27 +02:00
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:
```sh
docker build -t registry.example.com/you/photos:0.5.0 .
docker push 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.1
```
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
helm install photos deploy/chart \
--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 ingress.host=photos.example.com \
--set config.oidcIssuer=https://auth.example.com \
+25 -17
View File
@@ -24,24 +24,21 @@ export default function Gallery({
}) {
const selecting = selected && selected.size > 0
const containerRef = useRef(null)
const [cursor, setCursor] = useState(-1)
// The outline only shows once the keyboard (or the viewer) was actually
// used a fresh page must not present a photo as pre-marked.
const [cursorVisible, setCursorVisible] = useState(false)
// The cursor is always a valid index; `revealed` says whether the user has
// interacted yet. The first key press reveals the cursor where it is
// (nothing pre-marked on a fresh page, first arrow doesn't skip a photo).
const [cursor, setCursor] = useState(0)
const [revealed, setRevealed] = useState(false)
const live = useRef({})
live.current = { cursor, photos, onOpen, onToggleSelect, actions }
live.current = { cursor, revealed, photos, onOpen, onToggleSelect, actions }
useEffect(() => {
if (externalIndex >= 0) {
setCursor(externalIndex)
setCursorVisible(true)
setRevealed(true)
}
}, [externalIndex])
useEffect(() => {
if (keyboard && cursor < 0 && photos.length > 0) setCursor(0)
}, [keyboard, cursor, photos.length])
useEffect(() => {
if (cursor >= photos.length) setCursor(Math.max(0, photos.length - 1))
}, [cursor, photos.length])
@@ -58,37 +55,48 @@ export default function Gallery({
if (e.metaKey || e.ctrlKey || e.altKey) return
const s = live.current
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 columns = el ? getComputedStyle(el).gridTemplateColumns.split(' ').length : 1
const count = s.photos.length
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 === 'ArrowDown') next = s.cursor < 0 ? 0 : 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 === 'ArrowDown') next = Math.min(count - 1, s.cursor + columns)
else if (e.key === 'ArrowUp') next = Math.max(0, s.cursor - columns)
else if (e.key === ' ') {
if (photo) {
e.preventDefault()
setRevealed(true)
s.onOpen?.(s.cursor)
}
return
} else if (e.key === 'Enter') {
if (photo && s.onToggleSelect) {
e.preventDefault()
setRevealed(true)
s.onToggleSelect(photo.id)
}
return
} else {
const key = e.key.toLowerCase()
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
}
e.preventDefault()
setRevealed(true)
// The first arrow press only reveals the cursor; movement starts with
// the second.
if (s.revealed) {
setCursor(next)
setCursorVisible(true)
el?.children[next]?.scrollIntoView({ block: 'nearest' })
} else {
el?.children[s.cursor]?.scrollIntoView({ block: 'nearest' })
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
@@ -101,7 +109,7 @@ export default function Gallery({
return (
<div
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)}
>
<img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} />