Mobile: real swipe physics, pinch zoom, touch-first lightbox
ci / docker (push) Successful in 17s

- swipe sideways to browse (track follows the finger, FLIP settle),
  flick up/down to vote Tinder-style with ghost fly-off and the next
  photo zooming in behind; votes and navigation commit synchronously on
  release — animations are pure decoration, so closing or keypresses
  mid-animation can't drop or duplicate an action
- two fingers pinch-zoom the photo (springs back, never votes)
- voting the last photo fades the modal out over the gallery
- gesture engine extracted to useSwipe: per-move motion via direct DOM
  writes (no React render per touchmove), named threshold constants
- touch devices: selection checkmarks always visible, nav arrows yield
  to swiping, finger-sized vote/rating targets, safe-area padding
This commit is contained in:
2026-07-17 17:18:51 +02:00
parent 037b68fc9d
commit 7167af8984
7 changed files with 496 additions and 24 deletions
+103 -10
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { imgUrl } from '../api'
import useSwipe from '../useSwipe'
const BASE_SHORTCUTS = [
['← / →', 'previous / next photo'],
@@ -12,7 +13,21 @@ const BASE_SHORTCUTS = [
// 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 }) {
// `gestures` ({ up, down }) maps vertical touch flicks on the photo — the
// horizontal axis always navigates, so voting and browsing never collide.
// `closing` fades the whole modal out; `onClosed` fires when the fade ends
// (the page then actually unmounts the lightbox).
export default function Lightbox({
photos,
index,
onClose,
onNav,
footer,
actions,
gestures,
closing,
onClosed,
}) {
const photo = photos[index]
const [showHelp, setShowHelp] = useState(false)
@@ -22,7 +37,7 @@ export default function Lightbox({ photos, index, onClose, onNav, footer, action
// 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 }
live.current = { index, count: photos.length, photo, onClose, onNav, actions, showHelp, closing }
useEffect(() => {
// Only text entry captures keys (Escape leaves the field); focus on a
@@ -33,6 +48,7 @@ export default function Lightbox({ photos, index, onClose, onNav, footer, action
(el.tagName === 'INPUT' && !['checkbox', 'radio', 'button'].includes(el.type))
const handler = (e) => {
const s = live.current
if (s.closing) return
if (isTyping(e.target)) {
if (e.key === 'Escape') e.target.blur()
return
@@ -74,10 +90,41 @@ export default function Lightbox({ photos, index, onClose, onNav, footer, action
}
}, [])
const preview = (p) => imgUrl(p, 'preview')
const prevPhoto = photos[index - 1]
const nextPhoto = photos[index + 1]
const swipe = useSwipe({
photo,
index,
count: photos.length,
onNav,
gestures,
hasNext: index < photos.length - 1,
})
// Immediate neighbors are real DOM slides (loaded by definition); warm the
// browser cache a few photos further out so fast swiping never hits the
// network.
useEffect(() => {
for (const i of [index + 2, index + 3, index - 2]) {
if (photos[i]) new Image().src = preview(photos[i])
}
}, [index, photos])
if (!photo) return null
// Did the vote fly off the photo we're still showing (no advance target)?
const exitSelf = swipe.exit && swipe.exit.photo.id === photo.id
return (
<div className="lightbox" onClick={onClose}>
<div
className={`lightbox${closing ? ' lb-closing' : ''}`}
onClick={onClose}
onAnimationEnd={(e) => {
if (e.animationName === 'lb-fade-out') onClosed?.()
}}
>
<div className="lb-top" onClick={(e) => e.stopPropagation()}>
<span className="lb-name">{photo.filename}</span>
<span className="lb-count">
@@ -104,13 +151,59 @@ export default function Lightbox({ photos, index, onClose, onNav, footer, action
>
</button>
<div className="lb-stage">
<img
className="lb-img"
src={imgUrl(photo, 'preview')}
alt={photo.filename}
onClick={(e) => e.stopPropagation()}
/>
<div className="lb-stage" {...swipe.handlers}>
<div
className="lb-track"
ref={swipe.trackRef}
style={swipe.trackStyle}
onTransitionEnd={swipe.onTrackTransitionEnd}
>
{prevPhoto && (
<div key={prevPhoto.id} className="lb-slide" style={{ transform: 'translateX(-100vw)' }}>
<img className="lb-img" src={preview(prevPhoto)} alt="" />
</div>
)}
<div key={photo.id} className="lb-slide" style={{ zIndex: 1 }}>
<img
ref={swipe.imgRef}
className={`lb-img${swipe.exit ? (exitSelf ? ' lb-hidden' : ' lb-enter') : ''}`}
style={swipe.imgStyle}
onTransitionEnd={swipe.onImgTransitionEnd}
src={preview(photo)}
alt={photo.filename}
onClick={(e) => e.stopPropagation()}
/>
</div>
{nextPhoto &&
(swipe.showBehind ? (
<div
key={nextPhoto.id}
ref={swipe.behindRef}
className="lb-slide lb-behind"
style={swipe.behindStyle}
>
<img className="lb-img" src={preview(nextPhoto)} alt="" />
</div>
) : (
<div key={nextPhoto.id} className="lb-slide" style={{ transform: 'translateX(100vw)' }}>
<img className="lb-img" src={preview(nextPhoto)} alt="" />
</div>
))}
</div>
{swipe.exit && (
<div
className="lb-slide lb-ghost"
style={{ '--dy': `${swipe.exit.dy}px`, '--rot': `${swipe.exit.dy / 40}deg` }}
onAnimationEnd={swipe.clearExit}
>
<img
className={`lb-img lb-exit-${swipe.exit.dir}`}
src={preview(swipe.exit.photo)}
alt=""
/>
</div>
)}
{swipe.showBadge && <div ref={swipe.badgeRef} className="lb-flick" />}
</div>
<button
className="lb-nav lb-next"
+5 -2
View File
@@ -1,3 +1,6 @@
export const ACCEPT_GLYPH = '👍'
export const REJECT_GLYPH = '👎'
// Accept/reject vote. `value` is 'accept', 'reject' or null; clicking the
// active thumb clears it. Without onChange it renders read-only.
export default function Thumbs({ value, onChange, small }) {
@@ -17,8 +20,8 @@ export default function Thumbs({ value, onChange, small }) {
)
return (
<span className={`thumbs${small ? ' thumbs-small' : ''}`}>
{thumb('accept', '👍', 'Accept (P)')}
{thumb('reject', '👎', 'Reject (X)')}
{thumb('accept', ACCEPT_GLYPH, 'Accept (P)')}
{thumb('reject', REJECT_GLYPH, 'Reject (X)')}
</span>
)
}
+19 -2
View File
@@ -43,6 +43,12 @@ 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)),
@@ -114,6 +120,8 @@ export default function SharePage() {
: `${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 = [
@@ -171,8 +179,8 @@ export default function SharePage() {
<h1>{view.album_name}</h1>
{view.album_description && <p className="muted">{view.album_description}</p>}
<p className="muted">
{photos.length} photo{photos.length === 1 ? '' : 's'} · click a photo to view, rate and
tag · press <kbd>?</kbd> in the viewer for shortcuts
{photos.length} photo{photos.length === 1 ? '' : 's'} · tap a photo to view, rate and
tag · swipe to accept, to reject · <kbd>?</kbd> shows keyboard shortcuts
</p>
{photos.length > 0 && (
<div className="filter-bar">
@@ -242,6 +250,15 @@ export default function SharePage() {
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'),
}}
footer={(p) => (
<div className="client-footer">
<Thumbs value={p.my_verdict} onChange={(v) => setVerdict(p, v)} />
+124 -1
View File
@@ -451,6 +451,18 @@ progress {
.lb-stage {
flex: 1;
min-height: 0;
position: relative;
overflow: hidden;
/* Swipes are handled in JS; stop the browser from panning/zooming. */
touch-action: none;
}
.lb-track {
position: absolute;
inset: 0;
}
.lb-slide {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
@@ -462,6 +474,79 @@ progress {
object-fit: contain;
cursor: default;
}
.lb-flick {
z-index: 2;
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 5rem;
opacity: 0;
pointer-events: none;
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.6);
}
/* Next photo waiting behind a vote drag, before its zoom-in. */
.lb-behind {
transform: scale(0.22);
opacity: 0;
}
/* Ghost of a just-voted photo flying off; the vote itself is already saved. */
.lb-ghost {
z-index: 3;
pointer-events: none;
}
.lb-exit-up {
animation: lb-exit-up 0.26s ease-in forwards;
}
.lb-exit-down {
animation: lb-exit-down 0.26s ease-in forwards;
}
@keyframes lb-exit-up {
from {
transform: translateY(var(--dy)) rotate(var(--rot));
}
to {
transform: translateY(-120vh) rotate(var(--rot));
opacity: 0;
}
}
@keyframes lb-exit-down {
from {
transform: translateY(var(--dy)) rotate(var(--rot));
}
to {
transform: translateY(120vh) rotate(var(--rot));
opacity: 0;
}
}
.lb-hidden {
opacity: 0;
}
/* Whole-modal fade after voting the last photo — the gallery is behind it. */
.lightbox.lb-closing {
animation: lb-fade-out 0.26s ease forwards;
pointer-events: none;
}
@keyframes lb-fade-out {
to {
opacity: 0;
}
}
/* The photo taking over after a vote zooms in organically. */
.lb-enter {
animation: lb-enter 0.26s ease-out;
}
@keyframes lb-enter {
from {
transform: scale(0.3);
opacity: 0.2;
}
to {
transform: scale(1);
opacity: 1;
}
}
.lb-nav {
position: absolute;
top: 50%;
@@ -688,12 +773,50 @@ kbd {
padding: 0;
}
/* Touch devices: no hover — selection checkmarks must always be visible,
and the overlay nav arrows give way to swiping. */
@media (hover: none) {
.g-check {
opacity: 1;
}
.lb-nav {
display: none;
}
/* No visible arrows to keep clear of — give the photo the width. */
.lb-slide {
padding: 0 0.75rem;
}
}
@media (max-width: 700px) {
.lb-stage {
.lb-slide {
padding: 0 0.5rem;
}
.login-card {
padding: 2rem 1.5rem;
margin: 0 1rem;
}
.share-head {
padding: 1.25rem 0.75rem 0.25rem;
}
.lb-footer {
padding: 0.5rem 0.6rem calc(0.8rem + env(safe-area-inset-bottom));
}
.client-footer {
gap: 0.8rem;
}
/* Finger-sized vote and rating targets in the client lightbox. */
.client-footer .thumb {
font-size: 1.7rem;
}
.client-footer .star {
font-size: 1.8rem;
}
.select-bar {
flex-wrap: wrap;
justify-content: center;
max-width: calc(100vw - 1.5rem);
padding: 0.5rem 0.75rem;
bottom: calc(0.75rem + env(safe-area-inset-bottom));
}
}
+3 -1
View File
@@ -14,7 +14,9 @@ export default function useLightbox(photos) {
return {
index,
openAt: (i) => setOpenId(photos[i].id),
// Bounds-safe: callers may hold a stale index (the list can shrink
// between render and dispatch).
openAt: (i) => photos[i] && setOpenId(photos[i].id),
show: (id) => setOpenId(id),
close: () => setOpenId(null),
}
+230
View File
@@ -0,0 +1,230 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { ACCEPT_GLYPH, REJECT_GLYPH } from './components/Thumbs'
const COMMIT_MS = 260
const CANCEL_MS = 180
const DEAD_ZONE = 12 // px of travel before the axis locks
const FLICK_MS = 250 // releases faster than this commit at the lower threshold
const H_THRESHOLD = { slow: 70, fast: 30 }
const V_THRESHOLD = { slow: 90, fast: 40 }
const V_SATURATE = 160 // px of vertical drag for full badge/preview intensity
const ZOOM_MAX = 4
// Touch engine for the lightbox: horizontal swipes navigate, vertical flicks
// dispatch gestures.up/down (when provided), two fingers pinch-zoom the
// current photo (springs back on release, never votes).
//
// Navigation and votes are dispatched SYNCHRONOUSLY on release; every
// animation afterwards is pure decoration (a ghost flying off, the track
// settling). Closing the lightbox, key presses or list changes during an
// animation therefore can't drop, duplicate or misdirect an action.
//
// Per-move motion is written straight to the DOM via refs — dragging costs
// no React renders; state changes happen only at axis lock and release.
export default function useSwipe({ photo, index, count, onNav, gestures, hasNext }) {
const [dragAxis, setDragAxis] = useState(null) // 'h' | 'v' | 'z' while a finger is down
const [spring, setSpring] = useState(null) // 'h' | 'v' | 'z': sub-threshold release gliding back
const [settleFrom, setSettleFrom] = useState(null) // px the track settles from after a nav commit
const [settleRun, setSettleRun] = useState(false)
const [exit, setExit] = useState(null) // { photo, dir, dy }: ghost flying off after a vote
const trackRef = useRef(null)
const imgRef = useRef(null)
const behindRef = useRef(null)
const badgeRef = useRef(null)
const g = useRef(null)
// FLIP: paint the track at the release offset around the NEW index first,
// force a style flush, then let it transition to center.
useLayoutEffect(() => {
if (settleFrom == null) {
setSettleRun(false)
return
}
trackRef.current?.getBoundingClientRect()
setSettleRun(true)
}, [settleFrom])
const clearManual = () => {
for (const ref of [trackRef, imgRef, behindRef, badgeRef]) {
if (ref.current) {
ref.current.style.transform = ''
ref.current.style.opacity = ''
ref.current.style.transition = ''
ref.current.style.transformOrigin = ''
}
}
}
const onTouchStart = (e) => {
// Fresh input takes over: decorative animations yield immediately.
setSpring(null)
setSettleFrom(null)
if (e.touches.length === 2 && imgRef.current) {
// Second finger at any point turns the gesture into a pinch — a pinch
// never navigates and never votes.
clearManual()
const [a, b] = e.touches
const rect = imgRef.current.getBoundingClientRect()
const cx = (a.clientX + b.clientX) / 2
const cy = (a.clientY + b.clientY) / 2
imgRef.current.style.transformOrigin = `${cx - rect.left}px ${cy - rect.top}px`
g.current = {
mode: 'pinch',
d0: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY),
cx,
cy,
}
setDragAxis('z')
return
}
if (e.touches.length !== 1) {
g.current = null
setDragAxis(null)
clearManual()
return
}
const t = e.touches[0]
g.current = { mode: 'swipe', x0: t.clientX, y0: t.clientY, t0: e.timeStamp, axis: null, dx: 0, dy: 0 }
}
const onTouchMove = (e) => {
const s = g.current
if (!s) return
if (s.mode === 'pinch') {
if (e.touches.length < 2 || !imgRef.current) return
const [a, b] = e.touches
const scale = Math.min(
ZOOM_MAX,
Math.max(1, Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) / s.d0),
)
const mx = (a.clientX + b.clientX) / 2 - s.cx
const my = (a.clientY + b.clientY) / 2 - s.cy
imgRef.current.style.transition = 'none'
imgRef.current.style.transform = `translate(${mx}px, ${my}px) scale(${scale})`
return
}
if (e.touches.length !== 1) return
const t = e.touches[0]
s.dx = t.clientX - s.x0
s.dy = t.clientY - s.y0
if (!s.axis) {
if (Math.hypot(s.dx, s.dy) < DEAD_ZONE) return
s.axis = Math.abs(s.dx) >= Math.abs(s.dy) || !gestures ? 'h' : 'v'
setDragAxis(s.axis)
}
if (s.axis === 'h') {
if (trackRef.current) {
trackRef.current.style.transition = 'none'
trackRef.current.style.transform = `translateX(${s.dx}px)`
}
} else {
if (imgRef.current) {
imgRef.current.style.transition = 'none'
imgRef.current.style.transform = `translateY(${s.dy}px) rotate(${s.dy / 40}deg)`
}
const p = Math.min(1, Math.abs(s.dy) / V_SATURATE)
if (behindRef.current) {
behindRef.current.style.transition = 'none'
behindRef.current.style.transform = `scale(${0.22 + 0.15 * p})`
behindRef.current.style.opacity = p
}
if (badgeRef.current) {
badgeRef.current.style.opacity = p
badgeRef.current.textContent = s.dy < 0 ? ACCEPT_GLYPH : REJECT_GLYPH
}
}
}
const onTouchEnd = (e) => {
const s = g.current
if (!s) return
if (s.mode === 'pinch') {
if (e.touches.length > 0) return
g.current = null
setDragAxis(null)
setSpring('z')
return
}
g.current = null
setDragAxis(null)
if (!s.axis) {
clearManual()
return
}
const quick = e.timeStamp - s.t0 < FLICK_MS
const past = (v, th) => Math.abs(v) > th.slow || (quick && Math.abs(v) > th.fast)
if (s.axis === 'h') {
if (s.dx < 0 && past(s.dx, H_THRESHOLD) && index < count - 1) {
onNav(index + 1)
setSettleFrom(s.dx + window.innerWidth)
} else if (s.dx > 0 && past(s.dx, H_THRESHOLD) && index > 0) {
onNav(index - 1)
setSettleFrom(s.dx - window.innerWidth)
} else {
setSpring('h')
}
} else if (past(s.dy, V_THRESHOLD)) {
const dir = s.dy < 0 ? 'up' : 'down'
gestures?.[dir]?.(photo)
clearManual()
setExit({ photo, dir, dy: s.dy })
} else {
setSpring('v')
}
}
const onTouchCancel = () => {
g.current = null
setDragAxis(null)
clearManual()
}
const trackStyle =
settleFrom != null
? settleRun
? { transform: 'translateX(0)', transition: `transform ${COMMIT_MS}ms ease-out` }
: { transform: `translateX(${settleFrom}px)`, transition: 'none' }
: spring === 'h'
? { transform: 'translateX(0)', transition: `transform ${CANCEL_MS}ms ease-out` }
: undefined
const imgStyle =
spring === 'v'
? { transform: 'translateY(0) rotate(0deg)', transition: `transform ${CANCEL_MS}ms ease-out` }
: spring === 'z'
? { transform: 'none', transition: `transform ${CANCEL_MS}ms ease-out` }
: undefined
const behindStyle =
spring === 'v'
? { transform: 'scale(0.22)', opacity: 0, transition: `all ${CANCEL_MS}ms ease-out` }
: undefined
return {
handlers: { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel },
trackRef,
imgRef,
behindRef,
badgeRef,
trackStyle,
imgStyle,
behindStyle,
showBehind: (dragAxis === 'v' || spring === 'v') && hasNext,
showBadge: dragAxis === 'v',
exit,
clearExit: () => setExit(null),
onTrackTransitionEnd: (e) => {
if (e.target !== trackRef.current) return
setSettleFrom(null)
if (spring === 'h') setSpring(null)
},
onImgTransitionEnd: (e) => {
if (e.target !== imgRef.current) return
if (spring === 'v' || spring === 'z') {
setSpring(null)
clearManual()
}
},
}
}