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
+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)} />