From 7167af8984907247637525ee5e343d463ebd8e87 Mon Sep 17 00:00:00 2001 From: nils Date: Fri, 17 Jul 2026 17:18:51 +0200 Subject: [PATCH] Mobile: real swipe physics, pinch zoom, touch-first lightbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 20 ++- frontend/src/components/Lightbox.jsx | 113 +++++++++++-- frontend/src/components/Thumbs.jsx | 7 +- frontend/src/pages/SharePage.jsx | 21 ++- frontend/src/styles.css | 125 ++++++++++++++- frontend/src/useLightbox.js | 4 +- frontend/src/useSwipe.js | 230 +++++++++++++++++++++++++++ 7 files changed, 496 insertions(+), 24 deletions(-) create mode 100644 frontend/src/useSwipe.js diff --git a/README.md b/README.md index 52df6c3..f7c85e4 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,10 @@ The lightbox — accept/reject, star rating, tags, and keyboard-driven culling - **Frontend**: React + Vite SPA — justified gallery, lightbox with accept/reject thumbs, rating stars and tag chips, keyboard-driven culling (`P`/`X`/`U`, `1`–`5`, `?` shows all shortcuts), drag-and-drop multi-file - upload with progress. + upload with progress. Touch-first on mobile: swipe sideways to browse, + flick a photo up/down to accept/reject (real physics — votes commit + instantly, the animation is decoration), pinch to zoom, always-visible + selection checkmarks. Voting the last photo fades back to the gallery. ## Local development @@ -105,20 +108,20 @@ All configuration is via environment variables: ## Deploying to Kubernetes -Build and push the image (single image contains `server`, `worker`, and the -built frontend): +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 ghcr.io/YOU/photos:0.1.0 . -docker push ghcr.io/YOU/photos:0.1.0 +docker build -t registry.example.com/you/photos:0.4.0 . +docker push registry.example.com/you/photos:0.4.0 ``` Install the chart, pointing it at your existing Postgres and S3: ```sh helm install photos deploy/chart \ - --set image.repository=ghcr.io/YOU/photos \ - --set image.tag=0.1.0 \ + --set image.repository=git.draic.info/nils/photos \ + --set image.tag=0.4.0 \ --set publicUrl=https://photos.example.com \ --set ingress.host=photos.example.com \ --set config.oidcIssuer=https://auth.example.com \ @@ -159,7 +162,8 @@ Notes: stored **per link**, so create one link per client to keep feedback separate. The album view shows all feedback grouped by link label, and clients can filter their gallery by verdict (e.g. review only what's still - undecided, or select-all + download the accepted set). + undecided, or select-all + download the accepted set). Culling works with + keyboard (`P`/`X` auto-advance) on desktop and swipe gestures on mobile. - Clients (and you) can multi-select photos and download them — or the whole album — as a ZIP. Archives are streamed (each file spools briefly through a temp file for its checksum, then pipelines while the next one prefetches), diff --git a/frontend/src/components/Lightbox.jsx b/frontend/src/components/Lightbox.jsx index 95fde5e..a442491 100644 --- a/frontend/src/components/Lightbox.jsx +++ b/frontend/src/components/Lightbox.jsx @@ -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 ( -
+
{ + if (e.animationName === 'lb-fade-out') onClosed?.() + }} + >
e.stopPropagation()}> {photo.filename} @@ -104,13 +151,59 @@ export default function Lightbox({ photos, index, onClose, onNav, footer, action > ‹ -
- {photo.filename} e.stopPropagation()} - /> +
+
+ {prevPhoto && ( +
+ +
+ )} +
+ {photo.filename} e.stopPropagation()} + /> +
+ {nextPhoto && + (swipe.showBehind ? ( +
+ +
+ ) : ( +
+ +
+ ))} +
+ {swipe.exit && ( +
+ +
+ )} + {swipe.showBadge &&
}