1 Commits
Author SHA1 Message Date
nils 7167af8984 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
2026-07-17 17:18:51 +02:00
7 changed files with 496 additions and 24 deletions
+12 -8
View File
@@ -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),
+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()
}
},
}
}