- {photos.length} photo{photos.length === 1 ? '' : 's'} · click a photo to view, rate and
- tag · press ? 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 · ? shows keyboard shortcuts
{photos.length > 0 && (
@@ -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) => (
setVerdict(p, v)} />
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 3f558d4..70a2f01 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -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));
+ }
}
diff --git a/frontend/src/useLightbox.js b/frontend/src/useLightbox.js
index 0d4e202..2b511c9 100644
--- a/frontend/src/useLightbox.js
+++ b/frontend/src/useLightbox.js
@@ -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),
}
diff --git a/frontend/src/useSwipe.js b/frontend/src/useSwipe.js
new file mode 100644
index 0000000..31cec84
--- /dev/null
+++ b/frontend/src/useSwipe.js
@@ -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()
+ }
+ },
+ }
+}