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 &&
}