Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46dd8df55c | ||
|
|
b5512aa7e5 | ||
|
|
002e51f36e | ||
|
|
7167af8984 | ||
|
|
037b68fc9d | ||
|
|
5cb00f3ef0 | ||
|
|
30d0b3064e | ||
|
|
6259ca84d8 | ||
|
|
a6819809a7 |
Generated
+2
@@ -2562,6 +2562,7 @@ dependencies = [
|
||||
"cookie",
|
||||
"crc32fast",
|
||||
"futures",
|
||||
"hex",
|
||||
"image",
|
||||
"rand 0.8.7",
|
||||
"reqwest",
|
||||
@@ -2573,6 +2574,7 @@ dependencies = [
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
||||
+3
-1
@@ -25,6 +25,7 @@ axum = { version = "0.8", features = ["macros"] }
|
||||
axum-extra = { version = "0.10", features = ["cookie", "cookie-signed"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
futures = "0.3"
|
||||
hex = "0.4"
|
||||
image = "0.25"
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
@@ -36,7 +37,8 @@ tempfile = "3"
|
||||
time = "0.3"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "set-header", "trace"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
urlencoding = "2"
|
||||
|
||||
@@ -2,7 +2,25 @@
|
||||
|
||||
Self-hosted client gallery for photographers. Upload RAWs/JPGs into albums,
|
||||
share them with clients via private (optionally password-protected) links,
|
||||
collect ratings and tags, and let clients download originals.
|
||||
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
|
||||
|
||||
@@ -36,8 +54,13 @@ collect ratings and tags, and let clients download originals.
|
||||
revokes access immediately. Clients use unguessable share tokens, optionally
|
||||
gated by an argon2-hashed password (10 wrong guesses lock the link for
|
||||
15 minutes).
|
||||
- **Frontend**: React + Vite SPA — justified gallery, lightbox with rating
|
||||
stars and tag chips, drag-and-drop multi-file upload with progress.
|
||||
- **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.
|
||||
|
||||
## Local development
|
||||
|
||||
@@ -85,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.3 .
|
||||
docker push registry.example.com/you/photos:0.4.3
|
||||
```
|
||||
|
||||
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.3 \
|
||||
--set publicUrl=https://photos.example.com \
|
||||
--set ingress.host=photos.example.com \
|
||||
--set config.oidcIssuer=https://auth.example.com \
|
||||
@@ -135,9 +158,12 @@ Notes:
|
||||
- Each album can have any number of share links (`/s/<24-char-token>`), each
|
||||
with its own label (e.g. the client's name), optional password, optional
|
||||
expiry, and a per-link download toggle.
|
||||
- Ratings (1–5 stars) and free-form tags are stored **per link**, so create
|
||||
one link per client to keep feedback separate. The album view shows all
|
||||
feedback grouped by link label.
|
||||
- Accept/reject votes (👍/👎), ratings (1–5 stars) and free-form tags are
|
||||
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.
|
||||
- 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),
|
||||
@@ -150,8 +176,8 @@ Notes:
|
||||
- 10 wrong passwords lock a link for 15 minutes (fresh attempts after the
|
||||
window). A locked link shows in the album's share list with an Unlock
|
||||
button.
|
||||
- Deleting a link removes its ratings/tags; deleting photos or albums cleans
|
||||
up S3 objects via background jobs.
|
||||
- Deleting a link removes its votes/ratings/tags; deleting photos or albums
|
||||
cleans up S3 objects via background jobs.
|
||||
|
||||
## Known limitations / deliberate v1 cuts
|
||||
|
||||
|
||||
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 |
@@ -73,6 +73,13 @@ export function postDownload(url, ids = '') {
|
||||
form.remove()
|
||||
}
|
||||
|
||||
// SHA-256 of a File, matching the server's content hash — used to skip
|
||||
// uploading bytes the album already has.
|
||||
export async function sha256Hex(file) {
|
||||
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer())
|
||||
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
export function uploadFile(url, file, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
@@ -1,42 +1,85 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { imgUrl } from '../api'
|
||||
|
||||
// Justified gallery: rows are built with flexbox, each tile's flex-grow is
|
||||
// proportional to its aspect ratio so rows fill the container edge to edge.
|
||||
// When `selected`/`onToggleSelect` are provided, tiles get a select checkmark;
|
||||
// selection state lives in the parent so it survives lightbox open/close.
|
||||
// True justified layout: pack photos greedily into rows at their real aspect
|
||||
// ratios, then scale each row's height so it fills the container width
|
||||
// exactly. No cropping, no stretch, and the last row simply renders at the
|
||||
// target height instead of being padded by a spacer.
|
||||
function layoutRows(photos, containerWidth, targetHeight, gap) {
|
||||
const rows = []
|
||||
let row = []
|
||||
let arSum = 0
|
||||
let index = 0
|
||||
for (const photo of photos) {
|
||||
const ar = photo.width && photo.height ? photo.width / photo.height : 1.5
|
||||
row.push({ photo, ar, index: index++ })
|
||||
arSum += ar
|
||||
const gaps = (row.length - 1) * gap
|
||||
if (arSum * targetHeight + gaps >= containerWidth) {
|
||||
rows.push({ items: row, height: (containerWidth - gaps) / arSum })
|
||||
row = []
|
||||
arSum = 0
|
||||
}
|
||||
}
|
||||
if (row.length > 0) {
|
||||
const gaps = (row.length - 1) * gap
|
||||
rows.push({
|
||||
items: row,
|
||||
height: Math.min(targetHeight, (containerWidth - gaps) / arSum),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
export default function Gallery({ photos, onOpen, overlay, selected, onToggleSelect }) {
|
||||
if (photos.length === 0) return null
|
||||
const containerRef = useRef(null)
|
||||
const [width, setWidth] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width))
|
||||
observer.observe(containerRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// 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) : []
|
||||
const selecting = selected && selected.size > 0
|
||||
|
||||
return (
|
||||
<div className={`gallery${selecting ? ' selecting' : ''}`}>
|
||||
{photos.map((p, i) => {
|
||||
const ar = p.width && p.height ? p.width / p.height : 1.5
|
||||
const isSelected = selected ? selected.has(p.id) : false
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`g-item${isSelected ? ' selected' : ''}`}
|
||||
style={{ '--ar': ar }}
|
||||
onClick={() => onOpen && onOpen(i)}
|
||||
>
|
||||
<img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} />
|
||||
{onToggleSelect && (
|
||||
<button
|
||||
className="g-check"
|
||||
title={isSelected ? 'Deselect' : 'Select'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleSelect(p.id)
|
||||
}}
|
||||
<div ref={containerRef} className={`gallery${selecting ? ' selecting' : ''}`}>
|
||||
{rows.map((row) => (
|
||||
<div key={row.items[0].photo.id} className="g-row" style={{ height: row.height }}>
|
||||
{row.items.map(({ photo: p, ar, index }) => {
|
||||
const isSelected = selected ? selected.has(p.id) : false
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`g-item${isSelected ? ' selected' : ''}`}
|
||||
style={{ width: ar * row.height }}
|
||||
onClick={() => onOpen && onOpen(index)}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
)}
|
||||
{overlay && overlay(p)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="g-spacer" />
|
||||
<img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} />
|
||||
{onToggleSelect && (
|
||||
<button
|
||||
className="g-check"
|
||||
title={isSelected ? 'Deselect' : 'Select'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleSelect(p.id, e.shiftKey)
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
)}
|
||||
{overlay && overlay(p)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,87 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { imgUrl } from '../api'
|
||||
import useSwipe from '../useSwipe'
|
||||
|
||||
export default function Lightbox({ photos, index, onClose, onNav, footer }) {
|
||||
const BASE_SHORTCUTS = [
|
||||
['← / →', 'previous / next photo'],
|
||||
['Space', 'next photo (Shift+Space back)'],
|
||||
['?', 'show / hide shortcuts'],
|
||||
['Esc', 'close'],
|
||||
]
|
||||
|
||||
// `actions` defines the page's shortcuts as one table — display and dispatch
|
||||
// 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,
|
||||
}) {
|
||||
const photo = photos[index]
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
|
||||
// Handlers and view state live in a ref, updated every render, so the
|
||||
// window listener is attached once yet always dispatches against current
|
||||
// values — re-subscribing per render leaves a gap until effects re-run in
|
||||
// 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 }
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'ArrowRight' && index < photos.length - 1) onNav(index + 1)
|
||||
if (e.key === 'ArrowLeft' && index > 0) onNav(index - 1)
|
||||
// Only text entry captures keys (Escape leaves the field); focus on a
|
||||
// checkbox or button must not disable lightbox navigation.
|
||||
const isTyping = (el) =>
|
||||
el.tagName === 'TEXTAREA' ||
|
||||
el.isContentEditable ||
|
||||
(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
|
||||
}
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return
|
||||
if (e.key === 'Escape') {
|
||||
if (s.showHelp) setShowHelp(false)
|
||||
else s.onClose()
|
||||
return
|
||||
}
|
||||
if (e.key === '?') {
|
||||
setShowHelp((h) => !h)
|
||||
return
|
||||
}
|
||||
// With the help overlay up, keys must not act on the photo behind it.
|
||||
if (s.showHelp) return
|
||||
if (e.key === 'ArrowRight' || (e.key === ' ' && !e.shiftKey)) {
|
||||
e.preventDefault()
|
||||
if (s.index < s.count - 1) s.onNav(s.index + 1)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowLeft' || (e.key === ' ' && e.shiftKey)) {
|
||||
e.preventDefault()
|
||||
if (s.index > 0) s.onNav(s.index - 1)
|
||||
return
|
||||
}
|
||||
const key = e.key.toLowerCase()
|
||||
const action = s.actions?.find((a) => a.keys.includes(key))
|
||||
if (action && s.photo) action.run(s.photo, key)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [index, photos.length, onClose, onNav])
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden'
|
||||
@@ -21,15 +90,53 @@ export default function Lightbox({ photos, index, onClose, onNav, footer }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
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">
|
||||
{index + 1} / {photos.length}
|
||||
</span>
|
||||
<button
|
||||
className="lb-btn"
|
||||
onClick={() => setShowHelp((h) => !h)}
|
||||
title="Keyboard shortcuts (?)"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
<button className="lb-btn" onClick={onClose} title="Close (Esc)">
|
||||
✕
|
||||
</button>
|
||||
@@ -44,13 +151,59 @@ export default function Lightbox({ photos, index, onClose, onNav, footer }) {
|
||||
>
|
||||
‹
|
||||
</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"
|
||||
@@ -67,6 +220,25 @@ export default function Lightbox({ photos, index, onClose, onNav, footer }) {
|
||||
{footer(photo)}
|
||||
</div>
|
||||
)}
|
||||
{showHelp && (
|
||||
<div
|
||||
className="lb-help"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowHelp(false)
|
||||
}}
|
||||
>
|
||||
<div className="lb-help-card">
|
||||
<h3>Keyboard shortcuts</h3>
|
||||
{[...(actions?.map((a) => a.help) || []), ...BASE_SHORTCUTS].map(([keys, label]) => (
|
||||
<div key={keys} className="lb-help-row">
|
||||
<kbd>{keys}</kbd>
|
||||
<span className="muted">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export default function SelectionBar({
|
||||
onClear,
|
||||
onDownload,
|
||||
onDownloadAll,
|
||||
onDelete,
|
||||
}) {
|
||||
if (total === 0) return null
|
||||
|
||||
@@ -47,6 +48,11 @@ export default function SelectionBar({
|
||||
<button className="btn btn-primary" onClick={onDownload}>
|
||||
Download {count} as ZIP ({fmtBytes(selectedBytes)})
|
||||
</button>
|
||||
{onDelete && (
|
||||
<button className="btn btn-danger" onClick={onDelete}>
|
||||
Delete {count}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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 }) {
|
||||
const thumb = (verdict, glyph, label) => (
|
||||
<button
|
||||
type="button"
|
||||
className={`thumb${value === verdict ? ' active' : ''}`}
|
||||
disabled={!onChange}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange(value === verdict ? null : verdict)
|
||||
}}
|
||||
title={onChange ? label : undefined}
|
||||
>
|
||||
{glyph}
|
||||
</button>
|
||||
)
|
||||
return (
|
||||
<span className={`thumbs${small ? ' thumbs-small' : ''}`}>
|
||||
{thumb('accept', ACCEPT_GLYPH, 'Accept (P)')}
|
||||
{thumb('reject', REJECT_GLYPH, 'Reject (X)')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +1,127 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { api, postDownload, uploadFile } from '../api'
|
||||
import { api, postDownload, sha256Hex, uploadFile } from '../api'
|
||||
import Gallery from '../components/Gallery'
|
||||
import Lightbox from '../components/Lightbox'
|
||||
import SelectionBar from '../components/SelectionBar'
|
||||
import SelectionBar, { fmtBytes } from '../components/SelectionBar'
|
||||
import Stars from '../components/Stars'
|
||||
import Thumbs from '../components/Thumbs'
|
||||
import useLightbox from '../useLightbox'
|
||||
import useSelection from '../useSelection'
|
||||
|
||||
function fmtEta(seconds) {
|
||||
if (!isFinite(seconds) || seconds < 0) return ''
|
||||
if (seconds < 60) return `${Math.ceil(seconds)}s`
|
||||
if (seconds < 3600) return `${Math.ceil(seconds / 60)} min`
|
||||
return `${Math.floor(seconds / 3600)}h ${Math.ceil((seconds % 3600) / 60)} min`
|
||||
}
|
||||
|
||||
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:
|
||||
// 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)
|
||||
const [speed, setSpeed] = useState(0)
|
||||
const inputRef = useRef(null)
|
||||
const running = useRef(0)
|
||||
const pending = useRef([])
|
||||
const lastRefresh = useRef(0)
|
||||
const loadedRef = useRef(0)
|
||||
|
||||
const totalBytes = queue.reduce((sum, item) => sum + item.file.size, 0)
|
||||
const loadedBytes = queue.reduce(
|
||||
(sum, item) =>
|
||||
sum + (item.status === 'done' ? item.file.size : (item.progress || 0) * item.file.size),
|
||||
0,
|
||||
)
|
||||
loadedRef.current = loadedBytes
|
||||
const active = queue.some((item) =>
|
||||
['uploading', 'queued', 'checking'].includes(item.status),
|
||||
)
|
||||
|
||||
// Sample throughput once a second (EMA-smoothed) while uploads run.
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setSpeed(0)
|
||||
return
|
||||
}
|
||||
let last = { loaded: loadedRef.current, time: Date.now() }
|
||||
const timer = setInterval(() => {
|
||||
const now = Date.now()
|
||||
const instant = (loadedRef.current - last.loaded) / ((now - last.time) / 1000)
|
||||
last = { loaded: loadedRef.current, time: now }
|
||||
setSpeed((prev) => (prev > 0 ? prev * 0.7 + instant * 0.3 : instant))
|
||||
}, 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [active])
|
||||
|
||||
// Refresh the album at most every 5s during a bulk upload (the processing
|
||||
// poll keeps it fresh anyway), plus once when the queue drains.
|
||||
@@ -31,23 +137,27 @@ function UploadZone({ albumId, onUploaded }) {
|
||||
while (running.current < UPLOAD_CONCURRENCY && pending.current.length > 0) {
|
||||
const item = pending.current.shift()
|
||||
running.current += 1
|
||||
setQueue((q) =>
|
||||
q.map((x) => (x.key === item.key ? { ...x, status: 'uploading' } : x)),
|
||||
)
|
||||
const url = `/api/albums/${albumId}/photos?filename=${encodeURIComponent(item.file.name)}`
|
||||
uploadFile(url, item.file, (p) =>
|
||||
setQueue((q) => q.map((x) => (x.key === item.key ? { ...x, progress: p } : x))),
|
||||
)
|
||||
.then(() =>
|
||||
setQueue((q) =>
|
||||
q.map((x) => (x.key === item.key ? { ...x, status: 'done', progress: 1 } : x)),
|
||||
),
|
||||
)
|
||||
.catch((e) =>
|
||||
setQueue((q) =>
|
||||
q.map((x) => (x.key === item.key ? { ...x, status: 'error', error: e.message } : x)),
|
||||
),
|
||||
)
|
||||
const update = (patch) =>
|
||||
setQueue((q) => q.map((x) => (x.key === item.key ? { ...x, ...patch } : x)))
|
||||
const transfer = async () => {
|
||||
// Hash locally first: content the album already has is skipped
|
||||
// without transferring a single byte.
|
||||
update({ status: 'checking' })
|
||||
try {
|
||||
const hash = await sha256Hex(item.file)
|
||||
await api(`/api/albums/${albumId}/photos/by-hash/${hash}`)
|
||||
update({ status: 'skipped', progress: 1 })
|
||||
return
|
||||
} catch {
|
||||
// 404 (not there yet) or hashing unavailable — upload normally.
|
||||
}
|
||||
update({ status: 'uploading' })
|
||||
const url = `/api/albums/${albumId}/photos?filename=${encodeURIComponent(item.file.name)}`
|
||||
await uploadFile(url, item.file, (p) => update({ progress: p }))
|
||||
update({ status: 'done', progress: 1 })
|
||||
}
|
||||
transfer()
|
||||
.catch((e) => update({ status: 'error', error: e.message }))
|
||||
.finally(() => {
|
||||
running.current -= 1
|
||||
refresh()
|
||||
@@ -95,6 +205,22 @@ function UploadZone({ albumId, onUploaded }) {
|
||||
}}
|
||||
/>
|
||||
<p>Drop RAWs or JPGs here, or click to select</p>
|
||||
{queue.length > 0 && (
|
||||
<div className="upload-summary" onClick={(e) => e.stopPropagation()}>
|
||||
<progress className="upload-total" value={loadedBytes} max={totalBytes || 1} />
|
||||
<span className="muted">
|
||||
{queue.filter((i) => i.status === 'done' || i.status === 'skipped').length} /{' '}
|
||||
{queue.length} files ·{' '}
|
||||
{fmtBytes(loadedBytes)} of {fmtBytes(totalBytes)}
|
||||
{active && speed > 0 && (
|
||||
<>
|
||||
{' · '}
|
||||
{fmtBytes(speed)}/s · ~{fmtEta((totalBytes - loadedBytes) / speed)} left
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{queue.length > 0 && (
|
||||
<ul className="upload-list" onClick={(e) => e.stopPropagation()}>
|
||||
{queue.map((item) => (
|
||||
@@ -102,6 +228,10 @@ function UploadZone({ albumId, onUploaded }) {
|
||||
<span className="upload-name">{item.file.name}</span>
|
||||
{item.status === 'error' ? (
|
||||
<span className="error">{item.error}</span>
|
||||
) : item.status === 'skipped' ? (
|
||||
<span className="muted">already uploaded</span>
|
||||
) : item.status === 'checking' ? (
|
||||
<span className="muted">checking…</span>
|
||||
) : (
|
||||
<progress value={item.progress} max="1" />
|
||||
)}
|
||||
@@ -136,11 +266,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: '' })
|
||||
@@ -157,6 +283,18 @@ function SharesPanel({ albumId }) {
|
||||
setTimeout(() => setCopied(null), 1500)
|
||||
}
|
||||
|
||||
const update = async (shareId, patch) => {
|
||||
try {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<h2>Client links</h2>
|
||||
@@ -171,15 +309,20 @@ function SharesPanel({ albumId }) {
|
||||
<span className="muted">
|
||||
{s.has_password ? '🔒 password' : 'no password'}
|
||||
{' · '}
|
||||
{s.allow_download ? 'downloads on' : 'downloads off'}
|
||||
{s.expires_at
|
||||
? ` · expires ${new Date(s.expires_at).toLocaleDateString()}`
|
||||
: ' · never expires'}
|
||||
{' · '}
|
||||
{s.rating_count} ratings, {s.tag_count} tags
|
||||
{s.rating_count} ratings, {s.tag_count} tags, 👍 {s.accept_count} 👎{' '}
|
||||
{s.reject_count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="row">
|
||||
<label className="select-toggle" title="Allow this link to download originals/ZIPs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s.allow_download}
|
||||
onChange={(e) => update(s.id, { allow_download: e.target.checked })}
|
||||
/>
|
||||
downloads
|
||||
</label>
|
||||
<ExpiryDate value={s.expires_at} onCommit={(iso) => update(s.id, { expires_at: iso })} />
|
||||
{s.locked && (
|
||||
<button
|
||||
className="btn"
|
||||
@@ -260,9 +403,6 @@ export default function AlbumPage() {
|
||||
const navigate = useNavigate()
|
||||
const [detail, setDetail] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
// Track the open photo by id, not index — the polling refetch can reorder
|
||||
// the array underneath an open lightbox.
|
||||
const [lightboxId, setLightboxId] = useState(null)
|
||||
|
||||
const load = useCallback(
|
||||
() => api(`/api/albums/${id}`).then(setDetail).catch((e) => setError(e.message)),
|
||||
@@ -281,21 +421,7 @@ export default function AlbumPage() {
|
||||
}, [hasPending, load])
|
||||
|
||||
const ready = (detail?.photos ?? []).filter((p) => p.status === 'ready')
|
||||
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(ready)
|
||||
const lightboxIndex = ready.findIndex((p) => p.id === lightboxId)
|
||||
|
||||
// If the open photo leaves the ready list (deleted elsewhere, reprocess),
|
||||
// close for good — otherwise the lightbox would pop back open when the
|
||||
// photo returns to ready.
|
||||
useEffect(() => {
|
||||
if (lightboxId && lightboxIndex < 0) setLightboxId(null)
|
||||
}, [lightboxId, lightboxIndex])
|
||||
|
||||
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 feedback = detail?.feedback ?? {}
|
||||
|
||||
const avgRating = (photoId) => {
|
||||
const ratings = feedback[photoId]?.ratings || []
|
||||
@@ -303,6 +429,44 @@ export default function AlbumPage() {
|
||||
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 name = prompt('Album name', album.name)
|
||||
if (name && name.trim()) {
|
||||
@@ -319,11 +483,24 @@ export default function AlbumPage() {
|
||||
|
||||
const removePhoto = async (photoId) => {
|
||||
if (!confirm('Delete this photo?')) return
|
||||
setLightboxId(null)
|
||||
lightbox.close()
|
||||
await api(`/api/photos/${photoId}`, { method: 'DELETE' })
|
||||
load()
|
||||
}
|
||||
|
||||
const removeSelected = async () => {
|
||||
const ids = shownSelected.map((p) => p.id)
|
||||
if (!confirm(`Delete ${ids.length} selected photo${ids.length === 1 ? '' : 's'}? This cannot be undone.`))
|
||||
return
|
||||
try {
|
||||
await api('/api/photos/delete', { method: 'POST', body: { ids } })
|
||||
clear()
|
||||
} catch (e) {
|
||||
alert(`Delete failed: ${e.message}`)
|
||||
}
|
||||
load()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
@@ -374,17 +551,43 @@ export default function AlbumPage() {
|
||||
</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
|
||||
photos={ready}
|
||||
onOpen={(i) => setLightboxId(ready[i].id)}
|
||||
photos={shown}
|
||||
onOpen={lightbox.openAt}
|
||||
selected={selected}
|
||||
onToggleSelect={toggle}
|
||||
onToggleSelect={(photoId, shift) => toggle(photoId, shift, shown)}
|
||||
overlay={(p) => {
|
||||
const avg = avgRating(p.id)
|
||||
const tagCount = feedback[p.id]?.tags.length || 0
|
||||
if (avg === null && tagCount === 0) return null
|
||||
const verdicts = feedback[p.id]?.verdicts || []
|
||||
const accepts = verdicts.filter((v) => v.verdict === 'accept').length
|
||||
const rejects = verdicts.length - accepts
|
||||
if (avg === null && tagCount === 0 && accepts === 0 && rejects === 0) return null
|
||||
return (
|
||||
<div className="g-overlay">
|
||||
{accepts > 0 && <span>👍 {accepts}</span>}
|
||||
{rejects > 0 && <span>👎 {rejects}</span>}
|
||||
{avg !== null && <span>★ {avg.toFixed(1)}</span>}
|
||||
{tagCount > 0 && <span># {tagCount}</span>}
|
||||
</div>
|
||||
@@ -394,32 +597,48 @@ export default function AlbumPage() {
|
||||
{ready.length === 0 && notReady.length === 0 && (
|
||||
<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
|
||||
count={selected.size}
|
||||
total={ready.length}
|
||||
selectedBytes={selectedBytes}
|
||||
totalBytes={totalBytes}
|
||||
onSelectAll={selectAll}
|
||||
count={shownSelected.length}
|
||||
total={shown.length}
|
||||
selectedBytes={sumBytes(shownSelected)}
|
||||
totalBytes={sumBytes(shown)}
|
||||
onSelectAll={() => selectAll(shown)}
|
||||
onClear={clear}
|
||||
onDownload={() => postDownload(`/api/albums/${id}/zip`, [...selected].join(','))}
|
||||
onDownloadAll={() => postDownload(`/api/albums/${id}/zip`)}
|
||||
onDownload={() =>
|
||||
postDownload(`/api/albums/${id}/zip`, shownSelected.map((p) => p.id).join(','))
|
||||
}
|
||||
onDownloadAll={() =>
|
||||
postDownload(`/api/albums/${id}/zip`, filter === 'all' ? '' : shown.map((p) => p.id).join(','))
|
||||
}
|
||||
onDelete={removeSelected}
|
||||
/>
|
||||
|
||||
{lightboxIndex >= 0 && (
|
||||
{lightbox.index >= 0 && (
|
||||
<Lightbox
|
||||
photos={ready}
|
||||
index={lightboxIndex}
|
||||
onClose={() => setLightboxId(null)}
|
||||
onNav={(i) => setLightboxId(ready[i].id)}
|
||||
photos={shown}
|
||||
index={lightbox.index}
|
||||
onClose={lightbox.close}
|
||||
onNav={lightbox.openAt}
|
||||
actions={[
|
||||
{ keys: ['s'], help: ['S', 'select for download'], run: (p) => toggle(p.id) },
|
||||
]}
|
||||
footer={(p) => {
|
||||
const fb = feedback[p.id] || { ratings: [], tags: [] }
|
||||
const fb = feedback[p.id] || { ratings: [], verdicts: [], tags: [] }
|
||||
return (
|
||||
<div className="admin-footer">
|
||||
<div className="feedback">
|
||||
{fb.ratings.length === 0 && fb.tags.length === 0 && (
|
||||
{fb.ratings.length === 0 && fb.verdicts.length === 0 && fb.tags.length === 0 && (
|
||||
<span className="muted">No client feedback yet</span>
|
||||
)}
|
||||
{fb.verdicts.map((v, i) => (
|
||||
<span key={`v${i}`} className="feedback-item">
|
||||
{v.share_label || 'client'}: <Thumbs value={v.verdict} small />
|
||||
</span>
|
||||
))}
|
||||
{fb.ratings.map((r, i) => (
|
||||
<span key={`r${i}`} className="feedback-item">
|
||||
{r.share_label || 'client'}: <Stars value={r.rating} small />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { api, postDownload } from '../api'
|
||||
import Gallery from '../components/Gallery'
|
||||
@@ -6,18 +6,49 @@ import Lightbox from '../components/Lightbox'
|
||||
import SelectionBar from '../components/SelectionBar'
|
||||
import Stars from '../components/Stars'
|
||||
import TagEditor from '../components/TagEditor'
|
||||
import Thumbs from '../components/Thumbs'
|
||||
import useLightbox from '../useLightbox'
|
||||
import useSelection from '../useSelection'
|
||||
|
||||
const FILTERS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'accept', label: '👍' },
|
||||
{ key: 'reject', label: '👎' },
|
||||
{ key: 'undecided', label: 'Undecided' },
|
||||
]
|
||||
|
||||
const matchesFilter = (photo, key) =>
|
||||
key === 'all' || (key === 'undecided' ? !photo.my_verdict : photo.my_verdict === key)
|
||||
|
||||
export default function SharePage() {
|
||||
const { token } = useParams()
|
||||
const [view, setView] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [password, setPassword] = useState('')
|
||||
const [unlockError, setUnlockError] = useState(null)
|
||||
const [lightbox, setLightbox] = useState(-1)
|
||||
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(
|
||||
view?.photos ?? [],
|
||||
)
|
||||
const [filter, setFilter] = useState('all')
|
||||
|
||||
const photos = view?.photos ?? []
|
||||
const visible = useMemo(() => photos.filter((p) => matchesFilter(p, filter)), [photos, filter])
|
||||
const filterCounts = useMemo(() => {
|
||||
const counts = { all: photos.length, accept: 0, reject: 0, undecided: 0 }
|
||||
for (const p of photos) counts[p.my_verdict ?? 'undecided'] += 1
|
||||
return counts
|
||||
}, [photos])
|
||||
|
||||
// Selection spans the whole album (so switching filters keeps it), while
|
||||
// the selection bar describes only the current view: its counts, bytes and
|
||||
// downloads cover the visible photos, and "select all" adds them.
|
||||
const { selected, toggle, selectAll, clear } = useSelection(photos)
|
||||
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)),
|
||||
@@ -45,29 +76,77 @@ export default function SharePage() {
|
||||
}))
|
||||
}
|
||||
|
||||
const setRating = async (photo, rating) => {
|
||||
patchPhoto(photo.id, { my_rating: rating || null })
|
||||
// Optimistic write: patch local state, PUT, reload from the server on
|
||||
// failure to undo the patch.
|
||||
const saveFeedback = async (photo, patch, endpoint, body) => {
|
||||
patchPhoto(photo.id, patch)
|
||||
try {
|
||||
await api(`/api/share/${token}/photos/${photo.id}/rating`, {
|
||||
method: 'PUT',
|
||||
body: { rating },
|
||||
})
|
||||
await api(`/api/share/${token}/photos/${photo.id}/${endpoint}`, { method: 'PUT', body })
|
||||
} catch {
|
||||
load()
|
||||
}
|
||||
}
|
||||
const setRating = (photo, rating) =>
|
||||
saveFeedback(photo, { my_rating: rating || null }, 'rating', { rating })
|
||||
const setVerdict = (photo, verdict) =>
|
||||
saveFeedback(photo, { my_verdict: verdict }, 'verdict', { verdict })
|
||||
const setTags = (photo, tags) => saveFeedback(photo, { my_tags: tags }, 'tags', { tags })
|
||||
|
||||
// Keyboard votes navigate away from the photo they change, so a transient
|
||||
// toast names what just happened to it — without it the jump reads as
|
||||
// "did that register?".
|
||||
const [flash, setFlash] = useState(null)
|
||||
const flashSeq = useRef(0)
|
||||
const flashTimer = useRef()
|
||||
const showFlash = (text) => {
|
||||
flashSeq.current += 1
|
||||
setFlash({ text, key: flashSeq.current })
|
||||
clearTimeout(flashTimer.current)
|
||||
flashTimer.current = setTimeout(() => setFlash(null), 1400)
|
||||
}
|
||||
useEffect(() => () => clearTimeout(flashTimer.current), [])
|
||||
|
||||
// Culling flow: vote, then advance to the photo that was next in the
|
||||
// current view. Under a filter that hides the voted photo, the advance
|
||||
// target stays visible, so the run continues seamlessly.
|
||||
const voteAndAdvance = (photo, verdict) => {
|
||||
const next = visible[lightbox.index + 1]
|
||||
setVerdict(photo, verdict)
|
||||
showFlash(
|
||||
verdict === 'accept'
|
||||
? `👍 ${photo.filename}`
|
||||
: verdict === 'reject'
|
||||
? `👎 ${photo.filename}`
|
||||
: `↺ ${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 setTags = async (photo, tags) => {
|
||||
patchPhoto(photo.id, { my_tags: tags })
|
||||
try {
|
||||
await api(`/api/share/${token}/photos/${photo.id}/tags`, {
|
||||
method: 'PUT',
|
||||
body: { tags },
|
||||
})
|
||||
} catch {
|
||||
load()
|
||||
}
|
||||
}
|
||||
const keyActions = [
|
||||
{ keys: ['p'], help: ['P', 'accept and go to next'], run: (p) => voteAndAdvance(p, 'accept') },
|
||||
{ keys: ['x'], help: ['X', 'reject and go to next'], run: (p) => voteAndAdvance(p, 'reject') },
|
||||
{
|
||||
keys: ['u'],
|
||||
help: ['U', 'clear accept / reject'],
|
||||
// When clearing hides the photo from the current filter, advance like
|
||||
// a vote so the lightbox doesn't just close.
|
||||
run: (p) =>
|
||||
matchesFilter({ my_verdict: null }, filter)
|
||||
? setVerdict(p, null)
|
||||
: voteAndAdvance(p, null),
|
||||
},
|
||||
{
|
||||
keys: ['1', '2', '3', '4', '5'],
|
||||
help: ['1–5', 'star rating'],
|
||||
run: (p, key) => setRating(p, Number(key)),
|
||||
},
|
||||
{ keys: ['0'], help: ['0', 'clear star rating'], run: (p) => setRating(p, 0) },
|
||||
...(view?.allow_download
|
||||
? [{ keys: ['s'], help: ['S', 'select for download'], run: (p) => toggle(p.id) }]
|
||||
: []),
|
||||
]
|
||||
|
||||
if (error) return <div className="center-page">{error}</div>
|
||||
if (!view) return <div className="center-page">Loading…</div>
|
||||
@@ -100,49 +179,89 @@ export default function SharePage() {
|
||||
<h1>{view.album_name}</h1>
|
||||
{view.album_description && <p className="muted">{view.album_description}</p>}
|
||||
<p className="muted">
|
||||
{view.photos.length} photo{view.photos.length === 1 ? '' : 's'} · click a photo to view,
|
||||
rate and tag
|
||||
{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">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
className={`filter-chip${filter === f.key ? ' active' : ''}`}
|
||||
onClick={() => setFilter(f.key)}
|
||||
>
|
||||
{f.label} {filterCounts[f.key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<main className="page">
|
||||
<Gallery
|
||||
photos={view.photos}
|
||||
onOpen={setLightbox}
|
||||
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_rating || p.my_tags.length > 0 ? (
|
||||
p.my_verdict || p.my_rating || p.my_tags.length > 0 ? (
|
||||
<div className="g-overlay">
|
||||
{p.my_verdict && <span>{p.my_verdict === 'accept' ? '👍' : '👎'}</span>}
|
||||
{p.my_rating && <span>★ {p.my_rating}</span>}
|
||||
{p.my_tags.length > 0 && <span># {p.my_tags.length}</span>}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{view.photos.length === 0 && (
|
||||
{photos.length === 0 && (
|
||||
<p className="center-page muted">Nothing here yet — check back soon.</p>
|
||||
)}
|
||||
{photos.length > 0 && visible.length === 0 && (
|
||||
<p className="center-page muted">No photos match this filter.</p>
|
||||
)}
|
||||
</main>
|
||||
{view.allow_download && (
|
||||
<SelectionBar
|
||||
count={selected.size}
|
||||
total={view.photos.length}
|
||||
selectedBytes={selectedBytes}
|
||||
totalBytes={totalBytes}
|
||||
onSelectAll={selectAll}
|
||||
count={visibleSelected.length}
|
||||
total={visible.length}
|
||||
selectedBytes={sumBytes(visibleSelected)}
|
||||
totalBytes={sumBytes(visible)}
|
||||
onSelectAll={() => selectAll(visible)}
|
||||
onClear={clear}
|
||||
onDownload={() => postDownload(`/api/share/${token}/zip`, [...selected].join(','))}
|
||||
onDownloadAll={() => postDownload(`/api/share/${token}/zip`)}
|
||||
onDownload={() =>
|
||||
postDownload(`/api/share/${token}/zip`, visibleSelected.map((p) => p.id).join(','))
|
||||
}
|
||||
onDownloadAll={() =>
|
||||
// Under a filter, "download all" means all photos shown.
|
||||
postDownload(
|
||||
`/api/share/${token}/zip`,
|
||||
filter === 'all' ? '' : visible.map((p) => p.id).join(','),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{lightbox >= 0 && (
|
||||
{lightbox.index >= 0 && (
|
||||
<Lightbox
|
||||
photos={view.photos}
|
||||
index={lightbox}
|
||||
onClose={() => setLightbox(-1)}
|
||||
onNav={setLightbox}
|
||||
photos={visible}
|
||||
index={lightbox.index}
|
||||
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)} />
|
||||
<Stars value={p.my_rating || 0} onChange={(r) => setRating(p, r)} />
|
||||
<TagEditor tags={p.my_tags} onChange={(tags) => setTags(p, tags)} />
|
||||
{view.allow_download && (
|
||||
@@ -164,6 +283,11 @@ export default function SharePage() {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{flash && (
|
||||
<div key={flash.key} className="action-flash">
|
||||
{flash.text}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+304
-14
@@ -191,15 +191,17 @@ input:focus {
|
||||
/* justified gallery */
|
||||
.gallery {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.g-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.g-item {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
flex-grow: calc(var(--ar) * 100);
|
||||
flex-basis: calc(var(--ar) * 240px);
|
||||
flex: none;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
@@ -211,11 +213,6 @@ input:focus {
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.g-spacer {
|
||||
flex-grow: 1000000;
|
||||
flex-basis: 0;
|
||||
height: 0;
|
||||
}
|
||||
.g-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@@ -310,6 +307,18 @@ input:focus {
|
||||
.upload-zone p {
|
||||
margin: 0;
|
||||
}
|
||||
.upload-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin-top: 1rem;
|
||||
cursor: default;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.upload-total {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.upload-list {
|
||||
list-style: none;
|
||||
margin: 1rem 0 0;
|
||||
@@ -442,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;
|
||||
@@ -453,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%;
|
||||
@@ -525,6 +619,150 @@ progress {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* thumbs (accept / reject) */
|
||||
.thumbs {
|
||||
display: inline-flex;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.thumb {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.15rem;
|
||||
cursor: pointer;
|
||||
filter: grayscale(1);
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.thumb:hover:enabled {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.thumb.active {
|
||||
filter: none;
|
||||
opacity: 1;
|
||||
}
|
||||
.thumb:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.thumbs-small .thumb {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* verdict filter */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
.filter-chip {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-2);
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
padding: 0.25rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.filter-chip.active {
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
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) */
|
||||
.action-flash {
|
||||
position: fixed;
|
||||
top: 3rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 110;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-2);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
animation: flash-fade 1.4s ease forwards;
|
||||
}
|
||||
@keyframes flash-fade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -6px);
|
||||
}
|
||||
8%,
|
||||
70% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* lightbox shortcut help */
|
||||
.lb-help {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(8, 9, 11, 0.6);
|
||||
z-index: 102;
|
||||
}
|
||||
.lb-help-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-2);
|
||||
border-radius: 12px;
|
||||
padding: 1.1rem 1.5rem 1.25rem;
|
||||
min-width: 280px;
|
||||
cursor: default;
|
||||
}
|
||||
.lb-help-card h3 {
|
||||
font-size: 0.95rem;
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
.lb-help-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
padding: 0.18rem 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
kbd {
|
||||
background: var(--panel-2);
|
||||
border-radius: 4px;
|
||||
padding: 0.08rem 0.45rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* tags */
|
||||
.tag-editor {
|
||||
display: inline-flex;
|
||||
@@ -555,16 +793,68 @@ progress {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.g-item {
|
||||
height: 160px;
|
||||
flex-basis: calc(var(--ar) * 160px);
|
||||
/* 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-stage {
|
||||
.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 {
|
||||
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));
|
||||
}
|
||||
/* 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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// Lightbox state tracked by photo id, not index — the list can reorder
|
||||
// (polling refetch) or shrink (filter change, delete) underneath an open
|
||||
// lightbox. When the open photo leaves the list, close for good — otherwise
|
||||
// the lightbox would pop back open when the photo returns to the list.
|
||||
export default function useLightbox(photos) {
|
||||
const [openId, setOpenId] = useState(null)
|
||||
const index = openId ? photos.findIndex((p) => p.id === openId) : -1
|
||||
|
||||
useEffect(() => {
|
||||
if (openId && index < 0) setOpenId(null)
|
||||
}, [openId, index])
|
||||
|
||||
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),
|
||||
show: (id) => setOpenId(id),
|
||||
close: () => setOpenId(null),
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
// Multi-select over a photo list. Selection lives here (not in the gallery)
|
||||
// so it survives lightbox open/close, and is pruned automatically when
|
||||
// photos disappear from the list (deletes, polling refreshes).
|
||||
export default function useSelection(photos) {
|
||||
const [selected, setSelected] = useState(() => new Set())
|
||||
// Anchor for shift-click range selection: the photo last toggled.
|
||||
const lastToggled = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
setSelected((prev) => {
|
||||
@@ -15,16 +17,43 @@ export default function useSelection(photos) {
|
||||
})
|
||||
}, [photos])
|
||||
|
||||
const toggle = (photoId) =>
|
||||
// Shift-toggle selects the whole range from the previously toggled photo
|
||||
// (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 (next.has(photoId)) next.delete(photoId)
|
||||
else next.add(photoId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const selectAll = () => setSelected(new Set(photos.map((p) => p.id)))
|
||||
const clear = () => setSelected(new Set())
|
||||
// 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 = () => {
|
||||
// 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()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Content hashes: sha256 powers duplicate-upload detection (same content in
|
||||
-- the same album is returned instead of copied); crc32 lets zip downloads
|
||||
-- stream originals straight from S3 without a local spool pass.
|
||||
-- Both are null for photos uploaded before this migration; they self-heal on
|
||||
-- reprocess (sha256 + crc32) and on zip download (crc32).
|
||||
alter table photos add column sha256 text;
|
||||
alter table photos add column crc32 bigint;
|
||||
|
||||
create unique index photos_album_sha_uidx on photos (album_id, sha256)
|
||||
where sha256 is not null;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Client accept/reject votes, one per (link, photo) — a separate axis from
|
||||
-- the 1-5 star rating so a photo can be e.g. accepted but unrated.
|
||||
create table verdicts (
|
||||
share_id uuid not null references shares(id) on delete cascade,
|
||||
photo_id uuid not null references photos(id) on delete cascade,
|
||||
verdict text not null check (verdict in ('accept', 'reject')),
|
||||
updated_at timestamptz not null default now(),
|
||||
primary key (share_id, photo_id)
|
||||
);
|
||||
|
||||
-- Cascaded photo deletes fire per-row FK triggers; without this each one
|
||||
-- sequential-scans the table.
|
||||
create index verdicts_photo_idx on verdicts (photo_id);
|
||||
+26
-3
@@ -1,4 +1,8 @@
|
||||
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;
|
||||
|
||||
@@ -17,11 +21,30 @@ async fn main() -> anyhow::Result<()> {
|
||||
let state = AppState::new(config).await?;
|
||||
|
||||
let static_dir = state.config.static_dir.clone();
|
||||
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 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 app = photos::routes::router(&state)
|
||||
.nest_service("/assets", assets)
|
||||
.fallback_service(spa)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state.clone());
|
||||
|
||||
+37
-4
@@ -62,7 +62,24 @@ pub async fn process_photo(state: &AppState, photo_id: Uuid) -> anyhow::Result<(
|
||||
.unwrap_or("bin")
|
||||
.to_lowercase();
|
||||
let src_path = dir.path().join(format!("original.{extension}"));
|
||||
download(state, &s3::original_key(photo_id, &photo.filename), &src_path).await?;
|
||||
let (sha256, crc32) =
|
||||
download(state, &s3::original_key(photo_id, &photo.filename), &src_path).await?;
|
||||
if photo.sha256.is_none() || photo.crc32.is_none() {
|
||||
// Best-effort backfill for pre-hash photos; a duplicate in the same
|
||||
// album trips the unique index, which is fine — skip silently.
|
||||
if let Err(e) = sqlx::query(
|
||||
"update photos set sha256 = coalesce(sha256, $2), crc32 = coalesce(crc32, $3)
|
||||
where id = $1",
|
||||
)
|
||||
.bind(photo_id)
|
||||
.bind(&sha256)
|
||||
.bind(crc32)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
tracing::debug!("hash backfill skipped for {photo_id}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
let meta = exif_metadata(&src_path).await?;
|
||||
|
||||
@@ -103,7 +120,11 @@ pub async fn process_photo(state: &AppState, photo_id: Uuid) -> anyhow::Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download(state: &AppState, key: &str, path: &Path) -> anyhow::Result<()> {
|
||||
/// Download the original, hashing along the way so legacy photos (uploaded
|
||||
/// before hashes existed) can be backfilled.
|
||||
async fn download(state: &AppState, key: &str, path: &Path) -> anyhow::Result<(String, i64)> {
|
||||
use sha2::Digest;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let object = state
|
||||
.s3
|
||||
.get_object()
|
||||
@@ -114,8 +135,20 @@ async fn download(state: &AppState, key: &str, path: &Path) -> anyhow::Result<()
|
||||
.with_context(|| format!("fetching s3://{}/{key}", state.config.s3_bucket))?;
|
||||
let mut reader = object.body.into_async_read();
|
||||
let mut file = tokio::fs::File::create(path).await?;
|
||||
tokio::io::copy(&mut reader, &mut file).await?;
|
||||
Ok(())
|
||||
let mut sha = sha2::Sha256::new();
|
||||
let mut crc = crc32fast::Hasher::new();
|
||||
let mut buf = vec![0u8; 128 * 1024];
|
||||
loop {
|
||||
let n = reader.read(&mut buf).await?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
sha.update(&buf[..n]);
|
||||
crc.update(&buf[..n]);
|
||||
file.write_all(&buf[..n]).await?;
|
||||
}
|
||||
file.flush().await?;
|
||||
Ok((hex::encode(sha.finalize()), i64::from(crc.finalize())))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
|
||||
+24
-1
@@ -1,5 +1,5 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Stored as text in Postgres; decoded via TryFrom so an unknown value is a
|
||||
@@ -47,6 +47,25 @@ impl TryFrom<String> for PhotoStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// Client accept/reject vote. Same convention as PhotoStatus: text in
|
||||
/// Postgres (check-constrained), this enum everywhere Rust touches the value
|
||||
/// — serde rejects anything but "accept"/"reject" at the API boundary.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Verdict {
|
||||
Accept,
|
||||
Reject,
|
||||
}
|
||||
|
||||
impl Verdict {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Accept => "accept",
|
||||
Self::Reject => "reject",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum JobKind {
|
||||
ProcessPhoto,
|
||||
@@ -115,6 +134,10 @@ pub struct Photo {
|
||||
pub height: Option<i32>,
|
||||
pub taken_at: Option<DateTime<Utc>>,
|
||||
pub processed_at: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing)]
|
||||
pub sha256: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
pub crc32: Option<i64>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
|
||||
+78
-46
@@ -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)]
|
||||
@@ -79,12 +79,40 @@ pub struct ShareTag {
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ShareVerdict {
|
||||
pub share_label: String,
|
||||
pub verdict: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Default)]
|
||||
pub struct PhotoFeedback {
|
||||
pub ratings: Vec<ShareRating>,
|
||||
pub verdicts: Vec<ShareVerdict>,
|
||||
pub tags: Vec<ShareTag>,
|
||||
}
|
||||
|
||||
async fn feedback_rows<T>(
|
||||
db: &sqlx::PgPool,
|
||||
sql: &str,
|
||||
album_id: Uuid,
|
||||
) -> Result<Vec<(Uuid, String, T)>, sqlx::Error>
|
||||
where
|
||||
(Uuid, String, T): for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow> + Send + Unpin,
|
||||
{
|
||||
sqlx::query_as(sql).bind(album_id).fetch_all(db).await
|
||||
}
|
||||
|
||||
fn fold_feedback<T>(
|
||||
feedback: &mut HashMap<Uuid, PhotoFeedback>,
|
||||
rows: Vec<(Uuid, String, T)>,
|
||||
push: impl Fn(&mut PhotoFeedback, String, T),
|
||||
) {
|
||||
for (photo_id, share_label, value) in rows {
|
||||
push(feedback.entry(photo_id).or_default(), share_label, value);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AlbumDetail {
|
||||
pub album: Album,
|
||||
@@ -108,41 +136,49 @@ pub async fn get_one(
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
// The three feedback kinds are independent (photo_id, share label, value)
|
||||
// queries — run them concurrently and fold with one shared shape.
|
||||
let (ratings, verdicts, tags) = tokio::try_join!(
|
||||
feedback_rows::<i32>(
|
||||
&state.db,
|
||||
"select r.photo_id, s.label, r.rating
|
||||
from ratings r join shares s on s.id = r.share_id
|
||||
where s.album_id = $1",
|
||||
album_id,
|
||||
),
|
||||
feedback_rows::<String>(
|
||||
&state.db,
|
||||
"select v.photo_id, s.label, v.verdict
|
||||
from verdicts v join shares s on s.id = v.share_id
|
||||
where s.album_id = $1",
|
||||
album_id,
|
||||
),
|
||||
feedback_rows::<String>(
|
||||
&state.db,
|
||||
"select t.photo_id, s.label, t.tag
|
||||
from tags t join shares s on s.id = t.share_id
|
||||
where s.album_id = $1
|
||||
order by t.created_at",
|
||||
album_id,
|
||||
),
|
||||
)?;
|
||||
|
||||
let mut feedback: HashMap<Uuid, PhotoFeedback> = HashMap::new();
|
||||
let ratings: Vec<(Uuid, String, i32)> = sqlx::query_as(
|
||||
"select r.photo_id, s.label, r.rating
|
||||
from ratings r join shares s on s.id = r.share_id
|
||||
where s.album_id = $1",
|
||||
)
|
||||
.bind(album_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for (photo_id, share_label, rating) in ratings {
|
||||
feedback
|
||||
.entry(photo_id)
|
||||
.or_default()
|
||||
.ratings
|
||||
.push(ShareRating {
|
||||
share_label,
|
||||
rating,
|
||||
});
|
||||
}
|
||||
let tags: Vec<(Uuid, String, String)> = sqlx::query_as(
|
||||
"select t.photo_id, s.label, t.tag
|
||||
from tags t join shares s on s.id = t.share_id
|
||||
where s.album_id = $1
|
||||
order by t.created_at",
|
||||
)
|
||||
.bind(album_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for (photo_id, share_label, tag) in tags {
|
||||
feedback
|
||||
.entry(photo_id)
|
||||
.or_default()
|
||||
.tags
|
||||
.push(ShareTag { share_label, tag });
|
||||
}
|
||||
fold_feedback(&mut feedback, ratings, |f, share_label, rating| {
|
||||
f.ratings.push(ShareRating {
|
||||
share_label,
|
||||
rating,
|
||||
})
|
||||
});
|
||||
fold_feedback(&mut feedback, verdicts, |f, share_label, verdict| {
|
||||
f.verdicts.push(ShareVerdict {
|
||||
share_label,
|
||||
verdict,
|
||||
})
|
||||
});
|
||||
fold_feedback(&mut feedback, tags, |f, share_label, tag| {
|
||||
f.tags.push(ShareTag { share_label, tag })
|
||||
});
|
||||
|
||||
Ok(Json(AlbumDetail {
|
||||
album,
|
||||
@@ -196,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)
|
||||
|
||||
+40
-2
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::models::{PhotoStatus, Share};
|
||||
use crate::models::{PhotoStatus, Share, Verdict};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn load_share(state: &AppState, token: &str) -> Result<Share, ApiError> {
|
||||
@@ -137,6 +137,7 @@ struct ClientPhotoRow {
|
||||
taken_at: Option<DateTime<Utc>>,
|
||||
processed_at: Option<DateTime<Utc>>,
|
||||
my_rating: Option<i32>,
|
||||
my_verdict: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -185,9 +186,10 @@ pub async fn get_share(
|
||||
let jar = grant_access(&state, jar, share.id);
|
||||
|
||||
let rows: Vec<ClientPhotoRow> = sqlx::query_as(
|
||||
"select p.id, p.filename, p.size_bytes, p.width, p.height, p.taken_at, p.processed_at, r.rating as my_rating
|
||||
"select p.id, p.filename, p.size_bytes, p.width, p.height, p.taken_at, p.processed_at, r.rating as my_rating, v.verdict as my_verdict
|
||||
from photos p
|
||||
left join ratings r on r.photo_id = p.id and r.share_id = $2
|
||||
left join verdicts v on v.photo_id = p.id and v.share_id = $2
|
||||
where p.album_id = $1 and p.status = $3
|
||||
order by coalesce(p.taken_at, p.created_at), p.filename",
|
||||
)
|
||||
@@ -352,6 +354,42 @@ pub async fn set_rating(
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct VerdictBody {
|
||||
verdict: Option<Verdict>,
|
||||
}
|
||||
|
||||
pub async fn set_verdict(
|
||||
State(state): State<AppState>,
|
||||
Path((token, photo_id)): Path<(String, Uuid)>,
|
||||
jar: SignedCookieJar,
|
||||
Json(body): Json<VerdictBody>,
|
||||
) -> ApiResult<Json<serde_json::Value>> {
|
||||
let share = share_photo(&state, &jar, &token, photo_id).await?;
|
||||
match body.verdict {
|
||||
None => {
|
||||
sqlx::query("delete from verdicts where share_id = $1 and photo_id = $2")
|
||||
.bind(share.id)
|
||||
.bind(photo_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
Some(verdict) => {
|
||||
sqlx::query(
|
||||
"insert into verdicts (share_id, photo_id, verdict) values ($1, $2, $3)
|
||||
on conflict (share_id, photo_id)
|
||||
do update set verdict = excluded.verdict, updated_at = now()",
|
||||
)
|
||||
.bind(share.id)
|
||||
.bind(photo_id)
|
||||
.bind(verdict.as_str())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TagsBody {
|
||||
tags: Vec<String>,
|
||||
|
||||
+10
-1
@@ -77,9 +77,14 @@ pub fn router(state: &AppState) -> Router<AppState> {
|
||||
get(shares::list).post(shares::create),
|
||||
)
|
||||
.route("/api/albums/{id}/zip", post(zip::album_zip))
|
||||
.route("/api/albums/{id}/photos/by-hash/{sha256}", get(photos::by_hash))
|
||||
.route("/api/photos/delete", post(photos::delete_many))
|
||||
.route("/api/photos/{id}", delete(photos::delete))
|
||||
.route("/api/photos/{id}/reprocess", post(photos::reprocess))
|
||||
.route("/api/shares/{id}", delete(shares::delete))
|
||||
.route(
|
||||
"/api/shares/{id}",
|
||||
delete(shares::delete).patch(shares::update),
|
||||
)
|
||||
.route("/api/shares/{id}/reset-lock", post(shares::reset_lock))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
@@ -99,6 +104,10 @@ pub fn router(state: &AppState) -> Router<AppState> {
|
||||
"/api/share/{token}/photos/{photo_id}/rating",
|
||||
put(client::set_rating),
|
||||
)
|
||||
.route(
|
||||
"/api/share/{token}/photos/{photo_id}/verdict",
|
||||
put(client::set_verdict),
|
||||
)
|
||||
.route(
|
||||
"/api/share/{token}/photos/{photo_id}/tags",
|
||||
put(client::set_tags),
|
||||
|
||||
+134
-37
@@ -5,6 +5,7 @@ use axum::http::HeaderMap;
|
||||
use axum::Json;
|
||||
use futures::StreamExt;
|
||||
use serde::Deserialize;
|
||||
use sha2::Digest;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -60,17 +61,23 @@ pub async fn upload(
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
|
||||
// Stream the request body to a temp file so large raws never sit in memory.
|
||||
// Stream the request body to a temp file so large raws never sit in
|
||||
// memory, hashing as it flows: sha256 for duplicate detection, crc32 for
|
||||
// spool-free zip downloads.
|
||||
let dir = tempfile::tempdir().map_err(anyhow::Error::from)?;
|
||||
let path = dir.path().join("upload.bin");
|
||||
let mut file = tokio::fs::File::create(&path)
|
||||
.await
|
||||
.map_err(anyhow::Error::from)?;
|
||||
let mut stream = body.into_data_stream();
|
||||
let mut sha = sha2::Sha256::new();
|
||||
let mut crc = crc32fast::Hasher::new();
|
||||
let mut size: i64 = 0;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| ApiError::bad_request(format!("upload aborted: {e}")))?;
|
||||
size += chunk.len() as i64;
|
||||
sha.update(&chunk);
|
||||
crc.update(&chunk);
|
||||
file.write_all(&chunk).await.map_err(anyhow::Error::from)?;
|
||||
}
|
||||
file.flush().await.map_err(anyhow::Error::from)?;
|
||||
@@ -78,6 +85,20 @@ pub async fn upload(
|
||||
if size == 0 {
|
||||
return Err(ApiError::bad_request("empty upload"));
|
||||
}
|
||||
let sha256 = hex::encode(sha.finalize());
|
||||
let crc32 = i64::from(crc.finalize());
|
||||
|
||||
// Same content already in this album? Return it — re-dragging a folder
|
||||
// after a partial upload just fills the gaps instead of duplicating.
|
||||
let existing: Option<Photo> =
|
||||
sqlx::query_as("select * from photos where album_id = $1 and sha256 = $2")
|
||||
.bind(album_id)
|
||||
.bind(&sha256)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
if let Some(existing) = existing {
|
||||
return Ok(Json(existing));
|
||||
}
|
||||
|
||||
// Upload to S3 first, then create the row and enqueue processing in one
|
||||
// transaction — a photo row can never exist without its job, and a failed
|
||||
@@ -86,30 +107,33 @@ pub async fn upload(
|
||||
let key = s3::original_key(photo_id, &filename);
|
||||
s3::put_file(&state, &key, &path, &content_type).await?;
|
||||
|
||||
let result: ApiResult<(sqlx::Transaction<'static, sqlx::Postgres>, Photo)> = async {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let photo: Photo = sqlx::query_as(
|
||||
"insert into photos (id, album_id, filename, content_type, size_bytes, status)
|
||||
values ($1, $2, $3, $4, $5, $6)
|
||||
returning *",
|
||||
)
|
||||
.bind(photo_id)
|
||||
.bind(album_id)
|
||||
.bind(&filename)
|
||||
.bind(&content_type)
|
||||
.bind(size)
|
||||
.bind(PhotoStatus::Uploaded.as_str())
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
jobs::enqueue(
|
||||
&mut *tx,
|
||||
JobKind::ProcessPhoto,
|
||||
serde_json::json!({ "photo_id": photo_id }),
|
||||
)
|
||||
.await?;
|
||||
Ok((tx, photo))
|
||||
}
|
||||
.await;
|
||||
let result: Result<(sqlx::Transaction<'static, sqlx::Postgres>, Photo), sqlx::Error> =
|
||||
async {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let photo: Photo = sqlx::query_as(
|
||||
"insert into photos (id, album_id, filename, content_type, size_bytes, status, sha256, crc32)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
returning *",
|
||||
)
|
||||
.bind(photo_id)
|
||||
.bind(album_id)
|
||||
.bind(&filename)
|
||||
.bind(&content_type)
|
||||
.bind(size)
|
||||
.bind(PhotoStatus::Uploaded.as_str())
|
||||
.bind(&sha256)
|
||||
.bind(crc32)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
jobs::enqueue(
|
||||
&mut *tx,
|
||||
JobKind::ProcessPhoto,
|
||||
serde_json::json!({ "photo_id": photo_id }),
|
||||
)
|
||||
.await?;
|
||||
Ok((tx, photo))
|
||||
}
|
||||
.await;
|
||||
|
||||
let (tx, photo) = match result {
|
||||
Ok(pair) => pair,
|
||||
@@ -118,7 +142,24 @@ pub async fn upload(
|
||||
if let Err(cleanup) = s3::delete_prefix(&state, &s3::photo_prefix(photo_id)).await {
|
||||
tracing::error!("failed to clean up s3 after aborted upload: {cleanup:#}");
|
||||
}
|
||||
return Err(e);
|
||||
// Concurrent identical upload beat us to the unique index — hand
|
||||
// back the winner instead of an error.
|
||||
let unique_violation = matches!(
|
||||
&e,
|
||||
sqlx::Error::Database(db) if db.code().as_deref() == Some("23505")
|
||||
);
|
||||
if unique_violation {
|
||||
let winner: Option<Photo> =
|
||||
sqlx::query_as("select * from photos where album_id = $1 and sha256 = $2")
|
||||
.bind(album_id)
|
||||
.bind(&sha256)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
if let Some(winner) = winner {
|
||||
return Ok(Json(winner));
|
||||
}
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
if let Err(e) = tx.commit().await {
|
||||
@@ -133,28 +174,84 @@ pub async fn upload(
|
||||
Ok(Json(photo))
|
||||
}
|
||||
|
||||
/// Client-side dedup support: lets the uploader skip transferring files whose
|
||||
/// content already exists in the album.
|
||||
pub async fn by_hash(
|
||||
State(state): State<AppState>,
|
||||
Path((album_id, sha256)): Path<(Uuid, String)>,
|
||||
) -> ApiResult<Json<Photo>> {
|
||||
let photo: Option<Photo> =
|
||||
sqlx::query_as("select * from photos where album_id = $1 and sha256 = $2")
|
||||
.bind(album_id)
|
||||
.bind(&sha256)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
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 })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteManyBody {
|
||||
ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
pub async fn delete_many(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<DeleteManyBody>,
|
||||
) -> ApiResult<Json<serde_json::Value>> {
|
||||
if body.ids.is_empty() {
|
||||
return Err(ApiError::bad_request("ids must not be empty"));
|
||||
}
|
||||
let mut tx = state.db.begin().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": deleted })))
|
||||
}
|
||||
|
||||
pub async fn reprocess(
|
||||
State(state): State<AppState>,
|
||||
Path(photo_id): Path<Uuid>,
|
||||
|
||||
+63
-9
@@ -23,6 +23,8 @@ struct ShareAdminRow {
|
||||
created_at: DateTime<Utc>,
|
||||
rating_count: i64,
|
||||
tag_count: i64,
|
||||
accept_count: i64,
|
||||
reject_count: i64,
|
||||
}
|
||||
|
||||
fn share_json(state: &AppState, row: &ShareAdminRow) -> serde_json::Value {
|
||||
@@ -38,20 +40,30 @@ fn share_json(state: &AppState, row: &ShareAdminRow) -> serde_json::Value {
|
||||
"created_at": row.created_at,
|
||||
"rating_count": row.rating_count,
|
||||
"tag_count": row.tag_count,
|
||||
"accept_count": row.accept_count,
|
||||
"reject_count": row.reject_count,
|
||||
})
|
||||
}
|
||||
|
||||
const SHARE_COLUMNS: &str = "s.id, s.token, s.label, s.password_hash, s.allow_download,
|
||||
// Aggregates run as laterals so each feedback table is scanned once per
|
||||
// share (the verdict lateral yields both counts from a single pass).
|
||||
const SHARE_SELECT: &str = "select s.id, s.token, s.label, s.password_hash, s.allow_download,
|
||||
s.expires_at, s.locked_until, s.created_at,
|
||||
(select count(*) from ratings r where r.share_id = s.id) as rating_count,
|
||||
(select count(*) from tags t where t.share_id = s.id) as tag_count";
|
||||
rc.rating_count, tc.tag_count, vc.accept_count, vc.reject_count
|
||||
from shares s
|
||||
cross join lateral (select count(*) as rating_count from ratings r where r.share_id = s.id) rc
|
||||
cross join lateral (select count(*) as tag_count from tags t where t.share_id = s.id) tc
|
||||
cross join lateral (
|
||||
select count(*) filter (where v.verdict = 'accept') as accept_count,
|
||||
count(*) filter (where v.verdict = 'reject') as reject_count
|
||||
from verdicts v where v.share_id = s.id) vc";
|
||||
|
||||
pub async fn list(
|
||||
State(state): State<AppState>,
|
||||
Path(album_id): Path<Uuid>,
|
||||
) -> ApiResult<Json<Vec<serde_json::Value>>> {
|
||||
let rows: Vec<ShareAdminRow> = sqlx::query_as(&format!(
|
||||
"select {SHARE_COLUMNS} from shares s where s.album_id = $1 order by s.created_at desc"
|
||||
"{SHARE_SELECT} where s.album_id = $1 order by s.created_at desc"
|
||||
))
|
||||
.bind(album_id)
|
||||
.fetch_all(&state.db)
|
||||
@@ -119,11 +131,53 @@ pub async fn create(
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
let row: ShareAdminRow =
|
||||
sqlx::query_as(&format!("select {SHARE_COLUMNS} from shares s where s.id = $1"))
|
||||
.bind(share_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let row: ShareAdminRow = sqlx::query_as(&format!("{SHARE_SELECT} where s.id = $1"))
|
||||
.bind(share_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
Ok(Json(share_json(&state, &row)))
|
||||
}
|
||||
|
||||
/// Distinguishes an absent JSON field (keep current value) from an explicit
|
||||
/// null (clear the expiry): absent → None, present → Some(inner).
|
||||
fn double_option<'de, D>(de: D) -> Result<Option<Option<DateTime<Utc>>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
serde::Deserialize::deserialize(de).map(Some)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateShare {
|
||||
allow_download: Option<bool>,
|
||||
#[serde(default, deserialize_with = "double_option")]
|
||||
expires_at: Option<Option<DateTime<Utc>>>,
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(state): State<AppState>,
|
||||
Path(share_id): Path<Uuid>,
|
||||
Json(body): Json<UpdateShare>,
|
||||
) -> ApiResult<Json<serde_json::Value>> {
|
||||
let updated = sqlx::query(
|
||||
"update shares set
|
||||
allow_download = coalesce($2, allow_download),
|
||||
expires_at = case when $3 then $4 else expires_at end
|
||||
where id = $1",
|
||||
)
|
||||
.bind(share_id)
|
||||
.bind(body.allow_download)
|
||||
.bind(body.expires_at.is_some())
|
||||
.bind(body.expires_at.flatten())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if updated.rows_affected() == 0 {
|
||||
return Err(ApiError::not_found());
|
||||
}
|
||||
let row: ShareAdminRow = sqlx::query_as(&format!("{SHARE_SELECT} where s.id = $1"))
|
||||
.bind(share_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
Ok(Json(share_json(&state, &row)))
|
||||
}
|
||||
|
||||
|
||||
+62
-11
@@ -136,6 +136,9 @@ struct Entry {
|
||||
offset: u64,
|
||||
dos_time: u16,
|
||||
dos_date: u16,
|
||||
/// Stored at upload/processing time; photos from before hashes existed
|
||||
/// have None and take the slower spool path (which backfills it).
|
||||
crc: Option<u32>,
|
||||
}
|
||||
|
||||
struct ZipPlan {
|
||||
@@ -176,6 +179,7 @@ fn plan_zip(photos: &[Photo]) -> Result<ZipPlan, ApiError> {
|
||||
offset: entry_offset,
|
||||
dos_time,
|
||||
dos_date,
|
||||
crc: photo.crc32.map(|v| v as u32),
|
||||
});
|
||||
}
|
||||
let cd_offset = offset;
|
||||
@@ -277,9 +281,23 @@ fn stream_zip(state: AppState, photos: Vec<Photo>, album_name: &str) -> ApiResul
|
||||
.map_err(|e| anyhow::anyhow!("building response: {e}").into())
|
||||
}
|
||||
|
||||
/// Download an original into an anonymous temp file, computing its CRC-32 and
|
||||
/// verifying the byte count matches what the zip plan promised.
|
||||
fn spool(state: &AppState, key: String, expected_size: u64) -> JoinHandle<anyhow::Result<(tokio::fs::File, u32)>> {
|
||||
enum Fetched {
|
||||
/// CRC already known — the body streams straight into the response.
|
||||
Direct(Box<aws_sdk_s3::operation::get_object::GetObjectOutput>),
|
||||
/// Pre-hash photo: spooled to a temp file to compute the CRC first.
|
||||
Spooled(tokio::fs::File, u32),
|
||||
}
|
||||
|
||||
/// Start fetching an original. With a known CRC this only opens the S3
|
||||
/// response (the body is consumed later, straight into the zip stream);
|
||||
/// otherwise the object is spooled to an anonymous temp file to compute the
|
||||
/// CRC, verifying the byte count the zip plan promised.
|
||||
fn fetch_entry(
|
||||
state: &AppState,
|
||||
key: String,
|
||||
expected_size: u64,
|
||||
crc_known: bool,
|
||||
) -> JoinHandle<anyhow::Result<Fetched>> {
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let object = state
|
||||
@@ -290,6 +308,9 @@ fn spool(state: &AppState, key: String, expected_size: u64) -> JoinHandle<anyhow
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("fetching {key}: {e}"))?;
|
||||
if crc_known {
|
||||
return Ok(Fetched::Direct(Box::new(object)));
|
||||
}
|
||||
let mut file = tokio::fs::File::from_std(tempfile::tempfile()?);
|
||||
let mut reader = object.body.into_async_read();
|
||||
let mut hasher = crc32fast::Hasher::new();
|
||||
@@ -310,7 +331,7 @@ fn spool(state: &AppState, key: String, expected_size: u64) -> JoinHandle<anyhow
|
||||
);
|
||||
file.flush().await?;
|
||||
file.seek(std::io::SeekFrom::Start(0)).await?;
|
||||
Ok((file, hasher.finalize()))
|
||||
Ok(Fetched::Spooled(file, hasher.finalize()))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -323,20 +344,29 @@ async fn write_zip(
|
||||
const FLAGS: u16 = 0x0800;
|
||||
let mut crcs = Vec::with_capacity(plan.entries.len());
|
||||
|
||||
// Prefetch: spool the next object from S3 while streaming the current one.
|
||||
let mut pending: Option<JoinHandle<anyhow::Result<(tokio::fs::File, u32)>>> = None;
|
||||
// Prefetch: start fetching the next object while streaming the current one.
|
||||
let mut pending: Option<JoinHandle<anyhow::Result<Fetched>>> = None;
|
||||
for (i, entry) in plan.entries.iter().enumerate() {
|
||||
let current = match pending.take() {
|
||||
Some(handle) => handle,
|
||||
None => spool(state, entry.s3_key.clone(), entry.size),
|
||||
None => fetch_entry(state, entry.s3_key.clone(), entry.size, entry.crc.is_some()),
|
||||
};
|
||||
if let Some(next) = plan.entries.get(i + 1) {
|
||||
pending = Some(spool(state, next.s3_key.clone(), next.size));
|
||||
pending = Some(fetch_entry(
|
||||
state,
|
||||
next.s3_key.clone(),
|
||||
next.size,
|
||||
next.crc.is_some(),
|
||||
));
|
||||
}
|
||||
let (mut file, crc) = current
|
||||
let fetched = current
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("spool task failed: {e}"))?
|
||||
.map_err(|e| anyhow::anyhow!("fetch task failed: {e}"))?
|
||||
.map_err(|e| anyhow::anyhow!("photo {}: {e:#}", entry.photo_id))?;
|
||||
let crc = match &fetched {
|
||||
Fetched::Direct(_) => entry.crc.expect("direct fetch implies known crc"),
|
||||
Fetched::Spooled(_, crc) => *crc,
|
||||
};
|
||||
crcs.push(crc);
|
||||
|
||||
let mut lfh = Vec::with_capacity(30 + entry.name.len());
|
||||
@@ -353,7 +383,28 @@ async fn write_zip(
|
||||
lfh.extend_from_slice(&0u16.to_le_bytes()); // extra len
|
||||
lfh.extend_from_slice(&entry.name);
|
||||
out.write_all(&lfh).await?;
|
||||
tokio::io::copy(&mut file, &mut out).await?;
|
||||
match fetched {
|
||||
Fetched::Direct(object) => {
|
||||
let mut reader = object.body.into_async_read();
|
||||
let copied = tokio::io::copy(&mut reader, &mut out).await?;
|
||||
anyhow::ensure!(
|
||||
copied == entry.size,
|
||||
"{} is {copied} bytes in s3 but {} in the database",
|
||||
entry.s3_key,
|
||||
entry.size
|
||||
);
|
||||
}
|
||||
Fetched::Spooled(mut file, crc) => {
|
||||
tokio::io::copy(&mut file, &mut out).await?;
|
||||
// Self-heal: store the freshly computed crc so the next
|
||||
// download of this photo streams directly.
|
||||
let _ = sqlx::query("update photos set crc32 = coalesce(crc32, $2) where id = $1")
|
||||
.bind(entry.photo_id)
|
||||
.bind(i64::from(crc))
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Central directory.
|
||||
|
||||
Reference in New Issue
Block a user