6 Commits
Author SHA1 Message Date
nils 900287b7aa Fix review findings on bulk delete, share editing, selection
ci / docker (push) Successful in 13m43s
- expiry editor commits on blur/Enter with explicit clear button — no more
  per-keystroke PATCHes transiently expiring live links
- shift-range walks the filtered view, anchors reset on clear, updaters
  kept pure (StrictMode-safe)
- bulk delete surfaces errors, 404s on zero deletions, and shares one
  delete+cleanup path with single and album delete (s3::photo_prefix is
  the only prefix source)
- expiry end-of-day convention extracted; share rows update from the
  PATCH response instead of reloading the list
- Gallery container always renders, restoring the simple observer effect
2026-07-17 16:42:28 +02:00
nils 61f1bd1474 Bulk delete, editable share links, gallery filter fix
ci / docker (push) Successful in 13m28s
- shift-click selects ranges in the gallery; selection bar gains
  'Delete N' with a single confirm (POST /api/photos/delete)
- PATCH /api/shares/{id}: allow_download and expiry editable in place,
  token and client feedback preserved
- Gallery: re-attach ResizeObserver via callback ref — after a filter
  with zero matches the gallery stayed blank at width 0
2026-07-17 16:30:26 +02:00
nils 72bcd7a385 Client accept/reject votes with keyboard-driven culling
ci / docker (push) Successful in 13m22s
- verdicts table (per link, like ratings), PUT verdict endpoint, typed Verdict enum
- share page: thumbs up/down, verdict filter with counts, view-scoped selection bar
- lightbox: per-page shortcut table (P/X/U, 1-5/0, S), ? help overlay, action
  toast when a keyboard vote auto-advances
- shared useLightbox hook; single keydown subscription reading live state via ref
- album view: per-link thumbs and vote counts; share list shows accept/reject totals
- feedback queries deduped and run concurrently; verdict counts in one scan
2026-07-17 15:55:46 +02:00
nils 8baed71701 Content dedup on both sides, stored checksums, spool-free zips, upload stats
ci / docker (push) Successful in 16s
- sha256+crc32 hashed during upload streaming; unique index per album
- duplicate content returns the existing photo (race-safe via 23505)
- client hashes locally (WebCrypto) and skips the transfer entirely for
  content the album already has
- zip downloads stream S3->response directly using the stored crc32;
  pre-hash photos spool once and self-heal (crc via zip, sha via reprocess)
- upload UI: overall progress bar, bytes, live speed, ETA
2026-07-17 14:41:07 +02:00
nils 9be3609cea Gallery: true justified layout (row-height scaling, no crop, no spacer)
ci / docker (push) Successful in 15s
2026-07-17 14:12:57 +02:00
nilsandClaude f0238fc625 Initial release: self-hosted client photo gallery
ci / docker (push) Successful in 13m10s
Rust (axum + sqlx) API and worker sharing a Postgres-backed job queue
(SKIP LOCKED, heartbeat, reaper, typed statuses), S3 storage with derived
keys and a fully private bucket, OIDC photographer login with per-request
allowlist checks, client share links with argon2 passwords and lockout,
cookie-based image authorization with sliding expiry, hand-rolled
spec-compliant streaming ZIP downloads with exact Content-Length,
React + Vite gallery frontend, single Docker image, Helm chart for
external S3 + Postgres, and Gitea CI.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 13:12:42 +02:00
11 changed files with 30 additions and 527 deletions
+2 -2
View File
@@ -16,10 +16,10 @@ S3_FORCE_PATH_STYLE=true
OIDC_ISSUER=https://auth.example.com
OIDC_CLIENT_ID=photos
OIDC_CLIENT_SECRET=change-me
ALLOWED_EMAILS=you@example.com
ALLOWED_EMAILS=u797+claude@posteo.de
# DEV ONLY: skip OIDC and sign straight in as this email. Never set in production.
# DEV_AUTOLOGIN_EMAIL=you@example.com
# DEV_AUTOLOGIN_EMAIL=u797+claude@posteo.de
WORKER_CONCURRENCY=2
RUST_LOG=info,sqlx=warn
Generated
-1
View File
@@ -2574,7 +2574,6 @@ dependencies = [
"time",
"tokio",
"tokio-util",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
+1 -2
View File
@@ -37,8 +37,7 @@ tempfile = "3"
time = "0.3"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["fs", "set-header", "trace"] }
tower-http = { version = "0.6", features = ["fs", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
urlencoding = "2"
+8 -12
View File
@@ -57,10 +57,7 @@ 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. 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.
upload with progress.
## Local development
@@ -108,20 +105,20 @@ All configuration is via environment variables:
## Deploying to Kubernetes
The prebuilt image is at `git.draic.info/nils/photos` (single image contains
`server`, `worker`, and the built frontend). To build your own instead:
Build and push the image (single image contains `server`, `worker`, and the
built frontend):
```sh
docker build -t registry.example.com/you/photos:0.4.0 .
docker push registry.example.com/you/photos:0.4.0
docker build -t ghcr.io/YOU/photos:0.1.0 .
docker push ghcr.io/YOU/photos:0.1.0
```
Install the chart, pointing it at your existing Postgres and S3:
```sh
helm install photos deploy/chart \
--set image.repository=git.draic.info/nils/photos \
--set image.tag=0.4.0 \
--set image.repository=ghcr.io/YOU/photos \
--set image.tag=0.1.0 \
--set publicUrl=https://photos.example.com \
--set ingress.host=photos.example.com \
--set config.oidcIssuer=https://auth.example.com \
@@ -162,8 +159,7 @@ 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). Culling works with
keyboard (`P`/`X` auto-advance) on desktop and swipe gestures on mobile.
undecided, or select-all + download the accepted set).
- 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),
+10 -103
View File
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { imgUrl } from '../api'
import useSwipe from '../useSwipe'
const BASE_SHORTCUTS = [
['← / →', 'previous / next photo'],
@@ -13,21 +12,7 @@ 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.
// `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,
}) {
export default function Lightbox({ photos, index, onClose, onNav, footer, actions }) {
const photo = photos[index]
const [showHelp, setShowHelp] = useState(false)
@@ -37,7 +22,7 @@ export default function Lightbox({
// 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, closing }
live.current = { index, count: photos.length, photo, onClose, onNav, actions, showHelp }
useEffect(() => {
// Only text entry captures keys (Escape leaves the field); focus on a
@@ -48,7 +33,6 @@ export default function Lightbox({
(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
@@ -90,41 +74,10 @@ export default function Lightbox({
}
}, [])
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${closing ? ' lb-closing' : ''}`}
onClick={onClose}
onAnimationEnd={(e) => {
if (e.animationName === 'lb-fade-out') onClosed?.()
}}
>
<div className="lightbox" onClick={onClose}>
<div className="lb-top" onClick={(e) => e.stopPropagation()}>
<span className="lb-name">{photo.filename}</span>
<span className="lb-count">
@@ -151,59 +104,13 @@ export default function Lightbox({
>
</button>
<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 className="lb-stage">
<img
className="lb-img"
src={imgUrl(photo, 'preview')}
alt={photo.filename}
onClick={(e) => e.stopPropagation()}
/>
</div>
<button
className="lb-nav lb-next"
+2 -5
View File
@@ -1,6 +1,3 @@
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 }) {
@@ -20,8 +17,8 @@ export default function Thumbs({ value, onChange, small }) {
)
return (
<span className={`thumbs${small ? ' thumbs-small' : ''}`}>
{thumb('accept', ACCEPT_GLYPH, 'Accept (P)')}
{thumb('reject', REJECT_GLYPH, 'Reject (X)')}
{thumb('accept', '👍', 'Accept (P)')}
{thumb('reject', '👎', 'Reject (X)')}
</span>
)
}
+2 -19
View File
@@ -43,12 +43,6 @@ 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)),
@@ -120,8 +114,6 @@ 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 = [
@@ -179,8 +171,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'} · tap a photo to view, rate and
tag · swipe to accept, to reject · <kbd>?</kbd> shows keyboard shortcuts
{photos.length} photo{photos.length === 1 ? '' : 's'} · click a photo to view, rate and
tag · press <kbd>?</kbd> in the viewer for shortcuts
</p>
{photos.length > 0 && (
<div className="filter-bar">
@@ -250,15 +242,6 @@ 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)} />
+1 -124
View File
@@ -451,18 +451,6 @@ 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;
@@ -474,79 +462,6 @@ 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%;
@@ -773,50 +688,12 @@ 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-slide {
.lb-stage {
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));
}
}
+1 -3
View File
@@ -14,9 +14,7 @@ export default function useLightbox(photos) {
return {
index,
// Bounds-safe: callers may hold a stale index (the list can shrink
// between render and dispatch).
openAt: (i) => photos[i] && setOpenId(photos[i].id),
openAt: (i) => setOpenId(photos[i].id),
show: (id) => setOpenId(id),
close: () => setOpenId(null),
}
-230
View File
@@ -1,230 +0,0 @@
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()
}
},
}
}
+3 -26
View File
@@ -1,8 +1,4 @@
use axum::http::{header, HeaderValue};
use tower::ServiceBuilder;
use tower_http::services::fs::ServeFileSystemResponseBody;
use tower_http::services::{ServeDir, ServeFile};
use tower_http::set_header::SetResponseHeaderLayer;
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;
@@ -21,30 +17,11 @@ async fn main() -> anyhow::Result<()> {
let state = AppState::new(config).await?;
let static_dir = state.config.static_dir.clone();
let static_root = std::path::Path::new(&static_dir);
// Hashed assets cache forever — except 404s, which would outlive the next deploy.
let assets = ServiceBuilder::new()
.map_response(|mut response: axum::http::Response<ServeFileSystemResponseBody>| {
if response.status().is_success() {
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
}
response
})
.service(ServeDir::new(static_root.join("assets")));
// index.html revalidates every load (it names the asset hashes);
// .fallback (not .not_found_service) so SPA routes get it with a 200.
let spa = ServiceBuilder::new()
.layer(SetResponseHeaderLayer::overriding(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
))
.service(ServeDir::new(static_root).fallback(ServeFile::new(static_root.join("index.html"))));
let index = std::path::Path::new(&static_dir).join("index.html");
// .fallback (not .not_found_service) so SPA routes get index.html with a 200
let spa = ServeDir::new(&static_dir).fallback(ServeFile::new(index));
let app = photos::routes::router(&state)
.nest_service("/assets", assets)
.fallback_service(spa)
.layer(TraceLayer::new_for_http())
.with_state(state.clone());