Compare commits
6
Commits
v0.4.3
..
900287b7aa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
900287b7aa | ||
|
|
61f1bd1474 | ||
|
|
72bcd7a385 | ||
|
|
8baed71701 | ||
|
|
9be3609cea | ||
|
|
f0238fc625 |
+2
-2
@@ -16,10 +16,10 @@ S3_FORCE_PATH_STYLE=true
|
|||||||
OIDC_ISSUER=https://auth.example.com
|
OIDC_ISSUER=https://auth.example.com
|
||||||
OIDC_CLIENT_ID=photos
|
OIDC_CLIENT_ID=photos
|
||||||
OIDC_CLIENT_SECRET=change-me
|
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 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
|
WORKER_CONCURRENCY=2
|
||||||
RUST_LOG=info,sqlx=warn
|
RUST_LOG=info,sqlx=warn
|
||||||
|
|||||||
Generated
-1
@@ -2574,7 +2574,6 @@ dependencies = [
|
|||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|||||||
+1
-2
@@ -37,8 +37,7 @@ tempfile = "3"
|
|||||||
time = "0.3"
|
time = "0.3"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tokio-util = { version = "0.7", features = ["io"] }
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
tower = { version = "0.5", features = ["util"] }
|
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
||||||
tower-http = { version = "0.6", features = ["fs", "set-header", "trace"] }
|
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
|
|||||||
@@ -57,10 +57,7 @@ The lightbox — accept/reject, star rating, tags, and keyboard-driven culling
|
|||||||
- **Frontend**: React + Vite SPA — justified gallery, lightbox with
|
- **Frontend**: React + Vite SPA — justified gallery, lightbox with
|
||||||
accept/reject thumbs, rating stars and tag chips, keyboard-driven culling
|
accept/reject thumbs, rating stars and tag chips, keyboard-driven culling
|
||||||
(`P`/`X`/`U`, `1`–`5`, `?` shows all shortcuts), drag-and-drop multi-file
|
(`P`/`X`/`U`, `1`–`5`, `?` shows all shortcuts), drag-and-drop multi-file
|
||||||
upload with progress. Touch-first on mobile: swipe sideways to browse,
|
upload with progress.
|
||||||
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
|
## Local development
|
||||||
|
|
||||||
@@ -108,20 +105,20 @@ All configuration is via environment variables:
|
|||||||
|
|
||||||
## Deploying to Kubernetes
|
## Deploying to Kubernetes
|
||||||
|
|
||||||
The prebuilt image is at `git.draic.info/nils/photos` (single image contains
|
Build and push the image (single image contains `server`, `worker`, and the
|
||||||
`server`, `worker`, and the built frontend). To build your own instead:
|
built frontend):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build -t registry.example.com/you/photos:0.4.3 .
|
docker build -t ghcr.io/YOU/photos:0.1.0 .
|
||||||
docker push registry.example.com/you/photos:0.4.3
|
docker push ghcr.io/YOU/photos:0.1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Install the chart, pointing it at your existing Postgres and S3:
|
Install the chart, pointing it at your existing Postgres and S3:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
helm install photos deploy/chart \
|
helm install photos deploy/chart \
|
||||||
--set image.repository=git.draic.info/nils/photos \
|
--set image.repository=ghcr.io/YOU/photos \
|
||||||
--set image.tag=0.4.3 \
|
--set image.tag=0.1.0 \
|
||||||
--set publicUrl=https://photos.example.com \
|
--set publicUrl=https://photos.example.com \
|
||||||
--set ingress.host=photos.example.com \
|
--set ingress.host=photos.example.com \
|
||||||
--set config.oidcIssuer=https://auth.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
|
stored **per link**, so create one link per client to keep feedback
|
||||||
separate. The album view shows all feedback grouped by link label, and
|
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
|
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
|
undecided, or select-all + download the accepted set).
|
||||||
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
|
- 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
|
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),
|
temp file for its checksum, then pipelines while the next one prefetches),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { imgUrl } from '../api'
|
import { imgUrl } from '../api'
|
||||||
import useSwipe from '../useSwipe'
|
|
||||||
|
|
||||||
const BASE_SHORTCUTS = [
|
const BASE_SHORTCUTS = [
|
||||||
['← / →', 'previous / next photo'],
|
['← / →', 'previous / next photo'],
|
||||||
@@ -13,21 +12,7 @@ const BASE_SHORTCUTS = [
|
|||||||
// come from the same entry, so the help overlay can't drift from behavior:
|
// come from the same entry, so the help overlay can't drift from behavior:
|
||||||
// { keys: ['p'], help: ['P', 'accept…'], run: (photo, key) => … }.
|
// { keys: ['p'], help: ['P', 'accept…'], run: (photo, key) => … }.
|
||||||
// Keys fire only outside text inputs and while the help overlay is closed.
|
// Keys fire only outside text inputs and while the help overlay is closed.
|
||||||
// `gestures` ({ up, down }) maps vertical touch flicks on the photo — the
|
export default function Lightbox({ photos, index, onClose, onNav, footer, actions }) {
|
||||||
// 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 photo = photos[index]
|
||||||
const [showHelp, setShowHelp] = useState(false)
|
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
|
// which a fast second keystroke hits a stale closure (and e.g. re-votes
|
||||||
// the previous photo).
|
// the previous photo).
|
||||||
const live = useRef({})
|
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(() => {
|
useEffect(() => {
|
||||||
// Only text entry captures keys (Escape leaves the field); focus on a
|
// 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))
|
(el.tagName === 'INPUT' && !['checkbox', 'radio', 'button'].includes(el.type))
|
||||||
const handler = (e) => {
|
const handler = (e) => {
|
||||||
const s = live.current
|
const s = live.current
|
||||||
if (s.closing) return
|
|
||||||
if (isTyping(e.target)) {
|
if (isTyping(e.target)) {
|
||||||
if (e.key === 'Escape') e.target.blur()
|
if (e.key === 'Escape') e.target.blur()
|
||||||
return
|
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
|
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 (
|
return (
|
||||||
<div
|
<div className="lightbox" onClick={onClose}>
|
||||||
className={`lightbox${closing ? ' lb-closing' : ''}`}
|
|
||||||
onClick={onClose}
|
|
||||||
onAnimationEnd={(e) => {
|
|
||||||
if (e.animationName === 'lb-fade-out') onClosed?.()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="lb-top" onClick={(e) => e.stopPropagation()}>
|
<div className="lb-top" onClick={(e) => e.stopPropagation()}>
|
||||||
<span className="lb-name">{photo.filename}</span>
|
<span className="lb-name">{photo.filename}</span>
|
||||||
<span className="lb-count">
|
<span className="lb-count">
|
||||||
@@ -151,60 +104,14 @@ export default function Lightbox({
|
|||||||
>
|
>
|
||||||
‹
|
‹
|
||||||
</button>
|
</button>
|
||||||
<div className="lb-stage" {...swipe.handlers}>
|
<div className="lb-stage">
|
||||||
<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
|
<img
|
||||||
ref={swipe.imgRef}
|
className="lb-img"
|
||||||
className={`lb-img${swipe.exit ? (exitSelf ? ' lb-hidden' : ' lb-enter') : ''}`}
|
src={imgUrl(photo, 'preview')}
|
||||||
style={swipe.imgStyle}
|
|
||||||
onTransitionEnd={swipe.onImgTransitionEnd}
|
|
||||||
src={preview(photo)}
|
|
||||||
alt={photo.filename}
|
alt={photo.filename}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<button
|
||||||
className="lb-nav lb-next"
|
className="lb-nav lb-next"
|
||||||
disabled={index === photos.length - 1}
|
disabled={index === photos.length - 1}
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
export const ACCEPT_GLYPH = '👍'
|
|
||||||
export const REJECT_GLYPH = '👎'
|
|
||||||
|
|
||||||
// Accept/reject vote. `value` is 'accept', 'reject' or null; clicking the
|
// Accept/reject vote. `value` is 'accept', 'reject' or null; clicking the
|
||||||
// active thumb clears it. Without onChange it renders read-only.
|
// active thumb clears it. Without onChange it renders read-only.
|
||||||
export default function Thumbs({ value, onChange, small }) {
|
export default function Thumbs({ value, onChange, small }) {
|
||||||
@@ -20,8 +17,8 @@ export default function Thumbs({ value, onChange, small }) {
|
|||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<span className={`thumbs${small ? ' thumbs-small' : ''}`}>
|
<span className={`thumbs${small ? ' thumbs-small' : ''}`}>
|
||||||
{thumb('accept', ACCEPT_GLYPH, 'Accept (P)')}
|
{thumb('accept', '👍', 'Accept (P)')}
|
||||||
{thumb('reject', REJECT_GLYPH, 'Reject (X)')}
|
{thumb('reject', '👎', 'Reject (X)')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { api, postDownload, sha256Hex, uploadFile } from '../api'
|
import { api, postDownload, sha256Hex, uploadFile } from '../api'
|
||||||
import Gallery from '../components/Gallery'
|
import Gallery from '../components/Gallery'
|
||||||
@@ -18,13 +18,6 @@ function fmtEta(seconds) {
|
|||||||
|
|
||||||
const UPLOAD_CONCURRENCY = 3
|
const UPLOAD_CONCURRENCY = 3
|
||||||
|
|
||||||
const FEEDBACK_FILTERS = [
|
|
||||||
{ key: 'all', label: 'All' },
|
|
||||||
{ key: 'accept', label: '👍' },
|
|
||||||
{ key: 'reject', label: '👎' },
|
|
||||||
{ key: 'undecided', label: 'Undecided' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Expiry convention, in one place for the create form and the row editor:
|
// Expiry convention, in one place for the create form and the row editor:
|
||||||
// end of the chosen day in the photographer's local timezone — date-only
|
// end of the chosen day in the photographer's local timezone — date-only
|
||||||
// strings would parse as UTC midnight and expire a day early.
|
// strings would parse as UTC midnight and expire a day early.
|
||||||
@@ -421,7 +414,14 @@ export default function AlbumPage() {
|
|||||||
}, [hasPending, load])
|
}, [hasPending, load])
|
||||||
|
|
||||||
const ready = (detail?.photos ?? []).filter((p) => p.status === 'ready')
|
const ready = (detail?.photos ?? []).filter((p) => p.status === 'ready')
|
||||||
const feedback = detail?.feedback ?? {}
|
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(ready)
|
||||||
|
const lightbox = useLightbox(ready)
|
||||||
|
|
||||||
|
if (error) return <p className="error">{error}</p>
|
||||||
|
if (!detail) return <p className="muted">Loading…</p>
|
||||||
|
|
||||||
|
const { album, photos, feedback } = detail
|
||||||
|
const notReady = photos.filter((p) => p.status !== 'ready')
|
||||||
|
|
||||||
const avgRating = (photoId) => {
|
const avgRating = (photoId) => {
|
||||||
const ratings = feedback[photoId]?.ratings || []
|
const ratings = feedback[photoId]?.ratings || []
|
||||||
@@ -429,44 +429,6 @@ export default function AlbumPage() {
|
|||||||
return ratings.reduce((sum, r) => sum + r.rating, 0) / ratings.length
|
return ratings.reduce((sum, r) => sum + r.rating, 0) / ratings.length
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two combinable filter dimensions over client feedback: verdict chips
|
|
||||||
// plus a minimum-average-stars threshold (0 = off).
|
|
||||||
const [filter, setFilter] = useState('all')
|
|
||||||
const [minStars, setMinStars] = useState(0)
|
|
||||||
const matchesVerdict = (p, key) => {
|
|
||||||
if (key === 'all') return true
|
|
||||||
const verdicts = feedback[p.id]?.verdicts || []
|
|
||||||
if (key === 'accept') return verdicts.some((v) => v.verdict === 'accept')
|
|
||||||
if (key === 'reject') return verdicts.some((v) => v.verdict === 'reject')
|
|
||||||
return verdicts.length === 0
|
|
||||||
}
|
|
||||||
const shown = ready.filter(
|
|
||||||
(p) => matchesVerdict(p, filter) && (minStars === 0 || (avgRating(p.id) ?? 0) >= minStars),
|
|
||||||
)
|
|
||||||
const filterCounts = useMemo(() => {
|
|
||||||
const counts = { all: ready.length, accept: 0, reject: 0, undecided: 0 }
|
|
||||||
for (const p of ready) {
|
|
||||||
const verdicts = feedback[p.id]?.verdicts || []
|
|
||||||
if (verdicts.some((v) => v.verdict === 'accept')) counts.accept += 1
|
|
||||||
if (verdicts.some((v) => v.verdict === 'reject')) counts.reject += 1
|
|
||||||
if (verdicts.length === 0) counts.undecided += 1
|
|
||||||
}
|
|
||||||
return counts
|
|
||||||
}, [detail])
|
|
||||||
|
|
||||||
// Selection spans all ready photos; the bar and bulk actions cover only
|
|
||||||
// the current view, like on the share page.
|
|
||||||
const { selected, toggle, selectAll, clear } = useSelection(ready)
|
|
||||||
const shownSelected = shown.filter((p) => selected.has(p.id))
|
|
||||||
const sumBytes = (list) => list.reduce((sum, p) => sum + p.size_bytes, 0)
|
|
||||||
const lightbox = useLightbox(shown)
|
|
||||||
|
|
||||||
if (error) return <p className="error">{error}</p>
|
|
||||||
if (!detail) return <p className="muted">Loading…</p>
|
|
||||||
|
|
||||||
const { album, photos } = detail
|
|
||||||
const notReady = photos.filter((p) => p.status !== 'ready')
|
|
||||||
|
|
||||||
const rename = async () => {
|
const rename = async () => {
|
||||||
const name = prompt('Album name', album.name)
|
const name = prompt('Album name', album.name)
|
||||||
if (name && name.trim()) {
|
if (name && name.trim()) {
|
||||||
@@ -489,11 +451,10 @@ export default function AlbumPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removeSelected = async () => {
|
const removeSelected = async () => {
|
||||||
const ids = shownSelected.map((p) => p.id)
|
if (!confirm(`Delete ${selected.size} selected photo${selected.size === 1 ? '' : 's'}? This cannot be undone.`))
|
||||||
if (!confirm(`Delete ${ids.length} selected photo${ids.length === 1 ? '' : 's'}? This cannot be undone.`))
|
|
||||||
return
|
return
|
||||||
try {
|
try {
|
||||||
await api('/api/photos/delete', { method: 'POST', body: { ids } })
|
await api('/api/photos/delete', { method: 'POST', body: { ids: [...selected] } })
|
||||||
clear()
|
clear()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(`Delete failed: ${e.message}`)
|
alert(`Delete failed: ${e.message}`)
|
||||||
@@ -551,32 +512,11 @@ export default function AlbumPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ready.length > 0 && (
|
|
||||||
<div className="filter-bar filter-bar-admin">
|
|
||||||
{FEEDBACK_FILTERS.map((f) => (
|
|
||||||
<button
|
|
||||||
key={f.key}
|
|
||||||
className={`filter-chip${filter === f.key ? ' active' : ''}`}
|
|
||||||
onClick={() => setFilter(f.key)}
|
|
||||||
>
|
|
||||||
{f.label} {filterCounts[f.key]}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<span
|
|
||||||
className={`filter-stars${minStars > 0 ? ' active' : ''}`}
|
|
||||||
title="Minimum average rating — click a star to filter, click it again to clear"
|
|
||||||
>
|
|
||||||
<Stars value={minStars} onChange={(n) => setMinStars(n)} small />
|
|
||||||
{minStars > 0 && <span className="muted">≥ {minStars}</span>}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Gallery
|
<Gallery
|
||||||
photos={shown}
|
photos={ready}
|
||||||
onOpen={lightbox.openAt}
|
onOpen={lightbox.openAt}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
onToggleSelect={(photoId, shift) => toggle(photoId, shift, shown)}
|
onToggleSelect={toggle}
|
||||||
overlay={(p) => {
|
overlay={(p) => {
|
||||||
const avg = avgRating(p.id)
|
const avg = avgRating(p.id)
|
||||||
const tagCount = feedback[p.id]?.tags.length || 0
|
const tagCount = feedback[p.id]?.tags.length || 0
|
||||||
@@ -597,29 +537,22 @@ export default function AlbumPage() {
|
|||||||
{ready.length === 0 && notReady.length === 0 && (
|
{ready.length === 0 && notReady.length === 0 && (
|
||||||
<p className="muted">No photos yet — drop some above.</p>
|
<p className="muted">No photos yet — drop some above.</p>
|
||||||
)}
|
)}
|
||||||
{ready.length > 0 && shown.length === 0 && (
|
|
||||||
<p className="muted">No photos match this filter.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SelectionBar
|
<SelectionBar
|
||||||
count={shownSelected.length}
|
count={selected.size}
|
||||||
total={shown.length}
|
total={ready.length}
|
||||||
selectedBytes={sumBytes(shownSelected)}
|
selectedBytes={selectedBytes}
|
||||||
totalBytes={sumBytes(shown)}
|
totalBytes={totalBytes}
|
||||||
onSelectAll={() => selectAll(shown)}
|
onSelectAll={() => selectAll(ready)}
|
||||||
onClear={clear}
|
onClear={clear}
|
||||||
onDownload={() =>
|
onDownload={() => postDownload(`/api/albums/${id}/zip`, [...selected].join(','))}
|
||||||
postDownload(`/api/albums/${id}/zip`, shownSelected.map((p) => p.id).join(','))
|
onDownloadAll={() => postDownload(`/api/albums/${id}/zip`)}
|
||||||
}
|
|
||||||
onDownloadAll={() =>
|
|
||||||
postDownload(`/api/albums/${id}/zip`, filter === 'all' ? '' : shown.map((p) => p.id).join(','))
|
|
||||||
}
|
|
||||||
onDelete={removeSelected}
|
onDelete={removeSelected}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{lightbox.index >= 0 && (
|
{lightbox.index >= 0 && (
|
||||||
<Lightbox
|
<Lightbox
|
||||||
photos={shown}
|
photos={ready}
|
||||||
index={lightbox.index}
|
index={lightbox.index}
|
||||||
onClose={lightbox.close}
|
onClose={lightbox.close}
|
||||||
onNav={lightbox.openAt}
|
onNav={lightbox.openAt}
|
||||||
|
|||||||
@@ -43,12 +43,6 @@ export default function SharePage() {
|
|||||||
const visibleSelected = visible.filter((p) => selected.has(p.id))
|
const visibleSelected = visible.filter((p) => selected.has(p.id))
|
||||||
const sumBytes = (list) => list.reduce((sum, p) => sum + p.size_bytes, 0)
|
const sumBytes = (list) => list.reduce((sum, p) => sum + p.size_bytes, 0)
|
||||||
const lightbox = useLightbox(visible)
|
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(
|
const load = useCallback(
|
||||||
() => api(`/api/share/${token}`).then(setView).catch((e) => setError(e.message)),
|
() => api(`/api/share/${token}`).then(setView).catch((e) => setError(e.message)),
|
||||||
@@ -120,8 +114,6 @@ export default function SharePage() {
|
|||||||
: `↺ ${photo.filename} cleared`,
|
: `↺ ${photo.filename} cleared`,
|
||||||
)
|
)
|
||||||
if (next) lightbox.show(next.id)
|
if (next) lightbox.show(next.id)
|
||||||
// Last photo voted: the run is done — fade back to the gallery.
|
|
||||||
else setLightboxFading(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyActions = [
|
const keyActions = [
|
||||||
@@ -179,8 +171,8 @@ export default function SharePage() {
|
|||||||
<h1>{view.album_name}</h1>
|
<h1>{view.album_name}</h1>
|
||||||
{view.album_description && <p className="muted">{view.album_description}</p>}
|
{view.album_description && <p className="muted">{view.album_description}</p>}
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{photos.length} photo{photos.length === 1 ? '' : 's'} · tap a photo to view, rate and
|
{photos.length} photo{photos.length === 1 ? '' : 's'} · click a photo to view, rate and
|
||||||
tag · swipe ↑ to accept, ↓ to reject · <kbd>?</kbd> shows keyboard shortcuts
|
tag · press <kbd>?</kbd> in the viewer for shortcuts
|
||||||
</p>
|
</p>
|
||||||
{photos.length > 0 && (
|
{photos.length > 0 && (
|
||||||
<div className="filter-bar">
|
<div className="filter-bar">
|
||||||
@@ -250,15 +242,6 @@ export default function SharePage() {
|
|||||||
onClose={lightbox.close}
|
onClose={lightbox.close}
|
||||||
onNav={lightbox.openAt}
|
onNav={lightbox.openAt}
|
||||||
actions={keyActions}
|
actions={keyActions}
|
||||||
closing={lightboxFading}
|
|
||||||
onClosed={() => {
|
|
||||||
setLightboxFading(false)
|
|
||||||
lightbox.close()
|
|
||||||
}}
|
|
||||||
gestures={{
|
|
||||||
up: (p) => voteAndAdvance(p, 'accept'),
|
|
||||||
down: (p) => voteAndAdvance(p, 'reject'),
|
|
||||||
}}
|
|
||||||
footer={(p) => (
|
footer={(p) => (
|
||||||
<div className="client-footer">
|
<div className="client-footer">
|
||||||
<Thumbs value={p.my_verdict} onChange={(v) => setVerdict(p, v)} />
|
<Thumbs value={p.my_verdict} onChange={(v) => setVerdict(p, v)} />
|
||||||
|
|||||||
+1
-162
@@ -451,18 +451,6 @@ progress {
|
|||||||
.lb-stage {
|
.lb-stage {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -474,79 +462,6 @@ progress {
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
cursor: default;
|
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 {
|
.lb-nav {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -671,26 +586,6 @@ progress {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
.filter-bar-admin {
|
|
||||||
justify-content: flex-start;
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
}
|
|
||||||
.filter-stars {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--panel-2);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.1rem 0.7rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.filter-stars.active {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
.filter-stars .star {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* transient action feedback (keyboard votes that navigate away) */
|
/* transient action feedback (keyboard votes that navigate away) */
|
||||||
.action-flash {
|
.action-flash {
|
||||||
@@ -793,68 +688,12 @@ kbd {
|
|||||||
padding: 0;
|
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) {
|
@media (max-width: 700px) {
|
||||||
.lb-slide {
|
.lb-stage {
|
||||||
padding: 0 0.5rem;
|
padding: 0 0.5rem;
|
||||||
}
|
}
|
||||||
.login-card {
|
.login-card {
|
||||||
padding: 2rem 1.5rem;
|
padding: 2rem 1.5rem;
|
||||||
margin: 0 1rem;
|
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));
|
|
||||||
}
|
|
||||||
/* Share rows: info and controls stack instead of fighting for one line. */
|
|
||||||
.share-row {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem 0;
|
|
||||||
}
|
|
||||||
.share-row .row {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
row-gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.share-form {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
.share-form > input {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ export default function useLightbox(photos) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
index,
|
index,
|
||||||
// Bounds-safe: callers may hold a stale index (the list can shrink
|
openAt: (i) => setOpenId(photos[i].id),
|
||||||
// between render and dispatch).
|
|
||||||
openAt: (i) => photos[i] && setOpenId(photos[i].id),
|
|
||||||
show: (id) => setOpenId(id),
|
show: (id) => setOpenId(id),
|
||||||
close: () => setOpenId(null),
|
close: () => setOpenId(null),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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::services::{ServeDir, ServeFile};
|
||||||
use tower_http::set_header::SetResponseHeaderLayer;
|
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
@@ -21,30 +17,11 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let state = AppState::new(config).await?;
|
let state = AppState::new(config).await?;
|
||||||
|
|
||||||
let static_dir = state.config.static_dir.clone();
|
let static_dir = state.config.static_dir.clone();
|
||||||
let static_root = std::path::Path::new(&static_dir);
|
let index = std::path::Path::new(&static_dir).join("index.html");
|
||||||
// Hashed assets cache forever — except 404s, which would outlive the next deploy.
|
// .fallback (not .not_found_service) so SPA routes get index.html with a 200
|
||||||
let assets = ServiceBuilder::new()
|
let spa = ServeDir::new(&static_dir).fallback(ServeFile::new(index));
|
||||||
.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 app = photos::routes::router(&state)
|
let app = photos::routes::router(&state)
|
||||||
.nest_service("/assets", assets)
|
|
||||||
.fallback_service(spa)
|
.fallback_service(spa)
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state.clone());
|
.with_state(state.clone());
|
||||||
|
|||||||
Reference in New Issue
Block a user