Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7167af8984 | ||
|
|
037b68fc9d |
@@ -5,6 +5,23 @@ share them with clients via private (optionally password-protected) links,
|
||||
collect accept/reject votes, ratings and tags, and let clients download
|
||||
originals.
|
||||
|
||||
## Screenshots
|
||||
|
||||
The album view — drag-and-drop upload, justified gallery, and each client's
|
||||
feedback (votes, stars, tags) overlaid on the thumbnails:
|
||||
|
||||

|
||||
|
||||
What clients see on a share link — vote on favorites, filter by verdict,
|
||||
download selects or the whole album as a ZIP:
|
||||
|
||||

|
||||
|
||||
The lightbox — accept/reject, star rating, tags, and keyboard-driven culling
|
||||
(`P`/`X`/`U`, `1`–`5`):
|
||||
|
||||

|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
@@ -40,7 +57,10 @@ originals.
|
||||
- **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
|
||||
|
||||
@@ -88,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 \
|
||||
@@ -142,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),
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 400 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 528 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 389 KiB |
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { imgUrl } from '../api'
|
||||
|
||||
// True justified layout: pack photos greedily into rows at their real aspect
|
||||
@@ -32,22 +32,17 @@ function layoutRows(photos, containerWidth, targetHeight, gap) {
|
||||
}
|
||||
|
||||
export default function Gallery({ photos, onOpen, overlay, selected, onToggleSelect }) {
|
||||
const containerRef = useRef(null)
|
||||
const [width, setWidth] = useState(0)
|
||||
const observerRef = useRef(null)
|
||||
|
||||
// Callback ref instead of mount effect: the container div unmounts whenever
|
||||
// the photo list is empty (e.g. a filter with no matches), so the observer
|
||||
// must re-attach to each new element — a once-per-mount effect would leave
|
||||
// the re-rendered gallery unobserved at width 0, rendering nothing.
|
||||
const containerRef = useCallback((el) => {
|
||||
observerRef.current?.disconnect()
|
||||
observerRef.current = null
|
||||
if (!el) return
|
||||
observerRef.current = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width))
|
||||
observerRef.current.observe(el)
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width))
|
||||
observer.observe(containerRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
if (photos.length === 0) return null
|
||||
// The container renders even with zero photos — unmounting it would detach
|
||||
// the observer and leave a later non-empty render stuck at width 0.
|
||||
const gap = 6
|
||||
const targetHeight = width < 700 ? 170 : 240
|
||||
const rows = width > 0 ? layoutRows(photos, width, targetHeight, gap) : []
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,67 @@ function fmtEta(seconds) {
|
||||
|
||||
const UPLOAD_CONCURRENCY = 3
|
||||
|
||||
// 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
|
||||
// strings would parse as UTC midnight and expire a day early.
|
||||
const endOfDayIso = (day) => (day ? new Date(`${day}T23:59:59`).toISOString() : null)
|
||||
|
||||
// ISO timestamp -> local yyyy-mm-dd for date inputs.
|
||||
const localDate = (iso) => {
|
||||
const d = new Date(iso)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Staged expiry editor: commits on blur/Enter, never per keystroke — typing
|
||||
// a year fires change events with bogus intermediate dates (year 0002) that
|
||||
// must not hit the live link. The ✕ clears explicitly; Safari's date input
|
||||
// has no native clear control.
|
||||
function ExpiryDate({ value, onCommit }) {
|
||||
const current = value ? localDate(value) : ''
|
||||
const [draft, setDraft] = useState(current)
|
||||
useEffect(() => setDraft(current), [current])
|
||||
|
||||
const commit = () => {
|
||||
if (draft === current) return
|
||||
// A half-typed year (e.g. 0002) can survive until blur; don't persist it.
|
||||
if (draft && draft.slice(0, 4) < '2000') {
|
||||
setDraft(current)
|
||||
return
|
||||
}
|
||||
onCommit(endOfDayIso(draft))
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="row field-label" title="Expiry date — empty means the link never expires">
|
||||
<span className="muted">expires</span>
|
||||
<input
|
||||
type="date"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.target.blur()
|
||||
}}
|
||||
/>
|
||||
{draft ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
title="Remove expiry — link never expires"
|
||||
onClick={() => {
|
||||
setDraft('')
|
||||
onCommit(null)
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
) : (
|
||||
<span className="muted">never</span>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function UploadZone({ albumId, onUploaded }) {
|
||||
const [queue, setQueue] = useState([])
|
||||
const [dragging, setDragging] = useState(false)
|
||||
@@ -198,11 +259,7 @@ function SharesPanel({ albumId }) {
|
||||
label: form.label,
|
||||
password: form.password || null,
|
||||
allow_download: form.allow_download,
|
||||
// End of the chosen day in the photographer's local timezone —
|
||||
// date-only strings would parse as UTC midnight and expire a day early.
|
||||
expires_at: form.expires_at
|
||||
? new Date(`${form.expires_at}T23:59:59`).toISOString()
|
||||
: null,
|
||||
expires_at: endOfDayIso(form.expires_at),
|
||||
},
|
||||
})
|
||||
setForm({ label: '', password: '', allow_download: true, expires_at: '' })
|
||||
@@ -221,18 +278,14 @@ function SharesPanel({ albumId }) {
|
||||
|
||||
const update = async (shareId, patch) => {
|
||||
try {
|
||||
await api(`/api/shares/${shareId}`, { method: 'PATCH', body: patch })
|
||||
const updated = await api(`/api/shares/${shareId}`, { method: 'PATCH', body: patch })
|
||||
setShares((list) => list.map((s) => (s.id === shareId ? updated : s)))
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
// Re-sync the controlled inputs with what the server actually has.
|
||||
load()
|
||||
}
|
||||
load()
|
||||
}
|
||||
|
||||
// ISO timestamp -> local yyyy-mm-dd for the date input.
|
||||
const localDate = (iso) => {
|
||||
const d = new Date(iso)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -262,21 +315,7 @@ function SharesPanel({ albumId }) {
|
||||
/>
|
||||
downloads
|
||||
</label>
|
||||
<label className="row field-label" title="Expiry date — empty means the link never expires">
|
||||
<span className="muted">expires</span>
|
||||
<input
|
||||
type="date"
|
||||
value={s.expires_at ? localDate(s.expires_at) : ''}
|
||||
onChange={(e) =>
|
||||
update(s.id, {
|
||||
// End of the chosen day in local time, like the create form.
|
||||
expires_at: e.target.value
|
||||
? new Date(`${e.target.value}T23:59:59`).toISOString()
|
||||
: null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<ExpiryDate value={s.expires_at} onCommit={(iso) => update(s.id, { expires_at: iso })} />
|
||||
{s.locked && (
|
||||
<button
|
||||
className="btn"
|
||||
@@ -414,8 +453,12 @@ export default function AlbumPage() {
|
||||
const removeSelected = async () => {
|
||||
if (!confirm(`Delete ${selected.size} selected photo${selected.size === 1 ? '' : 's'}? This cannot be undone.`))
|
||||
return
|
||||
await api('/api/photos/delete', { method: 'POST', body: { ids: [...selected] } })
|
||||
clear()
|
||||
try {
|
||||
await api('/api/photos/delete', { method: 'POST', body: { ids: [...selected] } })
|
||||
clear()
|
||||
} catch (e) {
|
||||
alert(`Delete failed: ${e.message}`)
|
||||
}
|
||||
load()
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
@@ -193,7 +201,11 @@ export default function SharePage() {
|
||||
photos={visible}
|
||||
onOpen={lightbox.openAt}
|
||||
selected={view.allow_download ? selected : undefined}
|
||||
onToggleSelect={view.allow_download ? toggle : undefined}
|
||||
// Shift-ranges must walk the filtered view, not the full album —
|
||||
// otherwise hidden photos get swept into the selection.
|
||||
onToggleSelect={
|
||||
view.allow_download ? (id, shift) => toggle(id, shift, visible) : undefined
|
||||
}
|
||||
overlay={(p) =>
|
||||
p.my_verdict || p.my_rating || p.my_tags.length > 0 ? (
|
||||
<div className="g-overlay">
|
||||
@@ -238,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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -18,30 +18,42 @@ export default function useSelection(photos) {
|
||||
}, [photos])
|
||||
|
||||
// Shift-toggle selects the whole range from the previously toggled photo
|
||||
// (both directions), so contiguous runs don't need per-photo clicks.
|
||||
const toggle = (photoId, shift = false) =>
|
||||
// (both directions), so contiguous runs don't need per-photo clicks. The
|
||||
// range walks `list` — the photo order the user is actually looking at —
|
||||
// which callers with a filtered view must pass explicitly, so hidden
|
||||
// photos are never swept into the selection. Anchor updates and range
|
||||
// computation stay out of the setSelected updater: React may re-invoke
|
||||
// updaters (StrictMode does), so they must be pure.
|
||||
const toggle = (photoId, shift = false, list = photos) => {
|
||||
const anchor = lastToggled.current
|
||||
lastToggled.current = photoId
|
||||
if (shift && anchor) {
|
||||
const a = list.findIndex((p) => p.id === anchor)
|
||||
const b = list.findIndex((p) => p.id === photoId)
|
||||
if (a >= 0 && b >= 0) {
|
||||
const range = list.slice(Math.min(a, b), Math.max(a, b) + 1).map((p) => p.id)
|
||||
setSelected((prev) => new Set([...prev, ...range]))
|
||||
return
|
||||
}
|
||||
}
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (shift && lastToggled.current) {
|
||||
const a = photos.findIndex((p) => p.id === lastToggled.current)
|
||||
const b = photos.findIndex((p) => p.id === photoId)
|
||||
if (a >= 0 && b >= 0) {
|
||||
for (const p of photos.slice(Math.min(a, b), Math.max(a, b) + 1)) next.add(p.id)
|
||||
lastToggled.current = photoId
|
||||
return next
|
||||
}
|
||||
}
|
||||
if (next.has(photoId)) next.delete(photoId)
|
||||
else next.add(photoId)
|
||||
lastToggled.current = photoId
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Adds `list` (the caller's currently visible photos) to the selection —
|
||||
// additive, so selecting all of one filtered view keeps picks from another.
|
||||
const selectAll = (list) =>
|
||||
setSelected((prev) => new Set([...prev, ...list.map((p) => p.id)]))
|
||||
const clear = () => setSelected(new Set())
|
||||
const clear = () => {
|
||||
// Reset the anchor too — a shift-click after Clear must start fresh, not
|
||||
// extend a range from a photo selected before the wipe.
|
||||
lastToggled.current = null
|
||||
setSelected(new Set())
|
||||
}
|
||||
const selectedBytes = photos.reduce((sum, p) => sum + (selected.has(p.id) ? p.size_bytes : 0), 0)
|
||||
const totalBytes = photos.reduce((sum, p) => sum + p.size_bytes, 0)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
+8
-12
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::models::{Album, JobKind, Photo, PhotoStatus};
|
||||
use crate::models::{Album, Photo, PhotoStatus};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
@@ -232,17 +232,13 @@ pub async fn delete(
|
||||
if locked.is_none() {
|
||||
return Err(ApiError::not_found());
|
||||
}
|
||||
// Delete photos and enqueue their S3 cleanup atomically, in one statement.
|
||||
sqlx::query(
|
||||
"with deleted as (delete from photos where album_id = $1 returning id)
|
||||
insert into jobs (kind, payload)
|
||||
select $2, jsonb_build_object('prefix', 'photos/' || id || '/')
|
||||
from deleted",
|
||||
)
|
||||
.bind(album_id)
|
||||
.bind(JobKind::DeleteS3Prefix.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// Delete photos through the shared path so the S3 cleanup convention
|
||||
// lives in one place; the album lock above keeps this set complete.
|
||||
let photo_ids: Vec<Uuid> = sqlx::query_scalar("select id from photos where album_id = $1")
|
||||
.bind(album_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
super::photos::delete_with_cleanup(&mut tx, &photo_ids).await?;
|
||||
sqlx::query("delete from albums where id = $1")
|
||||
.bind(album_id)
|
||||
.execute(&mut *tx)
|
||||
|
||||
+36
-24
@@ -189,24 +189,42 @@ pub async fn by_hash(
|
||||
photo.map(Json).ok_or_else(ApiError::not_found)
|
||||
}
|
||||
|
||||
/// The one deletion path: delete the given photos and enqueue their S3
|
||||
/// cleanup jobs in the same transaction. Prefixes come from s3::photo_prefix
|
||||
/// so the key layout has a single source of truth. Returns how many photos
|
||||
/// were actually deleted.
|
||||
pub(super) async fn delete_with_cleanup(
|
||||
tx: &mut sqlx::PgConnection,
|
||||
ids: &[Uuid],
|
||||
) -> Result<u64, sqlx::Error> {
|
||||
let deleted: Vec<Uuid> = sqlx::query_scalar("delete from photos where id = any($1) returning id")
|
||||
.bind(ids)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
if deleted.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let prefixes: Vec<String> = deleted.iter().map(|id| s3::photo_prefix(*id)).collect();
|
||||
sqlx::query(
|
||||
"insert into jobs (kind, payload)
|
||||
select $1, jsonb_build_object('prefix', p)
|
||||
from unnest($2::text[]) as p",
|
||||
)
|
||||
.bind(JobKind::DeleteS3Prefix.as_str())
|
||||
.bind(&prefixes)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
Ok(deleted.len() as u64)
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
State(state): State<AppState>,
|
||||
Path(photo_id): Path<Uuid>,
|
||||
) -> ApiResult<Json<serde_json::Value>> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let deleted = sqlx::query("delete from photos where id = $1")
|
||||
.bind(photo_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if deleted.rows_affected() == 0 {
|
||||
if delete_with_cleanup(&mut tx, &[photo_id]).await? == 0 {
|
||||
return Err(ApiError::not_found());
|
||||
}
|
||||
jobs::enqueue(
|
||||
&mut *tx,
|
||||
JobKind::DeleteS3Prefix,
|
||||
serde_json::json!({ "prefix": s3::photo_prefix(photo_id) }),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -216,8 +234,6 @@ pub struct DeleteManyBody {
|
||||
ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
/// Bulk delete: photos vanish and their S3 cleanup jobs are enqueued in one
|
||||
/// statement, same as album deletion.
|
||||
pub async fn delete_many(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<DeleteManyBody>,
|
||||
@@ -226,18 +242,14 @@ pub async fn delete_many(
|
||||
return Err(ApiError::bad_request("ids must not be empty"));
|
||||
}
|
||||
let mut tx = state.db.begin().await?;
|
||||
let jobs = sqlx::query(
|
||||
"with deleted as (delete from photos where id = any($1) returning id)
|
||||
insert into jobs (kind, payload)
|
||||
select $2, jsonb_build_object('prefix', 'photos/' || id || '/')
|
||||
from deleted",
|
||||
)
|
||||
.bind(&body.ids)
|
||||
.bind(JobKind::DeleteS3Prefix.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let deleted = delete_with_cleanup(&mut tx, &body.ids).await?;
|
||||
// Consistent with the single-photo route: deleting nothing is an error,
|
||||
// not a silent success (e.g. a stale tab re-deleting already-gone photos).
|
||||
if deleted == 0 {
|
||||
return Err(ApiError::not_found());
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(Json(serde_json::json!({ "ok": true, "deleted": jobs.rows_affected() })))
|
||||
Ok(Json(serde_json::json!({ "ok": true, "deleted": deleted })))
|
||||
}
|
||||
|
||||
pub async fn reprocess(
|
||||
|
||||
Reference in New Issue
Block a user