Compare commits
2
Commits
main
..
multi-tenant
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84323ab637 | ||
|
|
40f7f2fb5e |
+2
-2
@@ -37,8 +37,7 @@ tempfile = "3"
|
|||||||
time = "0.3"
|
time = "0.3"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tokio-util = { version = "0.7", features = ["io"] }
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
tower = { version = "0.5", features = ["util"] }
|
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
||||||
tower-http = { version = "0.6", features = ["fs", "set-header", "trace"] }
|
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
@@ -46,6 +45,7 @@ uuid = { version = "1", features = ["v4", "serde"] }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
cookie = { version = "0.18", features = ["signed"] }
|
cookie = { version = "0.18", features = ["signed"] }
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
|
|||||||
@@ -2,25 +2,7 @@
|
|||||||
|
|
||||||
Self-hosted client gallery for photographers. Upload RAWs/JPGs into albums,
|
Self-hosted client gallery for photographers. Upload RAWs/JPGs into albums,
|
||||||
share them with clients via private (optionally password-protected) links,
|
share them with clients via private (optionally password-protected) links,
|
||||||
collect accept/reject votes, ratings and tags, and let clients download
|
collect ratings and tags, and let clients download originals.
|
||||||
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
|
## Architecture
|
||||||
|
|
||||||
@@ -48,19 +30,17 @@ The lightbox — accept/reject, star rating, tags, and keyboard-driven culling
|
|||||||
`photos/<photo_id>/preview.jpg`, `photos/<photo_id>/thumb.jpg`. The bucket
|
`photos/<photo_id>/preview.jpg`, `photos/<photo_id>/thumb.jpg`. The bucket
|
||||||
stays fully private; all image traffic is streamed through the API with
|
stays fully private; all image traffic is streamed through the API with
|
||||||
auth checks (no bucket CORS or public access needed).
|
auth checks (no bucket CORS or public access needed).
|
||||||
- **Auth**: photographer signs in via any OIDC provider (authorization-code
|
- **Auth**: photographers sign in via any OIDC provider (authorization-code
|
||||||
flow + userinfo); only emails in `ALLOWED_EMAILS` may sign in, and sessions
|
flow + userinfo); only emails in `ALLOWED_EMAILS` may sign in, and sessions
|
||||||
are re-checked against the allowlist on every request, so removing an email
|
are re-checked against the allowlist on every request, so removing an email
|
||||||
revokes access immediately. Clients use unguessable share tokens, optionally
|
revokes access immediately. **Multi-tenant**: each allowed email is its own
|
||||||
|
workspace — albums, photos, and share links are owned per photographer and
|
||||||
|
invisible to the others (enforced via ownership-scoped data access and
|
||||||
|
covered by the tenant-isolation test matrix). Clients use unguessable share tokens, optionally
|
||||||
gated by an argon2-hashed password (10 wrong guesses lock the link for
|
gated by an argon2-hashed password (10 wrong guesses lock the link for
|
||||||
15 minutes).
|
15 minutes).
|
||||||
- **Frontend**: React + Vite SPA — Lightroom-style photo grid (fixed cells, no crop), lightbox with
|
- **Frontend**: React + Vite SPA — justified gallery, lightbox with rating
|
||||||
accept/reject thumbs, rating stars and tag chips, keyboard-driven culling
|
stars and tag chips, drag-and-drop multi-file upload with progress.
|
||||||
(`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
|
## Local development
|
||||||
|
|
||||||
@@ -78,6 +58,13 @@ cargo run --bin worker # job worker (separate terminal, same env)
|
|||||||
cd frontend && npm install && npm run dev # UI on :5173, proxies /api
|
cd frontend && npm install && npm run dev # UI on :5173, proxies /api
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Tests (the tenant-isolation matrix needs a disposable database):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
createdb photos_test # or: docker compose exec postgres createdb -U photos photos_test
|
||||||
|
TEST_DATABASE_URL=postgres://photos:photos@localhost:5432/photos_test cargo test
|
||||||
|
```
|
||||||
|
|
||||||
Register the OIDC client with redirect URI `<PUBLIC_URL>/api/auth/callback`
|
Register the OIDC client with redirect URI `<PUBLIC_URL>/api/auth/callback`
|
||||||
(locally: `http://localhost:5173/api/auth/callback`). Any standard OIDC
|
(locally: `http://localhost:5173/api/auth/callback`). Any standard OIDC
|
||||||
provider works (Authentik, Keycloak, Zitadel, Dex, ...); the app uses
|
provider works (Authentik, Keycloak, Zitadel, Dex, ...); the app uses
|
||||||
@@ -108,20 +95,20 @@ All configuration is via environment variables:
|
|||||||
|
|
||||||
## Deploying to Kubernetes
|
## Deploying to Kubernetes
|
||||||
|
|
||||||
The prebuilt image is at `git.draic.info/nils/photos` (single image contains
|
Build and push the image (single image contains `server`, `worker`, and the
|
||||||
`server`, `worker`, and the built frontend). To build your own instead:
|
built frontend):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build -t registry.example.com/you/photos:0.5.1 .
|
docker build -t ghcr.io/YOU/photos:0.1.0 .
|
||||||
docker push registry.example.com/you/photos:0.5.1
|
docker push ghcr.io/YOU/photos:0.1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Install the chart, pointing it at your existing Postgres and S3:
|
Install the chart, pointing it at your existing Postgres and S3:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
helm install photos deploy/chart \
|
helm install photos deploy/chart \
|
||||||
--set image.repository=git.draic.info/nils/photos \
|
--set image.repository=ghcr.io/YOU/photos \
|
||||||
--set image.tag=0.5.1 \
|
--set image.tag=0.1.0 \
|
||||||
--set publicUrl=https://photos.example.com \
|
--set publicUrl=https://photos.example.com \
|
||||||
--set ingress.host=photos.example.com \
|
--set ingress.host=photos.example.com \
|
||||||
--set config.oidcIssuer=https://auth.example.com \
|
--set config.oidcIssuer=https://auth.example.com \
|
||||||
@@ -158,12 +145,9 @@ Notes:
|
|||||||
- Each album can have any number of share links (`/s/<24-char-token>`), each
|
- 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
|
with its own label (e.g. the client's name), optional password, optional
|
||||||
expiry, and a per-link download toggle.
|
expiry, and a per-link download toggle.
|
||||||
- Accept/reject votes (👍/👎), ratings (1–5 stars) and free-form tags are
|
- Ratings (1–5 stars) and free-form tags are stored **per link**, so create
|
||||||
stored **per link**, so create one link per client to keep feedback
|
one link per client to keep feedback separate. The album view shows all
|
||||||
separate. The album view shows all feedback grouped by link label, and
|
feedback grouped by link label.
|
||||||
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
|
- Clients (and you) can multi-select photos and download them — or the whole
|
||||||
album — as a ZIP. Archives are streamed (each file spools briefly through a
|
album — as a ZIP. Archives are streamed (each file spools briefly through a
|
||||||
temp file for its checksum, then pipelines while the next one prefetches),
|
temp file for its checksum, then pipelines while the next one prefetches),
|
||||||
@@ -176,16 +160,14 @@ Notes:
|
|||||||
- 10 wrong passwords lock a link for 15 minutes (fresh attempts after the
|
- 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
|
window). A locked link shows in the album's share list with an Unlock
|
||||||
button.
|
button.
|
||||||
- Deleting a link removes its votes/ratings/tags; deleting photos or albums
|
- Deleting a link removes its ratings/tags; deleting photos or albums cleans
|
||||||
cleans up S3 objects via background jobs.
|
up S3 objects via background jobs.
|
||||||
|
|
||||||
## Known limitations / deliberate v1 cuts
|
## Known limitations / deliberate v1 cuts
|
||||||
|
|
||||||
- Full RAW develop fallback for files whose embedded preview is tiny
|
- Full RAW develop fallback for files whose embedded preview is tiny
|
||||||
(exceedingly rare on modern cameras; `darktable-cli` in the worker image
|
(exceedingly rare on modern cameras; `darktable-cli` in the worker image
|
||||||
would cover it).
|
would cover it).
|
||||||
- Multiple photographer accounts with separate libraries (any allowed email
|
|
||||||
sees everything).
|
|
||||||
- No S3 orphan sweeper: a crash in the narrow window between an upload's S3
|
- No S3 orphan sweeper: a crash in the narrow window between an upload's S3
|
||||||
put and its DB commit can leave an unreferenced original in the bucket
|
put and its DB commit can leave an unreferenced original in the bucket
|
||||||
(never data loss — just unclaimed storage).
|
(never data loss — just unclaimed storage).
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 400 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 528 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 389 KiB |
+8
-2
@@ -73,9 +73,15 @@ export function postDownload(url, ids = '') {
|
|||||||
form.remove()
|
form.remove()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SHA-256 of a File, matching the server's content hash — used to skip
|
// Above this, hashing whole-file in memory (WebCrypto has no streaming digest)
|
||||||
// uploading bytes the album already has.
|
// risks OOM / the ~2GiB ArrayBuffer cap, so we skip the client dedup check and
|
||||||
|
// just upload — the server still dedups on arrival.
|
||||||
|
const CLIENT_HASH_LIMIT = 512 * 1024 * 1024
|
||||||
|
|
||||||
|
// SHA-256 of a File (lowercase hex), matching the server's content hash, or
|
||||||
|
// null when the file is too large to hash safely in the browser.
|
||||||
export async function sha256Hex(file) {
|
export async function sha256Hex(file) {
|
||||||
|
if (file.size > CLIENT_HASH_LIMIT) return null
|
||||||
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer())
|
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer())
|
||||||
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,134 +1,90 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
import { imgUrl } from '../api'
|
import { imgUrl } from '../api'
|
||||||
import { isTypingTarget } from '../useEscape'
|
|
||||||
|
|
||||||
// Lightroom-style grid: fixed square cells, photos fully visible via
|
// True justified layout: pack photos greedily into rows at their real aspect
|
||||||
// object-fit: contain. Cell size depends only on the container width, so a
|
// ratios, then scale each row's height so it fills the container width
|
||||||
// photo renders identically no matter which other photos are in view.
|
// exactly. No cropping, no stretch, and the last row simply renders at the
|
||||||
//
|
// target height instead of being padded by a spacer.
|
||||||
// Keyboard (while `keyboard` is true, i.e. no lightbox/modal open): arrow
|
function layoutRows(photos, containerWidth, targetHeight, gap) {
|
||||||
// keys move a cursor through the grid, Space opens the viewer on it,
|
const rows = []
|
||||||
// Enter toggles selection, and the page's culling `actions` (P/X/U, stars,
|
let row = []
|
||||||
// S) run against the cursor photo — same table as in the lightbox.
|
let arSum = 0
|
||||||
// `externalIndex` mirrors the lightbox position into the cursor, so closing
|
let index = 0
|
||||||
// the viewer continues where the culling run ended.
|
for (const photo of photos) {
|
||||||
export default function Gallery({
|
const ar = photo.width && photo.height ? photo.width / photo.height : 1.5
|
||||||
photos,
|
row.push({ photo, ar, index: index++ })
|
||||||
onOpen,
|
arSum += ar
|
||||||
overlay,
|
const gaps = (row.length - 1) * gap
|
||||||
selected,
|
if (arSum * targetHeight + gaps >= containerWidth) {
|
||||||
onToggleSelect,
|
rows.push({ items: row, height: (containerWidth - gaps) / arSum })
|
||||||
keyboard = false,
|
row = []
|
||||||
externalIndex = -1,
|
arSum = 0
|
||||||
actions,
|
}
|
||||||
}) {
|
}
|
||||||
|
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 }) {
|
||||||
|
const [width, setWidth] = useState(0)
|
||||||
|
const observerRef = useRef(null)
|
||||||
|
|
||||||
|
// Callback ref: (re)attaches the observer whenever the container node
|
||||||
|
// mounts. A plain mount-effect misses the case where Gallery first renders
|
||||||
|
// empty (no container) and photos arrive later.
|
||||||
|
const containerRef = useCallback((node) => {
|
||||||
|
observerRef.current?.disconnect()
|
||||||
|
if (!node) return
|
||||||
|
const observer = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width))
|
||||||
|
observer.observe(node)
|
||||||
|
observerRef.current = observer
|
||||||
|
setWidth(node.getBoundingClientRect().width)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (photos.length === 0) return null
|
||||||
|
const gap = 6
|
||||||
|
const targetHeight = width < 700 ? 170 : 240
|
||||||
|
const rows = width > 0 ? layoutRows(photos, width, targetHeight, gap) : []
|
||||||
const selecting = selected && selected.size > 0
|
const selecting = selected && selected.size > 0
|
||||||
const containerRef = useRef(null)
|
|
||||||
// The cursor is always a valid index; `revealed` says whether the user has
|
|
||||||
// interacted yet. The first key press reveals the cursor where it is
|
|
||||||
// (nothing pre-marked on a fresh page, first arrow doesn't skip a photo).
|
|
||||||
const [cursor, setCursor] = useState(0)
|
|
||||||
const [revealed, setRevealed] = useState(false)
|
|
||||||
const live = useRef({})
|
|
||||||
live.current = { cursor, revealed, photos, onOpen, onToggleSelect, actions }
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (externalIndex >= 0) {
|
|
||||||
setCursor(externalIndex)
|
|
||||||
setRevealed(true)
|
|
||||||
}
|
|
||||||
}, [externalIndex])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (cursor >= photos.length) setCursor(Math.max(0, photos.length - 1))
|
|
||||||
}, [cursor, photos.length])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!keyboard) return
|
|
||||||
const handler = (e) => {
|
|
||||||
const tag = e.target.tagName
|
|
||||||
if (isTypingTarget(e.target) || tag === 'SELECT') return
|
|
||||||
// Focused buttons/links keep their native Space/Enter; everything else
|
|
||||||
// still reaches the grid.
|
|
||||||
const clickable = ['BUTTON', 'A', 'LABEL', 'INPUT'].includes(tag)
|
|
||||||
if (clickable && (e.key === ' ' || e.key === 'Enter')) return
|
|
||||||
if (e.metaKey || e.ctrlKey || e.altKey) return
|
|
||||||
const s = live.current
|
|
||||||
if (s.photos.length === 0) return
|
|
||||||
const photo = s.photos[s.cursor]
|
|
||||||
const el = containerRef.current
|
|
||||||
const columns = el ? getComputedStyle(el).gridTemplateColumns.split(' ').length : 1
|
|
||||||
const count = s.photos.length
|
|
||||||
let next = null
|
|
||||||
if (e.key === 'ArrowRight') next = Math.min(count - 1, s.cursor + 1)
|
|
||||||
else if (e.key === 'ArrowLeft') next = Math.max(0, s.cursor - 1)
|
|
||||||
else if (e.key === 'ArrowDown') next = Math.min(count - 1, s.cursor + columns)
|
|
||||||
else if (e.key === 'ArrowUp') next = Math.max(0, s.cursor - columns)
|
|
||||||
else if (e.key === ' ') {
|
|
||||||
if (photo) {
|
|
||||||
e.preventDefault()
|
|
||||||
setRevealed(true)
|
|
||||||
s.onOpen?.(s.cursor)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
} else if (e.key === 'Enter') {
|
|
||||||
if (photo && s.onToggleSelect) {
|
|
||||||
e.preventDefault()
|
|
||||||
setRevealed(true)
|
|
||||||
s.onToggleSelect(photo.id)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
const key = e.key.toLowerCase()
|
|
||||||
const action = s.actions?.find((a) => a.keys.includes(key))
|
|
||||||
if (action && photo) {
|
|
||||||
setRevealed(true)
|
|
||||||
action.run(photo, key)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.preventDefault()
|
|
||||||
setRevealed(true)
|
|
||||||
// The first arrow press only reveals the cursor; movement starts with
|
|
||||||
// the second.
|
|
||||||
if (s.revealed) {
|
|
||||||
setCursor(next)
|
|
||||||
el?.children[next]?.scrollIntoView({ block: 'nearest' })
|
|
||||||
} else {
|
|
||||||
el?.children[s.cursor]?.scrollIntoView({ block: 'nearest' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', handler)
|
|
||||||
return () => window.removeEventListener('keydown', handler)
|
|
||||||
}, [keyboard])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className={`gallery${selecting ? ' selecting' : ''}`}>
|
<div ref={containerRef} className={`gallery${selecting ? ' selecting' : ''}`}>
|
||||||
{photos.map((p, index) => {
|
{rows.map((row) => (
|
||||||
const isSelected = selected ? selected.has(p.id) : false
|
<div key={row.items[0].photo.id} className="g-row" style={{ height: row.height }}>
|
||||||
return (
|
{row.items.map(({ photo: p, ar, index }) => {
|
||||||
<div
|
const isSelected = selected ? selected.has(p.id) : false
|
||||||
key={p.id}
|
return (
|
||||||
className={`g-item${isSelected ? ' selected' : ''}${revealed && index === cursor ? ' focused' : ''}`}
|
<div
|
||||||
onClick={() => onOpen && onOpen(index)}
|
key={p.id}
|
||||||
>
|
className={`g-item${isSelected ? ' selected' : ''}`}
|
||||||
<img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} />
|
style={{ width: ar * row.height }}
|
||||||
{onToggleSelect && (
|
onClick={() => onOpen && onOpen(index)}
|
||||||
<button
|
|
||||||
className="g-check"
|
|
||||||
title={isSelected ? 'Deselect' : 'Select'}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onToggleSelect(p.id, e.shiftKey)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
✓
|
<img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} />
|
||||||
</button>
|
{onToggleSelect && (
|
||||||
)}
|
<button
|
||||||
{overlay && overlay(p)}
|
className="g-check"
|
||||||
</div>
|
title={isSelected ? 'Deselect' : 'Select'}
|
||||||
)
|
onClick={(e) => {
|
||||||
})}
|
e.stopPropagation()
|
||||||
|
onToggleSelect(p.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{overlay && overlay(p)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +1,18 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { imgUrl } from '../api'
|
import { imgUrl } from '../api'
|
||||||
import { isTypingTarget } from '../useEscape'
|
|
||||||
import useSwipe from '../useSwipe'
|
|
||||||
|
|
||||||
const BASE_SHORTCUTS = [
|
export default function Lightbox({ photos, index, onClose, onNav, footer }) {
|
||||||
['← / →', 'previous / next photo'],
|
|
||||||
['?', 'show / hide shortcuts'],
|
|
||||||
['Space / 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 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(() => {
|
useEffect(() => {
|
||||||
const handler = (e) => {
|
const onKey = (e) => {
|
||||||
const s = live.current
|
if (e.key === 'Escape') onClose()
|
||||||
if (s.closing) return
|
if (e.key === 'ArrowRight' && index < photos.length - 1) onNav(index + 1)
|
||||||
if (isTypingTarget(e.target)) {
|
if (e.key === 'ArrowLeft' && index > 0) onNav(index - 1)
|
||||||
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 === ' ') {
|
|
||||||
e.preventDefault()
|
|
||||||
s.onClose()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.key === 'ArrowRight') {
|
|
||||||
e.preventDefault()
|
|
||||||
if (s.index < s.count - 1) s.onNav(s.index + 1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.key === 'ArrowLeft') {
|
|
||||||
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', handler)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', handler)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [])
|
}, [index, photos.length, onClose, onNav])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden'
|
||||||
@@ -89,54 +21,15 @@ export default function Lightbox({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const preview = (p) => imgUrl(p, 'preview')
|
|
||||||
const prevPhoto = photos[index - 1]
|
|
||||||
const nextPhoto = photos[index + 1]
|
|
||||||
|
|
||||||
const swipe = useSwipe({
|
|
||||||
photo,
|
|
||||||
index,
|
|
||||||
count: photos.length,
|
|
||||||
onNav,
|
|
||||||
gestures,
|
|
||||||
hasNext: index < photos.length - 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Immediate neighbors are real DOM slides (loaded by definition); warm the
|
|
||||||
// browser cache a few photos further out so fast swiping never hits the
|
|
||||||
// network.
|
|
||||||
useEffect(() => {
|
|
||||||
for (const i of [index + 2, index + 3, index - 2]) {
|
|
||||||
if (photos[i]) new Image().src = preview(photos[i])
|
|
||||||
}
|
|
||||||
}, [index, photos])
|
|
||||||
|
|
||||||
if (!photo) return null
|
if (!photo) return null
|
||||||
|
|
||||||
// Did the vote fly off the photo we're still showing (no advance target)?
|
|
||||||
const exit = swipe.exit
|
|
||||||
const exitSelf = exit && exit.photo.id === photo.id
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="lightbox" onClick={onClose}>
|
||||||
className={`lightbox${closing ? ' lb-closing' : ''}`}
|
|
||||||
onClick={onClose}
|
|
||||||
onAnimationEnd={(e) => {
|
|
||||||
if (e.animationName === 'lb-fade-out') onClosed?.()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="lb-top" onClick={(e) => e.stopPropagation()}>
|
<div className="lb-top" onClick={(e) => e.stopPropagation()}>
|
||||||
<span className="lb-name">{photo.filename}</span>
|
<span className="lb-name">{photo.filename}</span>
|
||||||
<span className="lb-count">
|
<span className="lb-count">
|
||||||
{index + 1} / {photos.length}
|
{index + 1} / {photos.length}
|
||||||
</span>
|
</span>
|
||||||
<button
|
|
||||||
className="lb-btn"
|
|
||||||
onClick={() => setShowHelp((h) => !h)}
|
|
||||||
title="Keyboard shortcuts (?)"
|
|
||||||
>
|
|
||||||
?
|
|
||||||
</button>
|
|
||||||
<button className="lb-btn" onClick={onClose} title="Close (Esc)">
|
<button className="lb-btn" onClick={onClose} title="Close (Esc)">
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@@ -151,55 +44,13 @@ export default function Lightbox({
|
|||||||
>
|
>
|
||||||
‹
|
‹
|
||||||
</button>
|
</button>
|
||||||
<div className="lb-stage" {...swipe.handlers}>
|
<div className="lb-stage">
|
||||||
<div
|
<img
|
||||||
className="lb-track"
|
className="lb-img"
|
||||||
ref={swipe.trackRef}
|
src={imgUrl(photo, 'preview')}
|
||||||
style={swipe.trackStyle}
|
alt={photo.filename}
|
||||||
onTransitionEnd={swipe.onTrackTransitionEnd}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
/>
|
||||||
{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${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>
|
|
||||||
{exit && (
|
|
||||||
<div
|
|
||||||
className="lb-slide lb-ghost"
|
|
||||||
style={{ '--dy': `${exit.dy}px`, '--rot': `${exit.dy / 40}deg` }}
|
|
||||||
onAnimationEnd={swipe.clearExit}
|
|
||||||
>
|
|
||||||
<img className={`lb-img lb-exit-${exit.dir}`} src={preview(exit.photo)} alt="" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{swipe.showBadge && <div ref={swipe.badgeRef} className="lb-flick" />}
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="lb-nav lb-next"
|
className="lb-nav lb-next"
|
||||||
@@ -216,25 +67,6 @@ export default function Lightbox({
|
|||||||
{footer(photo)}
|
{footer(photo)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import useEscape from '../useEscape'
|
|
||||||
|
|
||||||
export default function Modal({ title, onClose, children }) {
|
|
||||||
useEscape(onClose)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal" onClick={onClose}>
|
|
||||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="modal-head">
|
|
||||||
<h2>{title}</h2>
|
|
||||||
<button className="lb-btn" onClick={onClose} title="Close (Esc)">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,6 @@ export default function SelectionBar({
|
|||||||
onClear,
|
onClear,
|
||||||
onDownload,
|
onDownload,
|
||||||
onDownloadAll,
|
onDownloadAll,
|
||||||
onDelete,
|
|
||||||
}) {
|
}) {
|
||||||
if (total === 0) return null
|
if (total === 0) return null
|
||||||
|
|
||||||
@@ -48,11 +47,6 @@ export default function SelectionBar({
|
|||||||
<button className="btn btn-primary" onClick={onDownload}>
|
<button className="btn btn-primary" onClick={onDownload}>
|
||||||
Download {count} as ZIP ({fmtBytes(selectedBytes)})
|
Download {count} as ZIP ({fmtBytes(selectedBytes)})
|
||||||
</button>
|
</button>
|
||||||
{onDelete && (
|
|
||||||
<button className="btn btn-danger" onClick={onDelete}>
|
|
||||||
Delete {count}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,282 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
import { api } from '../api'
|
|
||||||
import { ACCEPT_GLYPH, REJECT_GLYPH } from './Thumbs'
|
|
||||||
|
|
||||||
// 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. Half-typed
|
|
||||||
// years (Safari fires change with e.g. 0002) count as "no expiry".
|
|
||||||
const endOfDayIso = (day) =>
|
|
||||||
!day || day.slice(0, 4) < '2000' ? null : new Date(`${day}T23:59:59`).toISOString()
|
|
||||||
|
|
||||||
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. Renders
|
|
||||||
// "never" until an expiry exists or the user asks for one — an empty date
|
|
||||||
// input is not shown at all (Safari fills it with today's date as a
|
|
||||||
// pseudo-placeholder, which reads like a set expiry).
|
|
||||||
function ExpiryDate({ value, onCommit }) {
|
|
||||||
const current = value ? localDate(value) : ''
|
|
||||||
const [draft, setDraft] = useState(current)
|
|
||||||
const [editing, setEditing] = useState(false)
|
|
||||||
useEffect(() => setDraft(current), [current])
|
|
||||||
|
|
||||||
const commit = () => {
|
|
||||||
setEditing(false)
|
|
||||||
if (draft === current) return
|
|
||||||
const iso = endOfDayIso(draft)
|
|
||||||
if (draft && !iso) {
|
|
||||||
setDraft(current)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onCommit(iso)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!value && !editing) {
|
|
||||||
return (
|
|
||||||
<label className="row field-label">
|
|
||||||
<span className="muted">expires</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost"
|
|
||||||
title="Set an expiry date"
|
|
||||||
onClick={() => setEditing(true)}
|
|
||||||
>
|
|
||||||
never
|
|
||||||
</button>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label className="row field-label">
|
|
||||||
<span className="muted">expires</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
autoFocus={editing}
|
|
||||||
value={draft}
|
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
|
||||||
onBlur={commit}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') e.target.blur()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{value && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost"
|
|
||||||
title="Remove expiry — link never expires"
|
|
||||||
onClick={() => {
|
|
||||||
setDraft('')
|
|
||||||
setEditing(false)
|
|
||||||
onCommit(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SharesPanel({ albumId }) {
|
|
||||||
const [shares, setShares] = useState([])
|
|
||||||
const [form, setForm] = useState({ label: '', password: '', allow_download: true, expires_at: '' })
|
|
||||||
const [expiryOpen, setExpiryOpen] = useState(false)
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
const [copied, setCopied] = useState(null)
|
|
||||||
|
|
||||||
const load = useCallback(
|
|
||||||
() => api(`/api/albums/${albumId}/shares`).then(setShares).catch((e) => setError(e.message)),
|
|
||||||
[albumId],
|
|
||||||
)
|
|
||||||
useEffect(() => {
|
|
||||||
load()
|
|
||||||
}, [load])
|
|
||||||
|
|
||||||
const create = async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
try {
|
|
||||||
await api(`/api/albums/${albumId}/shares`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
label: form.label,
|
|
||||||
password: form.password || null,
|
|
||||||
allow_download: form.allow_download,
|
|
||||||
expires_at: endOfDayIso(form.expires_at),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
setForm({ label: '', password: '', allow_download: true, expires_at: '' })
|
|
||||||
setExpiryOpen(false)
|
|
||||||
setError(null)
|
|
||||||
load()
|
|
||||||
} catch (e) {
|
|
||||||
setError(e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const copy = async (share) => {
|
|
||||||
await navigator.clipboard.writeText(share.url)
|
|
||||||
setCopied(share.id)
|
|
||||||
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 (
|
|
||||||
<div>
|
|
||||||
{shares.length === 0 && <p className="muted">No links yet.</p>}
|
|
||||||
{shares.map((s) => (
|
|
||||||
<div key={s.id} className="share-row">
|
|
||||||
<div className="share-info">
|
|
||||||
<span className="row">
|
|
||||||
<strong>{s.label || 'unnamed link'}</strong>
|
|
||||||
{s.has_password && <span title="Password protected">🔒</span>}
|
|
||||||
{s.locked && (
|
|
||||||
<>
|
|
||||||
<span className="error">locked (too many wrong passwords)</span>
|
|
||||||
<button
|
|
||||||
className="btn"
|
|
||||||
onClick={async () => {
|
|
||||||
await api(`/api/shares/${s.id}/reset-lock`, { method: 'POST' })
|
|
||||||
load()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Unlock
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<button className="share-url" title="Copy link" onClick={() => copy(s)}>
|
|
||||||
{copied === s.id ? 'Copied!' : s.url.replace(/^https?:\/\//, '')}
|
|
||||||
</button>
|
|
||||||
{s.accept_count + s.reject_count + s.rating_count + s.tag_count > 0 && (
|
|
||||||
<span className="row share-feedback">
|
|
||||||
<span className="share-stats">
|
|
||||||
{s.accept_count > 0 && (
|
|
||||||
<span className="chip chip-accept">
|
|
||||||
{ACCEPT_GLYPH} {s.accept_count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{s.reject_count > 0 && (
|
|
||||||
<span className="chip chip-reject">
|
|
||||||
{REJECT_GLYPH} {s.reject_count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{s.rating_count > 0 && <span className="chip">★ {s.rating_count}</span>}
|
|
||||||
{s.tag_count > 0 && <span className="chip"># {s.tag_count}</span>}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="row share-controls">
|
|
||||||
<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 })} />
|
|
||||||
<button className="btn" onClick={() => copy(s)}>
|
|
||||||
{copied === s.id ? 'Copied!' : 'Copy link'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-danger"
|
|
||||||
title="Delete link — removes its votes, ratings and tags too"
|
|
||||||
onClick={async () => {
|
|
||||||
if (!confirm(`Delete link "${s.label || s.token}"? Client votes, ratings and tags from this link are removed too.`)) return
|
|
||||||
await api(`/api/shares/${s.id}`, { method: 'DELETE' })
|
|
||||||
load()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<form className="share-form" onSubmit={create}>
|
|
||||||
<h3 className="share-form-title">New link</h3>
|
|
||||||
<label className="field">
|
|
||||||
<span className="muted">Label</span>
|
|
||||||
<input
|
|
||||||
placeholder="e.g. client name"
|
|
||||||
value={form.label}
|
|
||||||
onChange={(e) => setForm({ ...form, label: e.target.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span className="muted">Password</span>
|
|
||||||
<input
|
|
||||||
placeholder="optional"
|
|
||||||
value={form.password}
|
|
||||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span className="muted">Expires</span>
|
|
||||||
<span className="row">
|
|
||||||
{form.expires_at || expiryOpen ? (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
autoFocus={expiryOpen && !form.expires_at}
|
|
||||||
value={form.expires_at}
|
|
||||||
onChange={(e) => setForm({ ...form, expires_at: e.target.value })}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost"
|
|
||||||
title="No expiry — link never expires"
|
|
||||||
onClick={() => {
|
|
||||||
setExpiryOpen(false)
|
|
||||||
setForm({ ...form, expires_at: '' })
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost"
|
|
||||||
title="Set an expiry date"
|
|
||||||
onClick={() => setExpiryOpen(true)}
|
|
||||||
>
|
|
||||||
never
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span className="muted">Downloads</span>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.allow_download}
|
|
||||||
onChange={(e) => setForm({ ...form, allow_download: e.target.checked })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="share-form-full">
|
|
||||||
<button className="btn btn-primary" type="submit">
|
|
||||||
Create link
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{error && <p className="error">{error}</p>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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,306 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
||||||
import { api, sha256Hex, uploadFile } from '../api'
|
|
||||||
import { fmtBytes } from './SelectionBar'
|
|
||||||
|
|
||||||
const UPLOAD_CONCURRENCY = 3
|
|
||||||
|
|
||||||
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`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UploadZone({ albumId, onUploaded, processing, onRetry, onDelete }) {
|
|
||||||
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.
|
|
||||||
const refresh = useCallback(() => {
|
|
||||||
const drained = running.current === 0 && pending.current.length === 0
|
|
||||||
if (drained || Date.now() - lastRefresh.current > 5000) {
|
|
||||||
lastRefresh.current = Date.now()
|
|
||||||
onUploaded()
|
|
||||||
}
|
|
||||||
}, [onUploaded])
|
|
||||||
|
|
||||||
const pump = useCallback(() => {
|
|
||||||
while (running.current < UPLOAD_CONCURRENCY && pending.current.length > 0) {
|
|
||||||
const item = pending.current.shift()
|
|
||||||
running.current += 1
|
|
||||||
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()
|
|
||||||
pump()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [albumId, refresh])
|
|
||||||
|
|
||||||
const addFiles = useCallback(
|
|
||||||
(files) => {
|
|
||||||
const items = [...files].map((file, i) => ({
|
|
||||||
key: `${Date.now()}-${i}-${file.name}`,
|
|
||||||
file,
|
|
||||||
status: 'queued',
|
|
||||||
progress: 0,
|
|
||||||
}))
|
|
||||||
if (items.length === 0) return
|
|
||||||
setQueue((q) => [...q.filter((x) => x.status !== 'done'), ...items])
|
|
||||||
pending.current.push(...items)
|
|
||||||
pump()
|
|
||||||
},
|
|
||||||
[pump],
|
|
||||||
)
|
|
||||||
|
|
||||||
// The whole window is the drop target; a fullscreen overlay signals it
|
|
||||||
// while a file drag is over the page. Depth counter because dragenter/
|
|
||||||
// dragleave fire for every crossed element.
|
|
||||||
useEffect(() => {
|
|
||||||
let depth = 0
|
|
||||||
const hasFiles = (e) => [...(e.dataTransfer?.types || [])].includes('Files')
|
|
||||||
const enter = (e) => {
|
|
||||||
if (!hasFiles(e)) return
|
|
||||||
e.preventDefault()
|
|
||||||
depth += 1
|
|
||||||
setDragging(true)
|
|
||||||
}
|
|
||||||
const over = (e) => {
|
|
||||||
if (hasFiles(e)) e.preventDefault()
|
|
||||||
}
|
|
||||||
const leave = (e) => {
|
|
||||||
if (!hasFiles(e)) return
|
|
||||||
depth -= 1
|
|
||||||
if (depth <= 0) {
|
|
||||||
depth = 0
|
|
||||||
setDragging(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const drop = (e) => {
|
|
||||||
if (!hasFiles(e)) return
|
|
||||||
e.preventDefault()
|
|
||||||
depth = 0
|
|
||||||
setDragging(false)
|
|
||||||
addFiles(e.dataTransfer.files)
|
|
||||||
}
|
|
||||||
window.addEventListener('dragenter', enter)
|
|
||||||
window.addEventListener('dragover', over)
|
|
||||||
window.addEventListener('dragleave', leave)
|
|
||||||
window.addEventListener('drop', drop)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('dragenter', enter)
|
|
||||||
window.removeEventListener('dragover', over)
|
|
||||||
window.removeEventListener('dragleave', leave)
|
|
||||||
window.removeEventListener('drop', drop)
|
|
||||||
}
|
|
||||||
}, [addFiles])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
title="Or drop RAWs/JPGs anywhere on the page"
|
|
||||||
onClick={() => inputRef.current?.click()}
|
|
||||||
>
|
|
||||||
Upload
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
hidden
|
|
||||||
onChange={(e) => {
|
|
||||||
addFiles(e.target.files)
|
|
||||||
e.target.value = ''
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{dragging && <div className="drop-overlay">Drop to upload</div>}
|
|
||||||
<ActivityOverlay
|
|
||||||
queue={queue}
|
|
||||||
active={active}
|
|
||||||
speed={speed}
|
|
||||||
loadedBytes={loadedBytes}
|
|
||||||
totalBytes={totalBytes}
|
|
||||||
processing={processing}
|
|
||||||
onRetry={onRetry}
|
|
||||||
onDelete={onDelete}
|
|
||||||
onClearDone={() =>
|
|
||||||
setQueue((q) => q.filter((i) => i.status !== 'done' && i.status !== 'skipped'))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bottom-right collapsible activity panel: upload queue and the worker's
|
|
||||||
// processing queue in one place, out of the page flow.
|
|
||||||
function ActivityOverlay({
|
|
||||||
queue,
|
|
||||||
active,
|
|
||||||
speed,
|
|
||||||
loadedBytes,
|
|
||||||
totalBytes,
|
|
||||||
processing,
|
|
||||||
onRetry,
|
|
||||||
onDelete,
|
|
||||||
onClearDone,
|
|
||||||
}) {
|
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
|
||||||
if (queue.length === 0 && processing.length === 0) return null
|
|
||||||
|
|
||||||
const finished = queue.filter((i) => i.status === 'done' || i.status === 'skipped').length
|
|
||||||
const uploadErrors = queue.filter((i) => i.status === 'error').length
|
|
||||||
const working = processing.filter((p) => p.status !== 'error')
|
|
||||||
const failed = processing.filter((p) => p.status === 'error')
|
|
||||||
const summary = [
|
|
||||||
queue.length > 0 &&
|
|
||||||
(active
|
|
||||||
? `Uploading ${finished}/${queue.length}`
|
|
||||||
: uploadErrors > 0
|
|
||||||
? `${uploadErrors} upload${uploadErrors === 1 ? '' : 's'} failed`
|
|
||||||
: 'Uploads done'),
|
|
||||||
working.length > 0 && `${working.length} processing`,
|
|
||||||
].filter(Boolean)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="activity">
|
|
||||||
<button className="activity-head" onClick={() => setCollapsed((c) => !c)}>
|
|
||||||
<span className="upload-name">
|
|
||||||
{summary.join(' · ')}
|
|
||||||
{failed.length > 0 && (
|
|
||||||
<span className="error">
|
|
||||||
{summary.length > 0 && ' · '}
|
|
||||||
{failed.length} failed
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="row">
|
|
||||||
{!active && finished > 0 && (
|
|
||||||
<span
|
|
||||||
className="activity-clear"
|
|
||||||
title="Clear finished uploads"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onClearDone()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{collapsed ? '▸' : '▾'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{active && <progress className="activity-total" value={loadedBytes} max={totalBytes || 1} />}
|
|
||||||
{!collapsed && (
|
|
||||||
<div className="activity-body">
|
|
||||||
{queue.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="muted activity-section">
|
|
||||||
{fmtBytes(loadedBytes)} of {fmtBytes(totalBytes)}
|
|
||||||
{active && speed > 0 && (
|
|
||||||
<>
|
|
||||||
{' · '}
|
|
||||||
{fmtBytes(speed)}/s · ~{fmtEta((totalBytes - loadedBytes) / speed)} left
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ul className="upload-list">
|
|
||||||
{queue.map((item) => (
|
|
||||||
<li key={item.key} className={`upload-item ${item.status}`}>
|
|
||||||
<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" />
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{processing.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="muted activity-section">Processing</div>
|
|
||||||
<ul className="pending-list">
|
|
||||||
{processing.map((p) => (
|
|
||||||
<li key={p.id}>
|
|
||||||
<span className="upload-name">{p.filename}</span>
|
|
||||||
{p.status === 'error' ? (
|
|
||||||
<span className="row">
|
|
||||||
<span className="error">{p.error || 'failed'}</span>
|
|
||||||
<button className="btn" onClick={() => onRetry(p)}>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-danger" onClick={() => onDelete(p.id)}>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="muted">{p.status}…</span>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { api } from '../api'
|
|
||||||
import Modal from './Modal'
|
|
||||||
import { ACCEPT_GLYPH, REJECT_GLYPH } from './Thumbs'
|
|
||||||
|
|
||||||
export default function XmpModal({ albumId, onClose }) {
|
|
||||||
const [shares, setShares] = useState(null)
|
|
||||||
const [own, setOwn] = useState(true)
|
|
||||||
const [checked, setChecked] = useState(() => new Set())
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api(`/api/albums/${albumId}/shares`)
|
|
||||||
.then((list) => {
|
|
||||||
setShares(list)
|
|
||||||
setChecked(new Set(list.map((s) => s.id)))
|
|
||||||
})
|
|
||||||
.catch((e) => setError(e.message))
|
|
||||||
}, [albumId])
|
|
||||||
|
|
||||||
const toggleShare = (id) =>
|
|
||||||
setChecked((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(id)) next.delete(id)
|
|
||||||
else next.add(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
// Disabled until the share list is loaded — before that, "everything
|
|
||||||
// checked" can't be expressed and a click would export too little.
|
|
||||||
const disabled = shares == null || (checked.size === 0 && !own)
|
|
||||||
const url = `/api/albums/${albumId}/xmp?${new URLSearchParams({
|
|
||||||
shares: [...checked].join(','),
|
|
||||||
own: String(own),
|
|
||||||
})}`
|
|
||||||
|
|
||||||
const counts = (s) =>
|
|
||||||
[
|
|
||||||
s.accept_count > 0 && `${ACCEPT_GLYPH} ${s.accept_count}`,
|
|
||||||
s.reject_count > 0 && `${REJECT_GLYPH} ${s.reject_count}`,
|
|
||||||
s.rating_count > 0 && `★ ${s.rating_count}`,
|
|
||||||
s.tag_count > 0 && `# ${s.tag_count}`,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' · ') || 'no feedback yet'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal title="XMP export" onClose={onClose}>
|
|
||||||
<p className="muted">
|
|
||||||
Sidecar files for Capture One/Lightroom — unzip next to the RAWs, then load metadata.
|
|
||||||
Ratings merge as the highest vote across the selected sources; any accept wins over
|
|
||||||
rejects.
|
|
||||||
</p>
|
|
||||||
<label className="select-toggle xmp-source">
|
|
||||||
<input type="checkbox" checked={own} onChange={(e) => setOwn(e.target.checked)} />
|
|
||||||
you
|
|
||||||
<span className="muted">your votes and ratings</span>
|
|
||||||
</label>
|
|
||||||
{(shares ?? []).map((s) => (
|
|
||||||
<label key={s.id} className="select-toggle xmp-source">
|
|
||||||
<input type="checkbox" checked={checked.has(s.id)} onChange={() => toggleShare(s.id)} />
|
|
||||||
{s.label || 'unnamed link'}
|
|
||||||
<span className="muted">{counts(s)}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
<div className="row modal-foot">
|
|
||||||
<a
|
|
||||||
className={`btn btn-primary${disabled ? ' btn-disabled' : ''}`}
|
|
||||||
href={disabled ? undefined : url}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (disabled) e.preventDefault()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Download XMP
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{error && <p className="error">{error}</p>}
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+401
-345
@@ -1,38 +1,321 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { api, postDownload } from '../api'
|
import { api, postDownload, sha256Hex, uploadFile } from '../api'
|
||||||
import Gallery from '../components/Gallery'
|
import Gallery from '../components/Gallery'
|
||||||
import Lightbox from '../components/Lightbox'
|
import Lightbox from '../components/Lightbox'
|
||||||
import Modal from '../components/Modal'
|
import SelectionBar, { fmtBytes } from '../components/SelectionBar'
|
||||||
import SelectionBar from '../components/SelectionBar'
|
|
||||||
import SharesPanel from '../components/SharesPanel'
|
|
||||||
import Stars from '../components/Stars'
|
import Stars from '../components/Stars'
|
||||||
import Thumbs, { ACCEPT_GLYPH, REJECT_GLYPH } from '../components/Thumbs'
|
|
||||||
import UploadZone from '../components/UploadZone'
|
|
||||||
import XmpModal from '../components/XmpModal'
|
|
||||||
import useCulling from '../useCulling'
|
|
||||||
import useLightbox from '../useLightbox'
|
|
||||||
import useSelection from '../useSelection'
|
import useSelection from '../useSelection'
|
||||||
|
|
||||||
const FEEDBACK_FILTERS = [
|
function fmtEta(seconds) {
|
||||||
{ key: 'all', label: 'All' },
|
if (!isFinite(seconds) || seconds < 0) return ''
|
||||||
{ key: 'accept', label: ACCEPT_GLYPH },
|
if (seconds < 60) return `${Math.ceil(seconds)}s`
|
||||||
{ key: 'reject', label: REJECT_GLYPH },
|
if (seconds < 3600) return `${Math.ceil(seconds / 60)} min`
|
||||||
{ key: 'undecided', label: 'Undecided' },
|
return `${Math.floor(seconds / 3600)}h ${Math.ceil((seconds % 3600) / 60)} min`
|
||||||
]
|
}
|
||||||
|
|
||||||
// Feedback regrouped per client link for the lightbox footer — the
|
const UPLOAD_CONCURRENCY = 3
|
||||||
// meaningful reading unit is "what did this person say".
|
|
||||||
const clientFeedback = (fb) => {
|
function UploadZone({ albumId, onUploaded }) {
|
||||||
const map = new Map()
|
const [queue, setQueue] = useState([])
|
||||||
const entry = (id, label) => {
|
const [dragging, setDragging] = useState(false)
|
||||||
if (!map.has(id)) map.set(id, { label: label || 'client', tags: [] })
|
const [speed, setSpeed] = useState(0)
|
||||||
return map.get(id)
|
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.
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
const drained = running.current === 0 && pending.current.length === 0
|
||||||
|
if (drained || Date.now() - lastRefresh.current > 5000) {
|
||||||
|
lastRefresh.current = Date.now()
|
||||||
|
onUploaded()
|
||||||
|
}
|
||||||
|
}, [onUploaded])
|
||||||
|
|
||||||
|
const pump = useCallback(() => {
|
||||||
|
while (running.current < UPLOAD_CONCURRENCY && pending.current.length > 0) {
|
||||||
|
const item = pending.current.shift()
|
||||||
|
running.current += 1
|
||||||
|
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. Files too large to hash in the
|
||||||
|
// browser (null) fall straight through to a normal upload.
|
||||||
|
update({ status: 'checking' })
|
||||||
|
try {
|
||||||
|
const hash = await sha256Hex(item.file)
|
||||||
|
if (hash) {
|
||||||
|
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()
|
||||||
|
pump()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [albumId, refresh])
|
||||||
|
|
||||||
|
const addFiles = (files) => {
|
||||||
|
const items = [...files].map((file, i) => ({
|
||||||
|
key: `${Date.now()}-${i}-${file.name}`,
|
||||||
|
file,
|
||||||
|
status: 'queued',
|
||||||
|
progress: 0,
|
||||||
|
}))
|
||||||
|
if (items.length === 0) return
|
||||||
|
setQueue((q) => [...q.filter((x) => x.status !== 'done'), ...items])
|
||||||
|
pending.current.push(...items)
|
||||||
|
pump()
|
||||||
}
|
}
|
||||||
for (const r of fb.ratings) entry(r.share_id, r.share_label).rating = r.rating
|
|
||||||
for (const v of fb.verdicts) entry(v.share_id, v.share_label).verdict = v.verdict
|
return (
|
||||||
for (const t of fb.tags) entry(t.share_id, t.share_label).tags.push(t.tag)
|
<div
|
||||||
return [...map.entries()]
|
className={`upload-zone${dragging ? ' dragging' : ''}`}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragging(true)
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragging(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragging(false)
|
||||||
|
addFiles(e.dataTransfer.files)
|
||||||
|
}}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
addFiles(e.target.files)
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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) => (
|
||||||
|
<li key={item.key} className={`upload-item ${item.status}`}>
|
||||||
|
<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" />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SharesPanel({ albumId }) {
|
||||||
|
const [shares, setShares] = useState([])
|
||||||
|
const [form, setForm] = useState({ label: '', password: '', allow_download: true, expires_at: '' })
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [copied, setCopied] = useState(null)
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
() => api(`/api/albums/${albumId}/shares`).then(setShares).catch((e) => setError(e.message)),
|
||||||
|
[albumId],
|
||||||
|
)
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const create = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
try {
|
||||||
|
await api(`/api/albums/${albumId}/shares`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setForm({ label: '', password: '', allow_download: true, expires_at: '' })
|
||||||
|
setError(null)
|
||||||
|
load()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copy = async (share) => {
|
||||||
|
await navigator.clipboard.writeText(share.url)
|
||||||
|
setCopied(share.id)
|
||||||
|
setTimeout(() => setCopied(null), 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel">
|
||||||
|
<h2>Client links</h2>
|
||||||
|
{shares.length === 0 && <p className="muted">No links yet.</p>}
|
||||||
|
{shares.map((s) => (
|
||||||
|
<div key={s.id} className="share-row">
|
||||||
|
<div className="share-info">
|
||||||
|
<strong>
|
||||||
|
{s.label || 'unnamed link'}
|
||||||
|
{s.locked && <span className="error"> — locked (too many wrong passwords)</span>}
|
||||||
|
</strong>
|
||||||
|
<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
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
{s.locked && (
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={async () => {
|
||||||
|
await api(`/api/shares/${s.id}/reset-lock`, { method: 'POST' })
|
||||||
|
load()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Unlock
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn" onClick={() => copy(s)}>
|
||||||
|
{copied === s.id ? 'Copied!' : 'Copy link'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm(`Delete link "${s.label || s.token}"? Client ratings and tags from this link are removed too.`)) return
|
||||||
|
await api(`/api/shares/${s.id}`, { method: 'DELETE' })
|
||||||
|
load()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<form className="share-form" onSubmit={create}>
|
||||||
|
<input
|
||||||
|
placeholder="Label (e.g. client name)"
|
||||||
|
value={form.label}
|
||||||
|
onChange={(e) => setForm({ ...form, label: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="Password (optional)"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
/>
|
||||||
|
<label className="row field-label">
|
||||||
|
<span className="muted">Expires</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.expires_at}
|
||||||
|
onChange={(e) => setForm({ ...form, expires_at: e.target.value })}
|
||||||
|
/>
|
||||||
|
{form.expires_at ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
title="Remove expiry — link never expires"
|
||||||
|
onClick={() => setForm({ ...form, expires_at: '' })}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="muted">never</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<label className="row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.allow_download}
|
||||||
|
onChange={(e) => setForm({ ...form, allow_download: e.target.checked })}
|
||||||
|
/>
|
||||||
|
allow downloads
|
||||||
|
</label>
|
||||||
|
<button className="btn btn-primary" type="submit">
|
||||||
|
Create link
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AlbumPage() {
|
export default function AlbumPage() {
|
||||||
@@ -40,6 +323,9 @@ export default function AlbumPage() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [detail, setDetail] = useState(null)
|
const [detail, setDetail] = useState(null)
|
||||||
const [error, setError] = 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(
|
const load = useCallback(
|
||||||
() => api(`/api/albums/${id}`).then(setDetail).catch((e) => setError(e.message)),
|
() => api(`/api/albums/${id}`).then(setDetail).catch((e) => setError(e.message)),
|
||||||
@@ -49,184 +335,36 @@ export default function AlbumPage() {
|
|||||||
load()
|
load()
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
// While workers are busy, poll the lightweight pending endpoint and only
|
// Poll while any photo is still being processed by the workers.
|
||||||
// refetch the full album when something actually changed. The signature
|
const hasPending = detail?.photos.some((p) => p.status === 'uploaded' || p.status === 'processing')
|
||||||
// lives in a ref so votes/patches don't restart (and starve) the interval.
|
|
||||||
const notReady = (detail?.photos ?? []).filter((p) => p.status !== 'ready')
|
|
||||||
const hasPending = notReady.some((p) => p.status === 'uploaded' || p.status === 'processing')
|
|
||||||
const signature = (list) => list.map((p) => `${p.id}:${p.status}`).sort().join()
|
|
||||||
const pendingSig = useRef('')
|
|
||||||
pendingSig.current = signature(notReady)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasPending) return
|
if (!hasPending) return
|
||||||
const t = setInterval(async () => {
|
const t = setInterval(load, 4000)
|
||||||
try {
|
|
||||||
const status = await api(`/api/albums/${id}/pending`)
|
|
||||||
if (signature(status.pending) !== pendingSig.current) load()
|
|
||||||
} catch {
|
|
||||||
// transient; next tick retries
|
|
||||||
}
|
|
||||||
}, 4000)
|
|
||||||
return () => clearInterval(t)
|
return () => clearInterval(t)
|
||||||
}, [hasPending, id, load])
|
}, [hasPending, load])
|
||||||
|
|
||||||
const ready = useMemo(
|
const ready = (detail?.photos ?? []).filter((p) => p.status === 'ready')
|
||||||
() => (detail?.photos ?? []).filter((p) => p.status === 'ready'),
|
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(ready)
|
||||||
[detail],
|
const lightboxIndex = ready.findIndex((p) => p.id === lightboxId)
|
||||||
)
|
|
||||||
const feedback = detail?.feedback ?? {}
|
|
||||||
const aggregates = detail?.aggregates ?? {}
|
|
||||||
|
|
||||||
// Whose feedback the filters and overlays look at: null = everyone
|
// If the open photo leaves the ready list (deleted elsewhere, reprocess),
|
||||||
// (clients + own, server-aggregated), 'own', or a share id.
|
// close for good — otherwise the lightbox would pop back open when the
|
||||||
const [client, setClient] = useState(null)
|
// photo returns to ready.
|
||||||
|
|
||||||
// One pass per (album data, scope): everything downstream — filter,
|
|
||||||
// counts, overlay — reads this map instead of re-deriving per render.
|
|
||||||
const scoped = useMemo(() => {
|
|
||||||
const map = new Map()
|
|
||||||
for (const p of ready) {
|
|
||||||
const fb = feedback[p.id]
|
|
||||||
let top = null
|
|
||||||
let accepts = 0
|
|
||||||
let rejects = 0
|
|
||||||
let tags = 0
|
|
||||||
if (client === 'own') {
|
|
||||||
top = p.owner_rating ?? null
|
|
||||||
if (p.owner_verdict === 'accept') accepts = 1
|
|
||||||
if (p.owner_verdict === 'reject') rejects = 1
|
|
||||||
} else if (client) {
|
|
||||||
for (const r of fb?.ratings ?? [])
|
|
||||||
if (r.share_id === client) top = Math.max(top ?? 0, r.rating)
|
|
||||||
for (const v of fb?.verdicts ?? [])
|
|
||||||
if (v.share_id === client) v.verdict === 'accept' ? (accepts += 1) : (rejects += 1)
|
|
||||||
for (const t of fb?.tags ?? []) if (t.share_id === client) tags += 1
|
|
||||||
} else {
|
|
||||||
const agg = aggregates[p.id]
|
|
||||||
top = agg?.top_rating ?? null
|
|
||||||
accepts = agg?.accepts ?? 0
|
|
||||||
rejects = agg?.rejects ?? 0
|
|
||||||
tags = fb?.tags.length ?? 0
|
|
||||||
}
|
|
||||||
map.set(p.id, { top, accepts, rejects, tags })
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
}, [detail, client])
|
|
||||||
|
|
||||||
const [filter, setFilter] = useState('all')
|
|
||||||
const [minStars, setMinStars] = useState(0)
|
|
||||||
const [linksOpen, setLinksOpen] = useState(false)
|
|
||||||
const [xmpOpen, setXmpOpen] = useState(false)
|
|
||||||
|
|
||||||
const matchesVerdict = (s, key) => {
|
|
||||||
if (key === 'all') return true
|
|
||||||
if (key === 'accept') return s.accepts > 0
|
|
||||||
if (key === 'reject') return s.rejects > 0
|
|
||||||
return s.accepts + s.rejects === 0
|
|
||||||
}
|
|
||||||
const shown = useMemo(
|
|
||||||
() =>
|
|
||||||
ready.filter((p) => {
|
|
||||||
const s = scoped.get(p.id)
|
|
||||||
return matchesVerdict(s, filter) && (minStars === 0 || (s.top ?? 0) >= minStars)
|
|
||||||
}),
|
|
||||||
[ready, scoped, filter, minStars],
|
|
||||||
)
|
|
||||||
const filterCounts = useMemo(() => {
|
|
||||||
const counts = { all: ready.length, accept: 0, reject: 0, undecided: 0 }
|
|
||||||
for (const p of ready) {
|
|
||||||
const s = scoped.get(p.id)
|
|
||||||
if (s.accepts > 0) counts.accept += 1
|
|
||||||
if (s.rejects > 0) counts.reject += 1
|
|
||||||
if (s.accepts + s.rejects === 0) counts.undecided += 1
|
|
||||||
}
|
|
||||||
return counts
|
|
||||||
}, [ready, scoped])
|
|
||||||
|
|
||||||
const clientTotals = useMemo(() => {
|
|
||||||
const totals = new Map()
|
|
||||||
const entry = (id, label) => {
|
|
||||||
if (!totals.has(id))
|
|
||||||
totals.set(id, { label: label || 'client', accepts: 0, rejects: 0, ratings: 0, tags: 0 })
|
|
||||||
return totals.get(id)
|
|
||||||
}
|
|
||||||
for (const fb of Object.values(feedback)) {
|
|
||||||
for (const r of fb.ratings) entry(r.share_id, r.share_label).ratings += 1
|
|
||||||
for (const v of fb.verdicts)
|
|
||||||
entry(v.share_id, v.share_label)[v.verdict === 'accept' ? 'accepts' : 'rejects'] += 1
|
|
||||||
for (const t of fb.tags) entry(t.share_id, t.share_label).tags += 1
|
|
||||||
}
|
|
||||||
return [...totals.entries()]
|
|
||||||
}, [detail])
|
|
||||||
|
|
||||||
const ownTotals = useMemo(() => {
|
|
||||||
const totals = { accepts: 0, rejects: 0, ratings: 0, tags: 0 }
|
|
||||||
for (const p of ready) {
|
|
||||||
if (p.owner_verdict === 'accept') totals.accepts += 1
|
|
||||||
if (p.owner_verdict === 'reject') totals.rejects += 1
|
|
||||||
if (p.owner_rating) totals.ratings += 1
|
|
||||||
}
|
|
||||||
return totals
|
|
||||||
}, [ready])
|
|
||||||
|
|
||||||
// The active scope's pill stays visible even at zero counts, so clearing
|
|
||||||
// your last vote can't strand the filter without a control.
|
|
||||||
const strip = [
|
|
||||||
['own', { label: 'you', ...ownTotals }],
|
|
||||||
...clientTotals,
|
|
||||||
].filter(([key, t]) => key === client || t.accepts + t.rejects + t.ratings + t.tags > 0)
|
|
||||||
|
|
||||||
// Reset only when the scoped share is gone entirely (deleted link).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (client && client !== 'own' && !clientTotals.some(([id]) => id === client)) setClient(null)
|
if (lightboxId && lightboxIndex < 0) setLightboxId(null)
|
||||||
}, [client, clientTotals])
|
}, [lightboxId, lightboxIndex])
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
const patchPhoto = (photoId, patch) =>
|
|
||||||
setDetail((d) => ({
|
|
||||||
...d,
|
|
||||||
photos: d.photos.map((p) => (p.id === photoId ? { ...p, ...patch } : p)),
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Per-photo sequence: only the latest request's aggregate may land, so
|
|
||||||
// out-of-order responses can't overwrite fresher state.
|
|
||||||
const aggSeq = useRef(new Map())
|
|
||||||
const saveOwner = async (photo, patch, endpoint, body) => {
|
|
||||||
patchPhoto(photo.id, patch)
|
|
||||||
const seq = (aggSeq.current.get(photo.id) ?? 0) + 1
|
|
||||||
aggSeq.current.set(photo.id, seq)
|
|
||||||
try {
|
|
||||||
const res = await api(`/api/photos/${photo.id}/${endpoint}`, { method: 'PUT', body })
|
|
||||||
if (aggSeq.current.get(photo.id) !== seq) return
|
|
||||||
setDetail((d) => ({
|
|
||||||
...d,
|
|
||||||
aggregates: { ...d.aggregates, [photo.id]: res.aggregate },
|
|
||||||
}))
|
|
||||||
} catch {
|
|
||||||
if (aggSeq.current.get(photo.id) === seq) load()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const setOwnerRating = (photo, rating) =>
|
|
||||||
saveOwner(photo, { owner_rating: rating || null }, 'rating', { rating })
|
|
||||||
const setOwnerVerdict = (photo, verdict) =>
|
|
||||||
saveOwner(photo, { owner_verdict: verdict }, 'verdict', { verdict })
|
|
||||||
|
|
||||||
const culling = useCulling({
|
|
||||||
visible: lightbox.view,
|
|
||||||
lightbox,
|
|
||||||
setVerdict: setOwnerVerdict,
|
|
||||||
setRating: setOwnerRating,
|
|
||||||
toggle,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (error) return <p className="error">{error}</p>
|
if (error) return <p className="error">{error}</p>
|
||||||
if (!detail) return <p className="muted">Loading…</p>
|
if (!detail) return <p className="muted">Loading…</p>
|
||||||
|
|
||||||
const { album, photos } = detail
|
const { album, photos, feedback } = detail
|
||||||
|
const notReady = photos.filter((p) => p.status !== 'ready')
|
||||||
|
|
||||||
|
const avgRating = (photoId) => {
|
||||||
|
const ratings = feedback[photoId]?.ratings || []
|
||||||
|
if (ratings.length === 0) return null
|
||||||
|
return ratings.reduce((sum, r) => sum + r.rating, 0) / ratings.length
|
||||||
|
}
|
||||||
|
|
||||||
const rename = async () => {
|
const rename = async () => {
|
||||||
const name = prompt('Album name', album.name)
|
const name = prompt('Album name', album.name)
|
||||||
@@ -244,24 +382,11 @@ export default function AlbumPage() {
|
|||||||
|
|
||||||
const removePhoto = async (photoId) => {
|
const removePhoto = async (photoId) => {
|
||||||
if (!confirm('Delete this photo?')) return
|
if (!confirm('Delete this photo?')) return
|
||||||
lightbox.close()
|
setLightboxId(null)
|
||||||
await api(`/api/photos/${photoId}`, { method: 'DELETE' })
|
await api(`/api/photos/${photoId}`, { method: 'DELETE' })
|
||||||
load()
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
@@ -272,19 +397,6 @@ export default function AlbumPage() {
|
|||||||
{album.name}
|
{album.name}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<UploadZone
|
|
||||||
albumId={id}
|
|
||||||
onUploaded={load}
|
|
||||||
processing={notReady}
|
|
||||||
onRetry={(p) => api(`/api/photos/${p.id}/reprocess`, { method: 'POST' }).then(load)}
|
|
||||||
onDelete={removePhoto}
|
|
||||||
/>
|
|
||||||
<button className="btn" onClick={() => setLinksOpen(true)}>
|
|
||||||
Client links
|
|
||||||
</button>
|
|
||||||
<button className="btn" onClick={() => setXmpOpen(true)}>
|
|
||||||
XMP
|
|
||||||
</button>
|
|
||||||
<button className="btn" onClick={rename}>
|
<button className="btn" onClick={rename}>
|
||||||
Rename
|
Rename
|
||||||
</button>
|
</button>
|
||||||
@@ -294,119 +406,95 @@ export default function AlbumPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{strip.length > 0 && (
|
<UploadZone albumId={id} onUploaded={load} />
|
||||||
<div className="feedback-strip">
|
|
||||||
{strip.map(([key, t]) => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
className={`client-fb client-fb-btn${client === key ? ' active' : ''}`}
|
|
||||||
title={
|
|
||||||
client === key
|
|
||||||
? 'Show everyone’s feedback again'
|
|
||||||
: `Filter by ${t.label === 'you' ? 'your' : `${t.label}’s`} feedback`
|
|
||||||
}
|
|
||||||
onClick={() => setClient((c) => (c === key ? null : key))}
|
|
||||||
>
|
|
||||||
<span className="muted">{t.label}</span>
|
|
||||||
{t.accepts > 0 && (
|
|
||||||
<span className="chip chip-accept">
|
|
||||||
{ACCEPT_GLYPH} {t.accepts}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{t.rejects > 0 && (
|
|
||||||
<span className="chip chip-reject">
|
|
||||||
{REJECT_GLYPH} {t.rejects}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{t.ratings > 0 && <span className="chip">★ {t.ratings}</span>}
|
|
||||||
{t.tags > 0 && <span className="chip"># {t.tags}</span>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ready.length > 0 && (
|
{notReady.length > 0 && (
|
||||||
<div className="filter-bar filter-bar-admin">
|
<section className="panel">
|
||||||
{FEEDBACK_FILTERS.map((f) => (
|
<h2>Processing</h2>
|
||||||
<button
|
<ul className="pending-list">
|
||||||
key={f.key}
|
{notReady.map((p) => (
|
||||||
className={`filter-chip${filter === f.key ? ' active' : ''}`}
|
<li key={p.id}>
|
||||||
onClick={() => setFilter(f.key)}
|
<span className="upload-name">{p.filename}</span>
|
||||||
>
|
{p.status === 'error' ? (
|
||||||
{f.label} {filterCounts[f.key]}
|
<span className="row">
|
||||||
</button>
|
<span className="error">{p.error || 'failed'}</span>
|
||||||
))}
|
<button
|
||||||
<span
|
className="btn"
|
||||||
className={`filter-stars${minStars > 0 ? ' active' : ''}`}
|
onClick={() => api(`/api/photos/${p.id}/reprocess`, { method: 'POST' }).then(load)}
|
||||||
title="Minimum top rating in the selected scope — click a star to filter, click it again to clear"
|
>
|
||||||
>
|
Retry
|
||||||
<Stars value={minStars} onChange={(n) => setMinStars(n)} small />
|
</button>
|
||||||
{minStars > 0 && <span className="muted">≥ {minStars}</span>}
|
<button className="btn btn-danger" onClick={() => removePhoto(p.id)}>
|
||||||
</span>
|
Delete
|
||||||
</div>
|
</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="muted">{p.status}…</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Gallery
|
<Gallery
|
||||||
photos={shown}
|
photos={ready}
|
||||||
onOpen={lightbox.openAt}
|
onOpen={(i) => setLightboxId(ready[i].id)}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
onToggleSelect={(photoId, shift) => toggle(photoId, shift, shown)}
|
onToggleSelect={toggle}
|
||||||
keyboard={lightbox.index < 0 && !linksOpen && !xmpOpen}
|
|
||||||
externalIndex={lightbox.index}
|
|
||||||
actions={culling.keyActions}
|
|
||||||
overlay={(p) => {
|
overlay={(p) => {
|
||||||
const s = scoped.get(p.id)
|
const avg = avgRating(p.id)
|
||||||
if (!s || (s.top === null && s.tags === 0 && s.accepts === 0 && s.rejects === 0))
|
const tagCount = feedback[p.id]?.tags.length || 0
|
||||||
return null
|
if (avg === null && tagCount === 0) return null
|
||||||
return (
|
return (
|
||||||
<div className="g-overlay">
|
<div className="g-overlay">
|
||||||
{s.accepts > 0 && <span>👍 {s.accepts}</span>}
|
{avg !== null && <span>★ {avg.toFixed(1)}</span>}
|
||||||
{s.rejects > 0 && <span>👎 {s.rejects}</span>}
|
{tagCount > 0 && <span># {tagCount}</span>}
|
||||||
{s.top !== null && <span>{'★'.repeat(s.top)}</span>}
|
|
||||||
{s.tags > 0 && <span># {s.tags}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{ready.length === 0 && notReady.length === 0 && (
|
{ready.length === 0 && notReady.length === 0 && (
|
||||||
<p className="muted">No photos yet — use Upload or drop files anywhere on the page.</p>
|
<p className="muted">No photos yet — drop some above.</p>
|
||||||
)}
|
|
||||||
{ready.length > 0 && shown.length === 0 && (
|
|
||||||
<p className="muted">No photos match this filter.</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SelectionBar
|
<SelectionBar
|
||||||
count={shownSelected.length}
|
count={selected.size}
|
||||||
total={shown.length}
|
total={ready.length}
|
||||||
selectedBytes={sumBytes(shownSelected)}
|
selectedBytes={selectedBytes}
|
||||||
totalBytes={sumBytes(shown)}
|
totalBytes={totalBytes}
|
||||||
onSelectAll={() => selectAll(shown)}
|
onSelectAll={selectAll}
|
||||||
onClear={clear}
|
onClear={clear}
|
||||||
onDownload={() =>
|
onDownload={() => postDownload(`/api/albums/${id}/zip`, [...selected].join(','))}
|
||||||
postDownload(`/api/albums/${id}/zip`, shownSelected.map((p) => p.id).join(','))
|
onDownloadAll={() => postDownload(`/api/albums/${id}/zip`)}
|
||||||
}
|
|
||||||
onDownloadAll={() =>
|
|
||||||
postDownload(`/api/albums/${id}/zip`, filter === 'all' ? '' : shown.map((p) => p.id).join(','))
|
|
||||||
}
|
|
||||||
onDelete={removeSelected}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{lightbox.index >= 0 && (
|
{lightboxIndex >= 0 && (
|
||||||
<Lightbox
|
<Lightbox
|
||||||
photos={lightbox.view}
|
photos={ready}
|
||||||
index={lightbox.index}
|
index={lightboxIndex}
|
||||||
onClose={lightbox.close}
|
onClose={() => setLightboxId(null)}
|
||||||
onNav={lightbox.openAt}
|
onNav={(i) => setLightboxId(ready[i].id)}
|
||||||
{...culling.lightboxProps}
|
|
||||||
footer={(p) => {
|
footer={(p) => {
|
||||||
const groups = clientFeedback(feedback[p.id] || { ratings: [], verdicts: [], tags: [] })
|
const fb = feedback[p.id] || { ratings: [], tags: [] }
|
||||||
return (
|
return (
|
||||||
<div className="admin-footer">
|
<div className="admin-footer">
|
||||||
<div className="admin-actions">
|
<div className="feedback">
|
||||||
<span className="own-feedback">
|
{fb.ratings.length === 0 && fb.tags.length === 0 && (
|
||||||
<Thumbs value={p.owner_verdict} onChange={(v) => setOwnerVerdict(p, v)} />
|
<span className="muted">No client feedback yet</span>
|
||||||
<Stars value={p.owner_rating || 0} onChange={(r) => setOwnerRating(p, r)} />
|
)}
|
||||||
</span>
|
{fb.ratings.map((r, i) => (
|
||||||
|
<span key={`r${i}`} className="feedback-item">
|
||||||
|
{r.share_label || 'client'}: <Stars value={r.rating} small />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{fb.tags.map((t, i) => (
|
||||||
|
<span key={`t${i}`} className="chip">
|
||||||
|
{t.tag} <em className="muted">({t.share_label || 'client'})</em>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
<label className="select-toggle">
|
<label className="select-toggle">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -422,45 +510,13 @@ export default function AlbumPage() {
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="feedback">
|
|
||||||
{groups.length === 0 && <span className="muted">No client feedback yet</span>}
|
|
||||||
{groups.map(([shareId, c]) => (
|
|
||||||
<span
|
|
||||||
key={shareId}
|
|
||||||
className={`client-fb${c.verdict ? ` client-fb-${c.verdict}` : ''}`}
|
|
||||||
>
|
|
||||||
<span className="muted">{c.label}</span>
|
|
||||||
{c.verdict && (
|
|
||||||
<span className="client-fb-verdict">
|
|
||||||
{c.verdict === 'accept' ? ACCEPT_GLYPH : REJECT_GLYPH}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{c.rating && <Stars value={c.rating} small />}
|
|
||||||
{c.tags.map((tag) => (
|
|
||||||
<span key={tag} className="chip">
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{linksOpen && (
|
<SharesPanel albumId={id} />
|
||||||
<Modal title="Client links" onClose={() => setLinksOpen(false)}>
|
|
||||||
<SharesPanel albumId={id} />
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
{xmpOpen && <XmpModal albumId={id} onClose={() => setXmpOpen(false)} />}
|
|
||||||
{culling.flash && (
|
|
||||||
<div key={culling.flash.key} className="action-flash">
|
|
||||||
{culling.flash.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { api, postDownload } from '../api'
|
import { api, postDownload } from '../api'
|
||||||
import Gallery from '../components/Gallery'
|
import Gallery from '../components/Gallery'
|
||||||
@@ -6,44 +6,18 @@ import Lightbox from '../components/Lightbox'
|
|||||||
import SelectionBar from '../components/SelectionBar'
|
import SelectionBar from '../components/SelectionBar'
|
||||||
import Stars from '../components/Stars'
|
import Stars from '../components/Stars'
|
||||||
import TagEditor from '../components/TagEditor'
|
import TagEditor from '../components/TagEditor'
|
||||||
import Thumbs, { ACCEPT_GLYPH, REJECT_GLYPH } from '../components/Thumbs'
|
|
||||||
import useCulling from '../useCulling'
|
|
||||||
import useLightbox from '../useLightbox'
|
|
||||||
import useSelection from '../useSelection'
|
import useSelection from '../useSelection'
|
||||||
|
|
||||||
const FILTERS = [
|
|
||||||
{ key: 'all', label: 'All' },
|
|
||||||
{ key: 'accept', label: ACCEPT_GLYPH },
|
|
||||||
{ key: 'reject', label: REJECT_GLYPH },
|
|
||||||
{ key: 'undecided', label: 'Undecided' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const matchesFilter = (photo, key) =>
|
|
||||||
key === 'all' || (key === 'undecided' ? !photo.my_verdict : photo.my_verdict === key)
|
|
||||||
|
|
||||||
export default function SharePage() {
|
export default function SharePage() {
|
||||||
const { token } = useParams()
|
const { token } = useParams()
|
||||||
const [view, setView] = useState(null)
|
const [view, setView] = useState(null)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [unlockError, setUnlockError] = useState(null)
|
const [unlockError, setUnlockError] = useState(null)
|
||||||
const [filter, setFilter] = useState('all')
|
const [lightbox, setLightbox] = useState(-1)
|
||||||
|
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(
|
||||||
const photos = view?.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)
|
|
||||||
|
|
||||||
const load = useCallback(
|
const load = useCallback(
|
||||||
() => api(`/api/share/${token}`).then(setView).catch((e) => setError(e.message)),
|
() => api(`/api/share/${token}`).then(setView).catch((e) => setError(e.message)),
|
||||||
@@ -71,30 +45,29 @@ export default function SharePage() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimistic write: patch local state, PUT, reload from the server on
|
const setRating = async (photo, rating) => {
|
||||||
// failure to undo the patch.
|
patchPhoto(photo.id, { my_rating: rating || null })
|
||||||
const saveFeedback = async (photo, patch, endpoint, body) => {
|
|
||||||
patchPhoto(photo.id, patch)
|
|
||||||
try {
|
try {
|
||||||
await api(`/api/share/${token}/photos/${photo.id}/${endpoint}`, { method: 'PUT', body })
|
await api(`/api/share/${token}/photos/${photo.id}/rating`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { rating },
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
load()
|
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 })
|
|
||||||
|
|
||||||
const culling = useCulling({
|
const setTags = async (photo, tags) => {
|
||||||
visible: lightbox.view,
|
patchPhoto(photo.id, { my_tags: tags })
|
||||||
lightbox,
|
try {
|
||||||
setVerdict,
|
await api(`/api/share/${token}/photos/${photo.id}/tags`, {
|
||||||
setRating,
|
method: 'PUT',
|
||||||
toggle,
|
body: { tags },
|
||||||
canSelect: !!view?.allow_download,
|
})
|
||||||
})
|
} catch {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (error) return <div className="center-page">{error}</div>
|
if (error) return <div className="center-page">{error}</div>
|
||||||
if (!view) return <div className="center-page">Loading…</div>
|
if (!view) return <div className="center-page">Loading…</div>
|
||||||
@@ -127,83 +100,49 @@ export default function SharePage() {
|
|||||||
<h1>{view.album_name}</h1>
|
<h1>{view.album_name}</h1>
|
||||||
{view.album_description && <p className="muted">{view.album_description}</p>}
|
{view.album_description && <p className="muted">{view.album_description}</p>}
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{photos.length} photo{photos.length === 1 ? '' : 's'} · tap a photo to view, rate and
|
{view.photos.length} photo{view.photos.length === 1 ? '' : 's'} · click a photo to view,
|
||||||
tag · swipe ↑ to accept, ↓ to reject · <kbd>?</kbd> shows keyboard shortcuts
|
rate and tag
|
||||||
</p>
|
</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>
|
</header>
|
||||||
<main className="page">
|
<main className="page">
|
||||||
<Gallery
|
<Gallery
|
||||||
photos={visible}
|
photos={view.photos}
|
||||||
onOpen={lightbox.openAt}
|
onOpen={setLightbox}
|
||||||
selected={view.allow_download ? selected : undefined}
|
selected={view.allow_download ? selected : undefined}
|
||||||
// Shift-ranges must walk the filtered view, not the full album —
|
onToggleSelect={view.allow_download ? toggle : undefined}
|
||||||
// otherwise hidden photos get swept into the selection.
|
|
||||||
onToggleSelect={
|
|
||||||
view.allow_download ? (id, shift) => toggle(id, shift, visible) : undefined
|
|
||||||
}
|
|
||||||
keyboard={lightbox.index < 0}
|
|
||||||
externalIndex={lightbox.index}
|
|
||||||
actions={culling.keyActions}
|
|
||||||
overlay={(p) =>
|
overlay={(p) =>
|
||||||
p.my_verdict || p.my_rating || p.my_tags.length > 0 ? (
|
p.my_rating || p.my_tags.length > 0 ? (
|
||||||
<div className="g-overlay">
|
<div className="g-overlay">
|
||||||
{p.my_verdict && <span>{p.my_verdict === 'accept' ? '👍' : '👎'}</span>}
|
{p.my_rating && <span>★ {p.my_rating}</span>}
|
||||||
{p.my_rating && <span>{'★'.repeat(p.my_rating)}</span>}
|
|
||||||
{p.my_tags.length > 0 && <span># {p.my_tags.length}</span>}
|
{p.my_tags.length > 0 && <span># {p.my_tags.length}</span>}
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{photos.length === 0 && (
|
{view.photos.length === 0 && (
|
||||||
<p className="center-page muted">Nothing here yet — check back soon.</p>
|
<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>
|
</main>
|
||||||
{view.allow_download && (
|
{view.allow_download && (
|
||||||
<SelectionBar
|
<SelectionBar
|
||||||
count={visibleSelected.length}
|
count={selected.size}
|
||||||
total={visible.length}
|
total={view.photos.length}
|
||||||
selectedBytes={sumBytes(visibleSelected)}
|
selectedBytes={selectedBytes}
|
||||||
totalBytes={sumBytes(visible)}
|
totalBytes={totalBytes}
|
||||||
onSelectAll={() => selectAll(visible)}
|
onSelectAll={selectAll}
|
||||||
onClear={clear}
|
onClear={clear}
|
||||||
onDownload={() =>
|
onDownload={() => postDownload(`/api/share/${token}/zip`, [...selected].join(','))}
|
||||||
postDownload(`/api/share/${token}/zip`, visibleSelected.map((p) => p.id).join(','))
|
onDownloadAll={() => postDownload(`/api/share/${token}/zip`)}
|
||||||
}
|
|
||||||
onDownloadAll={() =>
|
|
||||||
// Under a filter, "download all" means all photos shown.
|
|
||||||
postDownload(
|
|
||||||
`/api/share/${token}/zip`,
|
|
||||||
filter === 'all' ? '' : visible.map((p) => p.id).join(','),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{lightbox.index >= 0 && (
|
{lightbox >= 0 && (
|
||||||
<Lightbox
|
<Lightbox
|
||||||
photos={lightbox.view}
|
photos={view.photos}
|
||||||
index={lightbox.index}
|
index={lightbox}
|
||||||
onClose={lightbox.close}
|
onClose={() => setLightbox(-1)}
|
||||||
onNav={lightbox.openAt}
|
onNav={setLightbox}
|
||||||
{...culling.lightboxProps}
|
|
||||||
footer={(p) => (
|
footer={(p) => (
|
||||||
<div className="client-footer">
|
<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)} />
|
<Stars value={p.my_rating || 0} onChange={(r) => setRating(p, r)} />
|
||||||
<TagEditor tags={p.my_tags} onChange={(tags) => setTags(p, tags)} />
|
<TagEditor tags={p.my_tags} onChange={(tags) => setTags(p, tags)} />
|
||||||
{view.allow_download && (
|
{view.allow_download && (
|
||||||
@@ -225,11 +164,6 @@ export default function SharePage() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{culling.flash && (
|
|
||||||
<div key={culling.flash.key} className="action-flash">
|
|
||||||
{culling.flash.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-539
@@ -10,16 +10,9 @@
|
|||||||
--muted: #9a978f;
|
--muted: #9a978f;
|
||||||
--accent: #d9a441;
|
--accent: #d9a441;
|
||||||
--danger: #e5645a;
|
--danger: #e5645a;
|
||||||
--accept: #7ac97a;
|
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
|
||||||
/* Reserve the scrollbar gutter — content width must not shift when a
|
|
||||||
filter change toggles the scrollbar. */
|
|
||||||
scrollbar-gutter: stable;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
@@ -112,11 +105,6 @@ input {
|
|||||||
input:focus {
|
input:focus {
|
||||||
outline: 1px solid var(--accent);
|
outline: 1px solid var(--accent);
|
||||||
}
|
}
|
||||||
input[type='checkbox'] {
|
|
||||||
accent-color: var(--accent);
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: var(--panel-2);
|
background: var(--panel-2);
|
||||||
@@ -170,9 +158,10 @@ input[type='checkbox'] {
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
transition: transform 0.1s;
|
||||||
}
|
}
|
||||||
.album-card:hover .album-cover img {
|
.album-card:hover {
|
||||||
filter: brightness(1.08);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
.album-cover {
|
.album-cover {
|
||||||
aspect-ratio: 3 / 2;
|
aspect-ratio: 3 / 2;
|
||||||
@@ -199,41 +188,41 @@ input[type='checkbox'] {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* photo grid (Lightroom-style fixed cells) */
|
/* justified gallery */
|
||||||
.gallery {
|
.gallery {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
.g-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
.g-item {
|
.g-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
aspect-ratio: 1 / 1;
|
flex: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
content-visibility: auto;
|
|
||||||
contain-intrinsic-size: 300px;
|
|
||||||
}
|
}
|
||||||
.g-item img {
|
.g-item img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 8px;
|
object-fit: cover;
|
||||||
object-fit: contain;
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.g-overlay {
|
.g-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 6px;
|
bottom: 0;
|
||||||
left: 6px;
|
left: 0;
|
||||||
display: inline-flex;
|
right: 0;
|
||||||
align-items: center;
|
display: flex;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
padding: 0.2rem 0.55rem;
|
padding: 0.35rem 0.55rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
background: rgba(0, 0, 0, 0.55);
|
background: linear-gradient(transparent, rgba(0, 0, 0, 0.75));
|
||||||
border-radius: 6px;
|
|
||||||
color: #ffd97a;
|
color: #ffd97a;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,10 +257,6 @@ input[type='checkbox'] {
|
|||||||
outline: 3px solid var(--accent);
|
outline: 3px solid var(--accent);
|
||||||
outline-offset: -3px;
|
outline-offset: -3px;
|
||||||
}
|
}
|
||||||
.g-item.focused {
|
|
||||||
outline: 2px solid var(--text);
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
.select-bar {
|
.select-bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 1.25rem;
|
bottom: 1.25rem;
|
||||||
@@ -299,8 +284,41 @@ input[type='checkbox'] {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.select-toggle input {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
/* upload */
|
/* upload zone */
|
||||||
|
.upload-zone {
|
||||||
|
border: 2px dashed var(--panel-2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.upload-zone.dragging {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.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 {
|
.upload-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 1rem 0 0;
|
margin: 1rem 0 0;
|
||||||
@@ -331,121 +349,6 @@ progress {
|
|||||||
accent-color: var(--accent);
|
accent-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* fullscreen drag target */
|
|
||||||
.drop-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 10px;
|
|
||||||
z-index: 200;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(16, 18, 22, 0.82);
|
|
||||||
border: 3px dashed var(--accent);
|
|
||||||
border-radius: 12px;
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 1.4rem;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* activity overlay: uploads + processing, bottom right */
|
|
||||||
.activity {
|
|
||||||
position: fixed;
|
|
||||||
right: 1rem;
|
|
||||||
bottom: calc(1rem + env(safe-area-inset-bottom));
|
|
||||||
width: min(340px, calc(100vw - 2rem));
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--panel-2);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
|
|
||||||
z-index: 60;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.activity-head {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.5rem;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text);
|
|
||||||
padding: 0.55rem 0.8rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.activity-clear {
|
|
||||||
color: var(--muted);
|
|
||||||
padding: 0 0.2rem;
|
|
||||||
}
|
|
||||||
.activity-total {
|
|
||||||
display: block;
|
|
||||||
width: calc(100% - 1.6rem);
|
|
||||||
margin: 0 0.8rem 0.5rem;
|
|
||||||
}
|
|
||||||
.activity-body {
|
|
||||||
border-top: 1px solid var(--panel-2);
|
|
||||||
padding: 0.5rem 0.8rem 0.7rem;
|
|
||||||
max-height: 40vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.activity-body .upload-list {
|
|
||||||
margin: 0.25rem 0 0;
|
|
||||||
max-height: none;
|
|
||||||
}
|
|
||||||
.activity-body progress {
|
|
||||||
width: 110px;
|
|
||||||
}
|
|
||||||
.activity-section {
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
.activity-section:first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* modal */
|
|
||||||
.modal {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 90;
|
|
||||||
background: rgba(8, 9, 11, 0.7);
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 8vh 1rem 1rem;
|
|
||||||
}
|
|
||||||
.modal-card {
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--panel-2);
|
|
||||||
border-radius: 12px;
|
|
||||||
width: min(760px, 100%);
|
|
||||||
max-height: 80vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 1rem 1.25rem 1.25rem;
|
|
||||||
}
|
|
||||||
.modal-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
.modal-head h2 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.modal-foot {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
.xmp-source {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.3rem 0;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.btn-disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* panels */
|
/* panels */
|
||||||
.panel {
|
.panel {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
@@ -484,72 +387,14 @@ progress {
|
|||||||
.share-info .muted {
|
.share-info .muted {
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
.share-stats {
|
.share-form {
|
||||||
display: inline-flex;
|
|
||||||
gap: 0.3rem;
|
|
||||||
}
|
|
||||||
.share-feedback {
|
|
||||||
margin-top: 0.35rem;
|
|
||||||
}
|
|
||||||
.feedback-strip {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
}
|
|
||||||
.client-fb-btn {
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: inherit;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.client-fb-btn.active {
|
|
||||||
border-color: var(--accent);
|
|
||||||
background: var(--panel-2);
|
|
||||||
}
|
|
||||||
.chip-accept {
|
|
||||||
background: color-mix(in srgb, var(--accept) 18%, var(--panel-2));
|
|
||||||
}
|
|
||||||
.chip-reject {
|
|
||||||
background: color-mix(in srgb, var(--danger) 18%, var(--panel-2));
|
|
||||||
}
|
|
||||||
.share-url {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
margin-top: 0.15rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
.share-url:hover {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
.share-form {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: max-content minmax(0, 320px);
|
|
||||||
gap: 0.6rem 1rem;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
border-top: 1px solid var(--panel-2);
|
|
||||||
padding-top: 0.9rem;
|
|
||||||
}
|
}
|
||||||
.share-form-title {
|
.share-form label {
|
||||||
grid-column: 1 / -1;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.share-form .field {
|
|
||||||
display: contents;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.share-form-full {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -606,18 +451,6 @@ progress {
|
|||||||
.lb-stage {
|
.lb-stage {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
/* Swipes are handled in JS; stop the browser from panning/zooming. */
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
.lb-track {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
}
|
|
||||||
.lb-slide {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -629,79 +462,6 @@ progress {
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.lb-flick {
|
|
||||||
z-index: 2;
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 5rem;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.6);
|
|
||||||
}
|
|
||||||
/* Next photo waiting behind a vote drag, before its zoom-in. */
|
|
||||||
.lb-behind {
|
|
||||||
transform: scale(0.22);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
/* Ghost of a just-voted photo flying off; the vote itself is already saved. */
|
|
||||||
.lb-ghost {
|
|
||||||
z-index: 3;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.lb-exit-up {
|
|
||||||
animation: lb-exit-up 0.26s ease-in forwards;
|
|
||||||
}
|
|
||||||
.lb-exit-down {
|
|
||||||
animation: lb-exit-down 0.26s ease-in forwards;
|
|
||||||
}
|
|
||||||
@keyframes lb-exit-up {
|
|
||||||
from {
|
|
||||||
transform: translateY(var(--dy)) rotate(var(--rot));
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(-120vh) rotate(var(--rot));
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes lb-exit-down {
|
|
||||||
from {
|
|
||||||
transform: translateY(var(--dy)) rotate(var(--rot));
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(120vh) rotate(var(--rot));
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.lb-hidden {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
/* Whole-modal fade after voting the last photo — the gallery is behind it. */
|
|
||||||
.lightbox.lb-closing {
|
|
||||||
animation: lb-fade-out 0.26s ease forwards;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
@keyframes lb-fade-out {
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* The photo taking over after a vote zooms in organically. */
|
|
||||||
.lb-enter {
|
|
||||||
animation: lb-enter 0.26s ease-out;
|
|
||||||
}
|
|
||||||
@keyframes lb-enter {
|
|
||||||
from {
|
|
||||||
transform: scale(0.3);
|
|
||||||
opacity: 0.2;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.lb-nav {
|
.lb-nav {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -729,49 +489,14 @@ progress {
|
|||||||
.lb-footer {
|
.lb-footer {
|
||||||
padding: 0.7rem 1rem 1rem;
|
padding: 0.7rem 1rem 1rem;
|
||||||
}
|
}
|
||||||
.client-footer {
|
.client-footer,
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1.25rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
/* Two rows: things you do (own vote + photo actions), things you read
|
|
||||||
(per-client feedback pills). */
|
|
||||||
.admin-footer {
|
.admin-footer {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
}
|
|
||||||
.admin-actions {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.client-fb {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--panel-2);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.15rem 0.7rem;
|
|
||||||
}
|
|
||||||
.client-fb-verdict {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.client-fb-accept {
|
|
||||||
border-color: color-mix(in srgb, var(--accept) 60%, transparent);
|
|
||||||
background: color-mix(in srgb, var(--accept) 16%, var(--panel));
|
|
||||||
}
|
|
||||||
.client-fb-reject {
|
|
||||||
border-color: color-mix(in srgb, var(--danger) 60%, transparent);
|
|
||||||
background: color-mix(in srgb, var(--danger) 16%, var(--panel));
|
|
||||||
}
|
|
||||||
.feedback {
|
.feedback {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -779,10 +504,10 @@ progress {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.own-feedback {
|
.feedback-item {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* stars */
|
/* stars */
|
||||||
@@ -809,150 +534,6 @@ progress {
|
|||||||
cursor: default;
|
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 */
|
/* tags */
|
||||||
.tag-editor {
|
.tag-editor {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -983,74 +564,12 @@ kbd {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Touch devices: no hover — selection checkmarks must always be visible,
|
|
||||||
and the overlay nav arrows give way to swiping. */
|
|
||||||
@media (hover: none) {
|
|
||||||
.g-check {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
.lb-nav {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
/* No visible arrows to keep clear of — give the photo the width. */
|
|
||||||
.lb-slide {
|
|
||||||
padding: 0 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.gallery {
|
.lb-stage {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
|
||||||
}
|
|
||||||
.lb-slide {
|
|
||||||
padding: 0 0.5rem;
|
padding: 0 0.5rem;
|
||||||
}
|
}
|
||||||
.login-card {
|
.login-card {
|
||||||
padding: 2rem 1.5rem;
|
padding: 2rem 1.5rem;
|
||||||
margin: 0 1rem;
|
margin: 0 1rem;
|
||||||
}
|
}
|
||||||
.share-head {
|
|
||||||
padding: 1.25rem 0.75rem 0.25rem;
|
|
||||||
}
|
|
||||||
.lb-footer {
|
|
||||||
padding: 0.5rem 0.6rem calc(0.8rem + env(safe-area-inset-bottom));
|
|
||||||
}
|
|
||||||
.client-footer {
|
|
||||||
gap: 0.8rem;
|
|
||||||
}
|
|
||||||
/* Finger-sized vote and rating targets in the client lightbox. */
|
|
||||||
.client-footer .thumb {
|
|
||||||
font-size: 1.7rem;
|
|
||||||
}
|
|
||||||
.client-footer .star {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
}
|
|
||||||
.select-bar {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
max-width: calc(100vw - 1.5rem);
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
bottom: calc(0.75rem + env(safe-area-inset-bottom));
|
|
||||||
}
|
|
||||||
/* Share rows: info and controls stack instead of fighting for one line. */
|
|
||||||
.share-row {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem 0;
|
|
||||||
}
|
|
||||||
.share-row .row {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
row-gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.share-form {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.share-form .field {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.share-form .field > input {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 0.2rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { ACCEPT_GLYPH, REJECT_GLYPH } from './components/Thumbs'
|
|
||||||
|
|
||||||
// Shared culling interaction for a lightbox over a (possibly filtered) photo
|
|
||||||
// list: the keyboard table (P/X/U, stars, select), swipe gestures, the
|
|
||||||
// action toast, and the modal fade-out after voting the last photo.
|
|
||||||
export default function useCulling({
|
|
||||||
visible,
|
|
||||||
lightbox,
|
|
||||||
setVerdict,
|
|
||||||
setRating,
|
|
||||||
toggle,
|
|
||||||
canSelect = true,
|
|
||||||
}) {
|
|
||||||
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), [])
|
|
||||||
|
|
||||||
const [fading, setFading] = useState(false)
|
|
||||||
useEffect(() => {
|
|
||||||
if (lightbox.index < 0) setFading(false)
|
|
||||||
}, [lightbox.index])
|
|
||||||
|
|
||||||
// Keyboard votes stay on the photo (the footer controls show the result);
|
|
||||||
// only touch gestures advance, where the fly-off animation carries the
|
|
||||||
// context.
|
|
||||||
const vote = (photo, verdict) => {
|
|
||||||
setVerdict(photo, verdict)
|
|
||||||
showFlash(
|
|
||||||
verdict === 'accept' ? `${ACCEPT_GLYPH} ${photo.filename}` : `${REJECT_GLYPH} ${photo.filename}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const voteAndAdvance = (photo, verdict) => {
|
|
||||||
const next = visible[lightbox.index + 1]
|
|
||||||
setVerdict(photo, verdict)
|
|
||||||
showFlash(
|
|
||||||
verdict === 'accept'
|
|
||||||
? `${ACCEPT_GLYPH} ${photo.filename}`
|
|
||||||
: verdict === 'reject'
|
|
||||||
? `${REJECT_GLYPH} ${photo.filename}`
|
|
||||||
: `↺ ${photo.filename} cleared`,
|
|
||||||
)
|
|
||||||
if (next) lightbox.show(next.id)
|
|
||||||
else setFading(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyActions = [
|
|
||||||
{ keys: ['p'], help: ['P', 'accept'], run: (p) => vote(p, 'accept') },
|
|
||||||
{ keys: ['x'], help: ['X', 'reject'], run: (p) => vote(p, 'reject') },
|
|
||||||
{ keys: ['u'], help: ['U', 'clear accept / reject'], run: (p) => setVerdict(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) },
|
|
||||||
...(canSelect
|
|
||||||
? [{ keys: ['s'], help: ['S', 'select for download'], run: (p) => toggle(p.id) }]
|
|
||||||
: []),
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
flash,
|
|
||||||
keyActions,
|
|
||||||
lightboxProps: {
|
|
||||||
actions: keyActions,
|
|
||||||
gestures: {
|
|
||||||
up: (p) => voteAndAdvance(p, 'accept'),
|
|
||||||
down: (p) => voteAndAdvance(p, 'reject'),
|
|
||||||
},
|
|
||||||
closing: fading,
|
|
||||||
onClosed: () => {
|
|
||||||
setFading(false)
|
|
||||||
lightbox.close()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { useEffect } from 'react'
|
|
||||||
|
|
||||||
export const isTypingTarget = (el) =>
|
|
||||||
el.tagName === 'TEXTAREA' ||
|
|
||||||
el.isContentEditable ||
|
|
||||||
(el.tagName === 'INPUT' && !['checkbox', 'radio', 'button'].includes(el.type))
|
|
||||||
|
|
||||||
// Escape closes — unless focus is in a text field, where it only leaves it.
|
|
||||||
export default function useEscape(onClose) {
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e) => {
|
|
||||||
if (e.key !== 'Escape') return
|
|
||||||
if (isTypingTarget(e.target)) {
|
|
||||||
e.target.blur()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', handler)
|
|
||||||
return () => window.removeEventListener('keydown', handler)
|
|
||||||
}, [onClose])
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
|
|
||||||
// Lightbox state tracked by photo id, not index — the list can reorder or
|
|
||||||
// shrink underneath an open lightbox. While open, the visible list is FROZEN
|
|
||||||
// to the ids present at open time (mapped to live photo objects, so votes
|
|
||||||
// still update the open photo): voting a photo out of the active filter must
|
|
||||||
// not yank it from under the viewer. Filters re-apply on close. If the open
|
|
||||||
// photo disappears entirely (deleted), the lightbox closes for good.
|
|
||||||
export default function useLightbox(photos) {
|
|
||||||
const [openId, setOpenId] = useState(null)
|
|
||||||
const frozen = useRef(null)
|
|
||||||
|
|
||||||
const byId = useMemo(() => new Map(photos.map((p) => [p.id, p])), [photos])
|
|
||||||
const view =
|
|
||||||
openId && frozen.current ? frozen.current.map((id) => byId.get(id)).filter(Boolean) : photos
|
|
||||||
const index = openId ? view.findIndex((p) => p.id === openId) : -1
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (openId && index < 0) {
|
|
||||||
frozen.current = null
|
|
||||||
setOpenId(null)
|
|
||||||
}
|
|
||||||
}, [openId, index])
|
|
||||||
|
|
||||||
const open = (id) => {
|
|
||||||
if (!frozen.current) frozen.current = photos.map((p) => p.id)
|
|
||||||
setOpenId(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
index,
|
|
||||||
view,
|
|
||||||
openAt: (i) => view[i] && open(view[i].id),
|
|
||||||
show: (id) => open(id),
|
|
||||||
close: () => {
|
|
||||||
frozen.current = null
|
|
||||||
setOpenId(null)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
// Multi-select over a photo list. Selection lives here (not in the gallery)
|
// Multi-select over a photo list. Selection lives here (not in the gallery)
|
||||||
// so it survives lightbox open/close, and is pruned automatically when
|
// so it survives lightbox open/close, and is pruned automatically when
|
||||||
// photos disappear from the list (deletes, polling refreshes).
|
// photos disappear from the list (deletes, polling refreshes).
|
||||||
export default function useSelection(photos) {
|
export default function useSelection(photos) {
|
||||||
const [selected, setSelected] = useState(() => new Set())
|
const [selected, setSelected] = useState(() => new Set())
|
||||||
// Anchor for shift-click range selection: the photo last toggled.
|
|
||||||
const lastToggled = useRef(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
@@ -17,43 +15,16 @@ export default function useSelection(photos) {
|
|||||||
})
|
})
|
||||||
}, [photos])
|
}, [photos])
|
||||||
|
|
||||||
// Shift-toggle selects the whole range from the previously toggled photo
|
const toggle = (photoId) =>
|
||||||
// (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) => {
|
setSelected((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (next.has(photoId)) next.delete(photoId)
|
if (next.has(photoId)) next.delete(photoId)
|
||||||
else next.add(photoId)
|
else next.add(photoId)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
// Adds `list` (the caller's currently visible photos) to the selection —
|
const selectAll = () => setSelected(new Set(photos.map((p) => p.id)))
|
||||||
// additive, so selecting all of one filtered view keeps picks from another.
|
const clear = () => setSelected(new Set())
|
||||||
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 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)
|
const totalBytes = photos.reduce((sum, p) => sum + p.size_bytes, 0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
import { useLayoutEffect, useRef, useState } from 'react'
|
|
||||||
import { ACCEPT_GLYPH, REJECT_GLYPH } from './components/Thumbs'
|
|
||||||
|
|
||||||
const COMMIT_MS = 260
|
|
||||||
const CANCEL_MS = 180
|
|
||||||
const DEAD_ZONE = 12 // px of travel before the axis locks
|
|
||||||
const FLICK_MS = 250 // releases faster than this commit at the lower threshold
|
|
||||||
const H_THRESHOLD = { slow: 70, fast: 30 }
|
|
||||||
const V_THRESHOLD = { slow: 90, fast: 40 }
|
|
||||||
const V_SATURATE = 160 // px of vertical drag for full badge/preview intensity
|
|
||||||
const ZOOM_MAX = 4
|
|
||||||
|
|
||||||
// Touch engine for the lightbox: horizontal swipes navigate, vertical flicks
|
|
||||||
// dispatch gestures.up/down (when provided), two fingers pinch-zoom the
|
|
||||||
// current photo (springs back on release, never votes).
|
|
||||||
//
|
|
||||||
// Navigation and votes are dispatched SYNCHRONOUSLY on release; every
|
|
||||||
// animation afterwards is pure decoration (a ghost flying off, the track
|
|
||||||
// settling). Closing the lightbox, key presses or list changes during an
|
|
||||||
// animation therefore can't drop, duplicate or misdirect an action.
|
|
||||||
//
|
|
||||||
// Per-move motion is written straight to the DOM via refs — dragging costs
|
|
||||||
// no React renders; state changes happen only at axis lock and release.
|
|
||||||
export default function useSwipe({ photo, index, count, onNav, gestures, hasNext }) {
|
|
||||||
const [dragAxis, setDragAxis] = useState(null) // 'h' | 'v' | 'z' while a finger is down
|
|
||||||
const [spring, setSpring] = useState(null) // 'h' | 'v' | 'z': sub-threshold release gliding back
|
|
||||||
const [settleFrom, setSettleFrom] = useState(null) // px the track settles from after a nav commit
|
|
||||||
const [settleRun, setSettleRun] = useState(false)
|
|
||||||
const [exit, setExit] = useState(null) // { photo, dir, dy }: ghost flying off after a vote
|
|
||||||
|
|
||||||
const trackRef = useRef(null)
|
|
||||||
const imgRef = useRef(null)
|
|
||||||
const behindRef = useRef(null)
|
|
||||||
const badgeRef = useRef(null)
|
|
||||||
const g = useRef(null)
|
|
||||||
|
|
||||||
// FLIP: paint the track at the release offset around the NEW index first,
|
|
||||||
// force a style flush, then let it transition to center.
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (settleFrom == null) {
|
|
||||||
setSettleRun(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
trackRef.current?.getBoundingClientRect()
|
|
||||||
setSettleRun(true)
|
|
||||||
}, [settleFrom])
|
|
||||||
|
|
||||||
const clearManual = () => {
|
|
||||||
for (const ref of [trackRef, imgRef, behindRef, badgeRef]) {
|
|
||||||
if (ref.current) {
|
|
||||||
ref.current.style.transform = ''
|
|
||||||
ref.current.style.opacity = ''
|
|
||||||
ref.current.style.transition = ''
|
|
||||||
ref.current.style.transformOrigin = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onTouchStart = (e) => {
|
|
||||||
// Fresh input takes over: decorative animations yield immediately.
|
|
||||||
setSpring(null)
|
|
||||||
setSettleFrom(null)
|
|
||||||
if (e.touches.length === 2 && imgRef.current) {
|
|
||||||
// Second finger at any point turns the gesture into a pinch — a pinch
|
|
||||||
// never navigates and never votes.
|
|
||||||
clearManual()
|
|
||||||
const [a, b] = e.touches
|
|
||||||
const rect = imgRef.current.getBoundingClientRect()
|
|
||||||
const cx = (a.clientX + b.clientX) / 2
|
|
||||||
const cy = (a.clientY + b.clientY) / 2
|
|
||||||
imgRef.current.style.transformOrigin = `${cx - rect.left}px ${cy - rect.top}px`
|
|
||||||
g.current = {
|
|
||||||
mode: 'pinch',
|
|
||||||
d0: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY),
|
|
||||||
cx,
|
|
||||||
cy,
|
|
||||||
}
|
|
||||||
setDragAxis('z')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.touches.length !== 1) {
|
|
||||||
g.current = null
|
|
||||||
setDragAxis(null)
|
|
||||||
clearManual()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const t = e.touches[0]
|
|
||||||
g.current = { mode: 'swipe', x0: t.clientX, y0: t.clientY, t0: e.timeStamp, axis: null, dx: 0, dy: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
const onTouchMove = (e) => {
|
|
||||||
const s = g.current
|
|
||||||
if (!s) return
|
|
||||||
if (s.mode === 'pinch') {
|
|
||||||
if (e.touches.length < 2 || !imgRef.current) return
|
|
||||||
const [a, b] = e.touches
|
|
||||||
const scale = Math.min(
|
|
||||||
ZOOM_MAX,
|
|
||||||
Math.max(1, Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) / s.d0),
|
|
||||||
)
|
|
||||||
const mx = (a.clientX + b.clientX) / 2 - s.cx
|
|
||||||
const my = (a.clientY + b.clientY) / 2 - s.cy
|
|
||||||
imgRef.current.style.transition = 'none'
|
|
||||||
imgRef.current.style.transform = `translate(${mx}px, ${my}px) scale(${scale})`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.touches.length !== 1) return
|
|
||||||
const t = e.touches[0]
|
|
||||||
s.dx = t.clientX - s.x0
|
|
||||||
s.dy = t.clientY - s.y0
|
|
||||||
if (!s.axis) {
|
|
||||||
if (Math.hypot(s.dx, s.dy) < DEAD_ZONE) return
|
|
||||||
s.axis = Math.abs(s.dx) >= Math.abs(s.dy) || !gestures ? 'h' : 'v'
|
|
||||||
setDragAxis(s.axis)
|
|
||||||
}
|
|
||||||
if (s.axis === 'h') {
|
|
||||||
if (trackRef.current) {
|
|
||||||
trackRef.current.style.transition = 'none'
|
|
||||||
trackRef.current.style.transform = `translateX(${s.dx}px)`
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (imgRef.current) {
|
|
||||||
imgRef.current.style.transition = 'none'
|
|
||||||
imgRef.current.style.transform = `translateY(${s.dy}px) rotate(${s.dy / 40}deg)`
|
|
||||||
}
|
|
||||||
const p = Math.min(1, Math.abs(s.dy) / V_SATURATE)
|
|
||||||
if (behindRef.current) {
|
|
||||||
behindRef.current.style.transition = 'none'
|
|
||||||
behindRef.current.style.transform = `scale(${0.22 + 0.15 * p})`
|
|
||||||
behindRef.current.style.opacity = p
|
|
||||||
}
|
|
||||||
if (badgeRef.current) {
|
|
||||||
badgeRef.current.style.opacity = p
|
|
||||||
badgeRef.current.textContent = s.dy < 0 ? ACCEPT_GLYPH : REJECT_GLYPH
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onTouchEnd = (e) => {
|
|
||||||
const s = g.current
|
|
||||||
if (!s) return
|
|
||||||
if (s.mode === 'pinch') {
|
|
||||||
if (e.touches.length > 0) return
|
|
||||||
g.current = null
|
|
||||||
setDragAxis(null)
|
|
||||||
setSpring('z')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
g.current = null
|
|
||||||
setDragAxis(null)
|
|
||||||
if (!s.axis) {
|
|
||||||
clearManual()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const quick = e.timeStamp - s.t0 < FLICK_MS
|
|
||||||
const past = (v, th) => Math.abs(v) > th.slow || (quick && Math.abs(v) > th.fast)
|
|
||||||
if (s.axis === 'h') {
|
|
||||||
if (s.dx < 0 && past(s.dx, H_THRESHOLD) && index < count - 1) {
|
|
||||||
onNav(index + 1)
|
|
||||||
setSettleFrom(s.dx + window.innerWidth)
|
|
||||||
} else if (s.dx > 0 && past(s.dx, H_THRESHOLD) && index > 0) {
|
|
||||||
onNav(index - 1)
|
|
||||||
setSettleFrom(s.dx - window.innerWidth)
|
|
||||||
} else {
|
|
||||||
setSpring('h')
|
|
||||||
}
|
|
||||||
} else if (past(s.dy, V_THRESHOLD)) {
|
|
||||||
const dir = s.dy < 0 ? 'up' : 'down'
|
|
||||||
gestures?.[dir]?.(photo)
|
|
||||||
clearManual()
|
|
||||||
setExit({ photo, dir, dy: s.dy })
|
|
||||||
} else {
|
|
||||||
setSpring('v')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onTouchCancel = () => {
|
|
||||||
g.current = null
|
|
||||||
setDragAxis(null)
|
|
||||||
clearManual()
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackStyle =
|
|
||||||
settleFrom != null
|
|
||||||
? settleRun
|
|
||||||
? { transform: 'translateX(0)', transition: `transform ${COMMIT_MS}ms ease-out` }
|
|
||||||
: { transform: `translateX(${settleFrom}px)`, transition: 'none' }
|
|
||||||
: spring === 'h'
|
|
||||||
? { transform: 'translateX(0)', transition: `transform ${CANCEL_MS}ms ease-out` }
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const imgStyle =
|
|
||||||
spring === 'v'
|
|
||||||
? { transform: 'translateY(0) rotate(0deg)', transition: `transform ${CANCEL_MS}ms ease-out` }
|
|
||||||
: spring === 'z'
|
|
||||||
? { transform: 'none', transition: `transform ${CANCEL_MS}ms ease-out` }
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const behindStyle =
|
|
||||||
spring === 'v'
|
|
||||||
? { transform: 'scale(0.22)', opacity: 0, transition: `all ${CANCEL_MS}ms ease-out` }
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
return {
|
|
||||||
handlers: { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel },
|
|
||||||
trackRef,
|
|
||||||
imgRef,
|
|
||||||
behindRef,
|
|
||||||
badgeRef,
|
|
||||||
trackStyle,
|
|
||||||
imgStyle,
|
|
||||||
behindStyle,
|
|
||||||
showBehind: (dragAxis === 'v' || spring === 'v') && hasNext,
|
|
||||||
showBadge: dragAxis === 'v',
|
|
||||||
exit,
|
|
||||||
clearExit: () => setExit(null),
|
|
||||||
onTrackTransitionEnd: (e) => {
|
|
||||||
if (e.target !== trackRef.current) return
|
|
||||||
setSettleFrom(null)
|
|
||||||
if (spring === 'h') setSpring(null)
|
|
||||||
},
|
|
||||||
onImgTransitionEnd: (e) => {
|
|
||||||
if (e.target !== imgRef.current) return
|
|
||||||
if (spring === 'v' || spring === 'z') {
|
|
||||||
setSpring(null)
|
|
||||||
clearManual()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Albums gain an owner: the tenancy root. Photos, shares, ratings and tags
|
||||||
|
-- all hang off albums, so this single column scopes everything.
|
||||||
|
alter table albums add column owner_id uuid references users(id);
|
||||||
|
|
||||||
|
-- Backfill: pre-tenancy albums had no owner. Assigning them to "the" original
|
||||||
|
-- photographer is only unambiguous when exactly one user exists. With several
|
||||||
|
-- (v0.1.0 let every allowed email share all albums) the correct owner is
|
||||||
|
-- unknowable, so refuse rather than silently transfer everyone's work to one
|
||||||
|
-- account — the operator must assign ownership manually before migrating.
|
||||||
|
do $$
|
||||||
|
declare
|
||||||
|
n_users int;
|
||||||
|
n_albums int;
|
||||||
|
begin
|
||||||
|
select count(*) into n_users from users;
|
||||||
|
select count(*) into n_albums from albums;
|
||||||
|
if n_albums > 0 and n_users <> 1 then
|
||||||
|
raise exception
|
||||||
|
'multi-tenant migration: % albums exist but there are % users (need exactly 1 to auto-assign ownership); set albums.owner_id manually first',
|
||||||
|
n_albums, n_users;
|
||||||
|
end if;
|
||||||
|
update albums set owner_id = (select id from users order by created_at limit 1);
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
alter table albums alter column owner_id set not null;
|
||||||
|
|
||||||
|
create index albums_owner_idx on albums (owner_id);
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
-- The photographer's own verdict/rating. One opinion per photo, so these
|
|
||||||
-- live on the photo itself rather than in the per-link feedback tables.
|
|
||||||
alter table photos add column owner_rating int check (owner_rating between 1 and 5);
|
|
||||||
alter table photos add column owner_verdict text check (owner_verdict in ('accept', 'reject'));
|
|
||||||
+7
-1
@@ -114,11 +114,17 @@ pub(crate) fn base_cookie(name: &'static str, value: String, secure: bool) -> Co
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The signed session-cookie payload, in one place so tests and the
|
||||||
|
/// mint_session dev tool can't drift from what user_from_jar parses.
|
||||||
|
pub fn session_payload(user_id: Uuid, email: &str, exp: i64) -> String {
|
||||||
|
format!("{user_id}|{exp}|{email}")
|
||||||
|
}
|
||||||
|
|
||||||
fn session_cookie(state: &AppState, user_id: Uuid, email: &str) -> Cookie<'static> {
|
fn session_cookie(state: &AppState, user_id: Uuid, email: &str) -> Cookie<'static> {
|
||||||
let exp = Utc::now().timestamp() + SESSION_DAYS * 86400;
|
let exp = Utc::now().timestamp() + SESSION_DAYS * 86400;
|
||||||
let mut cookie = base_cookie(
|
let mut cookie = base_cookie(
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
format!("{user_id}|{exp}|{email}"),
|
session_payload(user_id, email, exp),
|
||||||
state.config.cookie_secure(),
|
state.config.cookie_secure(),
|
||||||
);
|
);
|
||||||
cookie.set_max_age(time::Duration::days(SESSION_DAYS));
|
cookie.set_max_age(time::Duration::days(SESSION_DAYS));
|
||||||
|
|||||||
+3
-26
@@ -1,8 +1,4 @@
|
|||||||
use axum::http::{header, HeaderValue};
|
|
||||||
use tower::ServiceBuilder;
|
|
||||||
use tower_http::services::fs::ServeFileSystemResponseBody;
|
|
||||||
use tower_http::services::{ServeDir, ServeFile};
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
use tower_http::set_header::SetResponseHeaderLayer;
|
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
@@ -21,30 +17,11 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let state = AppState::new(config).await?;
|
let state = AppState::new(config).await?;
|
||||||
|
|
||||||
let static_dir = state.config.static_dir.clone();
|
let static_dir = state.config.static_dir.clone();
|
||||||
let static_root = std::path::Path::new(&static_dir);
|
let index = std::path::Path::new(&static_dir).join("index.html");
|
||||||
// Hashed assets cache forever — except 404s, which would outlive the next deploy.
|
// .fallback (not .not_found_service) so SPA routes get index.html with a 200
|
||||||
let assets = ServiceBuilder::new()
|
let spa = ServeDir::new(&static_dir).fallback(ServeFile::new(index));
|
||||||
.map_response(|mut response: axum::http::Response<ServeFileSystemResponseBody>| {
|
|
||||||
if response.status().is_success() {
|
|
||||||
response.headers_mut().insert(
|
|
||||||
header::CACHE_CONTROL,
|
|
||||||
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
response
|
|
||||||
})
|
|
||||||
.service(ServeDir::new(static_root.join("assets")));
|
|
||||||
// index.html revalidates every load (it names the asset hashes);
|
|
||||||
// .fallback (not .not_found_service) so SPA routes get it with a 200.
|
|
||||||
let spa = ServiceBuilder::new()
|
|
||||||
.layer(SetResponseHeaderLayer::overriding(
|
|
||||||
header::CACHE_CONTROL,
|
|
||||||
HeaderValue::from_static("no-cache"),
|
|
||||||
))
|
|
||||||
.service(ServeDir::new(static_root).fallback(ServeFile::new(static_root.join("index.html"))));
|
|
||||||
|
|
||||||
let app = photos::routes::router(&state)
|
let app = photos::routes::router(&state)
|
||||||
.nest_service("/assets", assets)
|
|
||||||
.fallback_service(spa)
|
.fallback_service(spa)
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state.clone());
|
.with_state(state.clone());
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod error;
|
|||||||
pub mod imaging;
|
pub mod imaging;
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
pub mod owned;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod s3;
|
pub mod s3;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|||||||
+3
-78
@@ -1,5 +1,5 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Serialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Stored as text in Postgres; decoded via TryFrom so an unknown value is a
|
/// Stored as text in Postgres; decoded via TryFrom so an unknown value is a
|
||||||
@@ -47,81 +47,6 @@ 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",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The one place feedback-aggregation policy lives (UI filters and XMP
|
|
||||||
/// export must agree): the rating is the MAX across everyone, any accept
|
|
||||||
/// wins over rejects, and the owner's feedback counts like a client's.
|
|
||||||
#[derive(Debug, Clone, Copy, Default, Serialize)]
|
|
||||||
pub struct FeedbackAggregate {
|
|
||||||
pub top_rating: Option<i32>,
|
|
||||||
pub accepts: i32,
|
|
||||||
pub rejects: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FeedbackAggregate {
|
|
||||||
pub fn add_rating(&mut self, rating: i32) {
|
|
||||||
self.top_rating = Some(self.top_rating.map_or(rating, |top| top.max(rating)));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_verdict(&mut self, verdict: &str) {
|
|
||||||
if verdict == "accept" {
|
|
||||||
self.accepts += 1;
|
|
||||||
} else {
|
|
||||||
self.rejects += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_owner(&mut self, rating: Option<i32>, verdict: Option<&str>) {
|
|
||||||
if let Some(rating) = rating {
|
|
||||||
self.add_rating(rating);
|
|
||||||
}
|
|
||||||
if let Some(verdict) = verdict {
|
|
||||||
self.add_verdict(verdict);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.top_rating.is_none() && self.accepts == 0 && self.rejects == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn merge(&mut self, other: &FeedbackAggregate) {
|
|
||||||
if let Some(rating) = other.top_rating {
|
|
||||||
self.add_rating(rating);
|
|
||||||
}
|
|
||||||
self.accepts += other.accepts;
|
|
||||||
self.rejects += other.rejects;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// XMP color label: any accept wins, otherwise any reject.
|
|
||||||
pub fn label(&self) -> Option<&'static str> {
|
|
||||||
if self.accepts > 0 {
|
|
||||||
Some("Green")
|
|
||||||
} else if self.rejects > 0 {
|
|
||||||
Some("Red")
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum JobKind {
|
pub enum JobKind {
|
||||||
ProcessPhoto,
|
ProcessPhoto,
|
||||||
@@ -171,6 +96,8 @@ impl JobStatus {
|
|||||||
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
|
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
|
||||||
pub struct Album {
|
pub struct Album {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub owner_id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
@@ -190,8 +117,6 @@ pub struct Photo {
|
|||||||
pub height: Option<i32>,
|
pub height: Option<i32>,
|
||||||
pub taken_at: Option<DateTime<Utc>>,
|
pub taken_at: Option<DateTime<Utc>>,
|
||||||
pub processed_at: Option<DateTime<Utc>>,
|
pub processed_at: Option<DateTime<Utc>>,
|
||||||
pub owner_rating: Option<i32>,
|
|
||||||
pub owner_verdict: Option<String>,
|
|
||||||
#[serde(skip_serializing)]
|
#[serde(skip_serializing)]
|
||||||
pub sha256: Option<String>,
|
pub sha256: Option<String>,
|
||||||
#[serde(skip_serializing)]
|
#[serde(skip_serializing)]
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
//! Tenant-scoped data access. These are the ONLY functions admin handlers may
|
||||||
|
//! use to fetch albums, photos, or shares — every one joins ownership, so a
|
||||||
|
//! handler cannot accidentally reach across tenants. Another user's resource
|
||||||
|
//! is indistinguishable from a nonexistent one (404).
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::models::{Album, Photo, Share};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub async fn album(state: &AppState, album_id: Uuid, owner: Uuid) -> Result<Album, ApiError> {
|
||||||
|
let album: Option<Album> =
|
||||||
|
sqlx::query_as("select * from albums where id = $1 and owner_id = $2")
|
||||||
|
.bind(album_id)
|
||||||
|
.bind(owner)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
album.ok_or_else(ApiError::not_found)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn photo(state: &AppState, photo_id: Uuid, owner: Uuid) -> Result<Photo, ApiError> {
|
||||||
|
let photo: Option<Photo> = sqlx::query_as(
|
||||||
|
"select p.* from photos p
|
||||||
|
join albums a on a.id = p.album_id
|
||||||
|
where p.id = $1 and a.owner_id = $2",
|
||||||
|
)
|
||||||
|
.bind(photo_id)
|
||||||
|
.bind(owner)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
photo.ok_or_else(ApiError::not_found)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn share(state: &AppState, share_id: Uuid, owner: Uuid) -> Result<Share, ApiError> {
|
||||||
|
let share: Option<Share> = sqlx::query_as(
|
||||||
|
"select s.* from shares s
|
||||||
|
join albums a on a.id = s.album_id
|
||||||
|
where s.id = $1 and a.owner_id = $2",
|
||||||
|
)
|
||||||
|
.bind(share_id)
|
||||||
|
.bind(owner)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
share.ok_or_else(ApiError::not_found)
|
||||||
|
}
|
||||||
+72
-143
@@ -6,8 +6,10 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth::AuthUser;
|
||||||
use crate::error::{ApiError, ApiResult};
|
use crate::error::{ApiError, ApiResult};
|
||||||
use crate::models::{Album, FeedbackAggregate, Photo, PhotoStatus};
|
use crate::models::{Album, JobKind, Photo, PhotoStatus};
|
||||||
|
use crate::owned;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
#[derive(Serialize, sqlx::FromRow)]
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
@@ -23,6 +25,7 @@ pub struct AlbumListItem {
|
|||||||
|
|
||||||
pub async fn list(
|
pub async fn list(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
) -> ApiResult<Json<Vec<AlbumListItem>>> {
|
) -> ApiResult<Json<Vec<AlbumListItem>>> {
|
||||||
let albums: Vec<AlbumListItem> = sqlx::query_as(
|
let albums: Vec<AlbumListItem> = sqlx::query_as(
|
||||||
"select a.id, a.name, a.description, a.created_at,
|
"select a.id, a.name, a.description, a.created_at,
|
||||||
@@ -35,9 +38,11 @@ pub async fn list(
|
|||||||
order by coalesce(p.taken_at, p.created_at), p.filename
|
order by coalesce(p.taken_at, p.created_at), p.filename
|
||||||
limit 1
|
limit 1
|
||||||
) c on true
|
) c on true
|
||||||
|
where a.owner_id = $2
|
||||||
order by a.created_at desc",
|
order by a.created_at desc",
|
||||||
)
|
)
|
||||||
.bind(PhotoStatus::Ready.as_str())
|
.bind(PhotoStatus::Ready.as_str())
|
||||||
|
.bind(user.id)
|
||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(albums))
|
Ok(Json(albums))
|
||||||
@@ -52,104 +57,55 @@ pub struct CreateAlbum {
|
|||||||
|
|
||||||
pub async fn create(
|
pub async fn create(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
Json(body): Json<CreateAlbum>,
|
Json(body): Json<CreateAlbum>,
|
||||||
) -> ApiResult<Json<Album>> {
|
) -> ApiResult<Json<Album>> {
|
||||||
let name = body.name.trim();
|
let name = body.name.trim();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
return Err(ApiError::bad_request("album name is required"));
|
return Err(ApiError::bad_request("album name is required"));
|
||||||
}
|
}
|
||||||
let album: Album =
|
let album: Album = sqlx::query_as(
|
||||||
sqlx::query_as("insert into albums (name, description) values ($1, $2) returning *")
|
"insert into albums (owner_id, name, description) values ($1, $2, $3) returning *",
|
||||||
.bind(name)
|
)
|
||||||
.bind(body.description.trim())
|
.bind(user.id)
|
||||||
.fetch_one(&state.db)
|
.bind(name)
|
||||||
.await?;
|
.bind(body.description.trim())
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
Ok(Json(album))
|
Ok(Json(album))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct ShareRating {
|
pub struct ShareRating {
|
||||||
pub share_id: Uuid,
|
|
||||||
pub share_label: String,
|
pub share_label: String,
|
||||||
pub rating: i32,
|
pub rating: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct ShareTag {
|
pub struct ShareTag {
|
||||||
pub share_id: Uuid,
|
|
||||||
pub share_label: String,
|
pub share_label: String,
|
||||||
pub tag: String,
|
pub tag: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct ShareVerdict {
|
|
||||||
pub share_id: Uuid,
|
|
||||||
pub share_label: String,
|
|
||||||
pub verdict: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Default)]
|
#[derive(Serialize, Default)]
|
||||||
pub struct PhotoFeedback {
|
pub struct PhotoFeedback {
|
||||||
pub ratings: Vec<ShareRating>,
|
pub ratings: Vec<ShareRating>,
|
||||||
pub verdicts: Vec<ShareVerdict>,
|
|
||||||
pub tags: Vec<ShareTag>,
|
pub tags: Vec<ShareTag>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) const RATING_ROWS: &str = "select r.photo_id, s.id, s.label, r.rating
|
|
||||||
from ratings r join shares s on s.id = r.share_id
|
|
||||||
where s.album_id = $1 and ($2::uuid[] is null or r.share_id = any($2))";
|
|
||||||
pub(super) const VERDICT_ROWS: &str = "select v.photo_id, s.id, s.label, v.verdict
|
|
||||||
from verdicts v join shares s on s.id = v.share_id
|
|
||||||
where s.album_id = $1 and ($2::uuid[] is null or v.share_id = any($2))";
|
|
||||||
pub(super) const TAG_ROWS: &str = "select t.photo_id, s.id, s.label, t.tag
|
|
||||||
from tags t join shares s on s.id = t.share_id
|
|
||||||
where s.album_id = $1 and ($2::uuid[] is null or t.share_id = any($2))
|
|
||||||
order by t.created_at";
|
|
||||||
|
|
||||||
/// One feedback query: (photo_id, share id, share label, value) rows for an
|
|
||||||
/// album, optionally narrowed to a single share ($2).
|
|
||||||
pub(super) async fn feedback_rows<T>(
|
|
||||||
db: &sqlx::PgPool,
|
|
||||||
sql: &str,
|
|
||||||
album_id: Uuid,
|
|
||||||
shares: Option<&[Uuid]>,
|
|
||||||
) -> Result<Vec<(Uuid, Uuid, String, T)>, sqlx::Error>
|
|
||||||
where
|
|
||||||
(Uuid, Uuid, String, T): for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow> + Send + Unpin,
|
|
||||||
{
|
|
||||||
sqlx::query_as(sql)
|
|
||||||
.bind(album_id)
|
|
||||||
.bind(shares)
|
|
||||||
.fetch_all(db)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fold_feedback<T>(
|
|
||||||
feedback: &mut HashMap<Uuid, PhotoFeedback>,
|
|
||||||
rows: Vec<(Uuid, Uuid, String, T)>,
|
|
||||||
push: impl Fn(&mut PhotoFeedback, Uuid, String, T),
|
|
||||||
) {
|
|
||||||
for (photo_id, share_id, share_label, value) in rows {
|
|
||||||
push(feedback.entry(photo_id).or_default(), share_id, share_label, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct AlbumDetail {
|
pub struct AlbumDetail {
|
||||||
pub album: Album,
|
pub album: Album,
|
||||||
pub photos: Vec<Photo>,
|
pub photos: Vec<Photo>,
|
||||||
pub feedback: HashMap<Uuid, PhotoFeedback>,
|
pub feedback: HashMap<Uuid, PhotoFeedback>,
|
||||||
pub aggregates: HashMap<Uuid, FeedbackAggregate>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_one(
|
pub async fn get_one(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
Path(album_id): Path<Uuid>,
|
Path(album_id): Path<Uuid>,
|
||||||
) -> ApiResult<Json<AlbumDetail>> {
|
) -> ApiResult<Json<AlbumDetail>> {
|
||||||
let album: Album = sqlx::query_as("select * from albums where id = $1")
|
let album = owned::album(&state, album_id, user.id).await?;
|
||||||
.bind(album_id)
|
|
||||||
.fetch_one(&state.db)
|
|
||||||
.await?;
|
|
||||||
let photos: Vec<Photo> = sqlx::query_as(
|
let photos: Vec<Photo> = sqlx::query_as(
|
||||||
"select * from photos where album_id = $1
|
"select * from photos where album_id = $1
|
||||||
order by coalesce(taken_at, created_at), filename",
|
order by coalesce(taken_at, created_at), filename",
|
||||||
@@ -158,85 +114,49 @@ pub async fn get_one(
|
|||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// The three feedback kinds are independent (photo_id, share, value)
|
|
||||||
// queries — run them concurrently and fold with one shared shape.
|
|
||||||
let (ratings, verdicts, tags) = tokio::try_join!(
|
|
||||||
feedback_rows::<i32>(&state.db, RATING_ROWS, album_id, None),
|
|
||||||
feedback_rows::<String>(&state.db, VERDICT_ROWS, album_id, None),
|
|
||||||
feedback_rows::<String>(&state.db, TAG_ROWS, album_id, None),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let mut aggregates: HashMap<Uuid, FeedbackAggregate> = HashMap::new();
|
|
||||||
for (photo_id, _, _, rating) in &ratings {
|
|
||||||
aggregates.entry(*photo_id).or_default().add_rating(*rating);
|
|
||||||
}
|
|
||||||
for (photo_id, _, _, verdict) in &verdicts {
|
|
||||||
aggregates.entry(*photo_id).or_default().add_verdict(verdict);
|
|
||||||
}
|
|
||||||
for photo in &photos {
|
|
||||||
if photo.owner_rating.is_some() || photo.owner_verdict.is_some() {
|
|
||||||
aggregates
|
|
||||||
.entry(photo.id)
|
|
||||||
.or_default()
|
|
||||||
.add_owner(photo.owner_rating, photo.owner_verdict.as_deref());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut feedback: HashMap<Uuid, PhotoFeedback> = HashMap::new();
|
let mut feedback: HashMap<Uuid, PhotoFeedback> = HashMap::new();
|
||||||
fold_feedback(&mut feedback, ratings, |f, share_id, share_label, rating| {
|
let ratings: Vec<(Uuid, String, i32)> = sqlx::query_as(
|
||||||
f.ratings.push(ShareRating {
|
"select r.photo_id, s.label, r.rating
|
||||||
share_id,
|
from ratings r join shares s on s.id = r.share_id
|
||||||
share_label,
|
where s.album_id = $1",
|
||||||
rating,
|
)
|
||||||
})
|
.bind(album_id)
|
||||||
});
|
.fetch_all(&state.db)
|
||||||
fold_feedback(&mut feedback, verdicts, |f, share_id, share_label, verdict| {
|
.await?;
|
||||||
f.verdicts.push(ShareVerdict {
|
for (photo_id, share_label, rating) in ratings {
|
||||||
share_id,
|
feedback
|
||||||
share_label,
|
.entry(photo_id)
|
||||||
verdict,
|
.or_default()
|
||||||
})
|
.ratings
|
||||||
});
|
.push(ShareRating {
|
||||||
fold_feedback(&mut feedback, tags, |f, share_id, share_label, tag| {
|
share_label,
|
||||||
f.tags.push(ShareTag {
|
rating,
|
||||||
share_id,
|
});
|
||||||
share_label,
|
}
|
||||||
tag,
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(AlbumDetail {
|
Ok(Json(AlbumDetail {
|
||||||
album,
|
album,
|
||||||
photos,
|
photos,
|
||||||
feedback,
|
feedback,
|
||||||
aggregates,
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, sqlx::FromRow)]
|
|
||||||
pub struct PendingPhoto {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub filename: String,
|
|
||||||
pub status: String,
|
|
||||||
pub error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn pending(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(album_id): Path<Uuid>,
|
|
||||||
) -> ApiResult<Json<serde_json::Value>> {
|
|
||||||
let pending: Vec<PendingPhoto> = sqlx::query_as(
|
|
||||||
"select id, filename, status, error from photos
|
|
||||||
where album_id = $1 and status != $2
|
|
||||||
order by created_at",
|
|
||||||
)
|
|
||||||
.bind(album_id)
|
|
||||||
.bind(PhotoStatus::Ready.as_str())
|
|
||||||
.fetch_all(&state.db)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(serde_json::json!({ "pending": pending })))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct UpdateAlbum {
|
pub struct UpdateAlbum {
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
@@ -245,6 +165,7 @@ pub struct UpdateAlbum {
|
|||||||
|
|
||||||
pub async fn update(
|
pub async fn update(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
Path(album_id): Path<Uuid>,
|
Path(album_id): Path<Uuid>,
|
||||||
Json(body): Json<UpdateAlbum>,
|
Json(body): Json<UpdateAlbum>,
|
||||||
) -> ApiResult<Json<Album>> {
|
) -> ApiResult<Json<Album>> {
|
||||||
@@ -256,12 +177,13 @@ pub async fn update(
|
|||||||
let album: Album = sqlx::query_as(
|
let album: Album = sqlx::query_as(
|
||||||
"update albums
|
"update albums
|
||||||
set name = coalesce($2, name), description = coalesce($3, description)
|
set name = coalesce($2, name), description = coalesce($3, description)
|
||||||
where id = $1
|
where id = $1 and owner_id = $4
|
||||||
returning *",
|
returning *",
|
||||||
)
|
)
|
||||||
.bind(album_id)
|
.bind(album_id)
|
||||||
.bind(body.name.as_deref().map(str::trim))
|
.bind(body.name.as_deref().map(str::trim))
|
||||||
.bind(body.description.as_deref().map(str::trim))
|
.bind(body.description.as_deref().map(str::trim))
|
||||||
|
.bind(user.id)
|
||||||
.fetch_one(&state.db)
|
.fetch_one(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(album))
|
Ok(Json(album))
|
||||||
@@ -269,26 +191,33 @@ pub async fn update(
|
|||||||
|
|
||||||
pub async fn delete(
|
pub async fn delete(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
Path(album_id): Path<Uuid>,
|
Path(album_id): Path<Uuid>,
|
||||||
) -> ApiResult<Json<serde_json::Value>> {
|
) -> ApiResult<Json<serde_json::Value>> {
|
||||||
let mut tx = state.db.begin().await?;
|
let mut tx = state.db.begin().await?;
|
||||||
// Lock the album row: concurrent uploads block on their FK check against
|
// Lock the album row: concurrent uploads block on their FK check against
|
||||||
// it, then fail once it's gone and clean up their own S3 objects — so no
|
// it, then fail once it's gone and clean up their own S3 objects — so no
|
||||||
// photo can slip in between the cleanup enqueue and the cascade delete.
|
// photo can slip in between the cleanup enqueue and the cascade delete.
|
||||||
let locked: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1 for update")
|
let locked: Option<(Uuid,)> =
|
||||||
.bind(album_id)
|
sqlx::query_as("select id from albums where id = $1 and owner_id = $2 for update")
|
||||||
.fetch_optional(&mut *tx)
|
.bind(album_id)
|
||||||
.await?;
|
.bind(user.id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
if locked.is_none() {
|
if locked.is_none() {
|
||||||
return Err(ApiError::not_found());
|
return Err(ApiError::not_found());
|
||||||
}
|
}
|
||||||
// Delete photos through the shared path so the S3 cleanup convention
|
// Delete photos and enqueue their S3 cleanup atomically, in one statement.
|
||||||
// lives in one place; the album lock above keeps this set complete.
|
sqlx::query(
|
||||||
let photo_ids: Vec<Uuid> = sqlx::query_scalar("select id from photos where album_id = $1")
|
"with deleted as (delete from photos where album_id = $1 returning id)
|
||||||
.bind(album_id)
|
insert into jobs (kind, payload)
|
||||||
.fetch_all(&mut *tx)
|
select $2, jsonb_build_object('prefix', 'photos/' || id || '/')
|
||||||
.await?;
|
from deleted",
|
||||||
super::photos::delete_with_cleanup(&mut tx, &photo_ids).await?;
|
)
|
||||||
|
.bind(album_id)
|
||||||
|
.bind(JobKind::DeleteS3Prefix.as_str())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
sqlx::query("delete from albums where id = $1")
|
sqlx::query("delete from albums where id = $1")
|
||||||
.bind(album_id)
|
.bind(album_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
|
|||||||
+2
-40
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::error::{ApiError, ApiResult};
|
use crate::error::{ApiError, ApiResult};
|
||||||
use crate::models::{PhotoStatus, Share, Verdict};
|
use crate::models::{PhotoStatus, Share};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub async fn load_share(state: &AppState, token: &str) -> Result<Share, ApiError> {
|
pub async fn load_share(state: &AppState, token: &str) -> Result<Share, ApiError> {
|
||||||
@@ -137,7 +137,6 @@ struct ClientPhotoRow {
|
|||||||
taken_at: Option<DateTime<Utc>>,
|
taken_at: Option<DateTime<Utc>>,
|
||||||
processed_at: Option<DateTime<Utc>>,
|
processed_at: Option<DateTime<Utc>>,
|
||||||
my_rating: Option<i32>,
|
my_rating: Option<i32>,
|
||||||
my_verdict: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -186,10 +185,9 @@ pub async fn get_share(
|
|||||||
let jar = grant_access(&state, jar, share.id);
|
let jar = grant_access(&state, jar, share.id);
|
||||||
|
|
||||||
let rows: Vec<ClientPhotoRow> = sqlx::query_as(
|
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, v.verdict as my_verdict
|
"select p.id, p.filename, p.size_bytes, p.width, p.height, p.taken_at, p.processed_at, r.rating as my_rating
|
||||||
from photos p
|
from photos p
|
||||||
left join ratings r on r.photo_id = p.id and r.share_id = $2
|
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
|
where p.album_id = $1 and p.status = $3
|
||||||
order by coalesce(p.taken_at, p.created_at), p.filename",
|
order by coalesce(p.taken_at, p.created_at), p.filename",
|
||||||
)
|
)
|
||||||
@@ -354,42 +352,6 @@ pub async fn set_rating(
|
|||||||
Ok(Json(serde_json::json!({ "ok": true })))
|
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)]
|
#[derive(Deserialize)]
|
||||||
pub struct TagsBody {
|
pub struct TagsBody {
|
||||||
tags: Vec<String>,
|
tags: Vec<String>,
|
||||||
|
|||||||
+13
-2
@@ -28,8 +28,19 @@ async fn authorize_photo(
|
|||||||
.await?;
|
.await?;
|
||||||
let photo = photo.ok_or_else(ApiError::not_found)?;
|
let photo = photo.ok_or_else(ApiError::not_found)?;
|
||||||
|
|
||||||
if user_from_jar(state, jar).is_some() {
|
// Photographers may only reach their OWN photos (scoped by album owner);
|
||||||
return Ok(photo);
|
// otherwise fall through to share-cookie access.
|
||||||
|
if let Some(user) = user_from_jar(state, jar) {
|
||||||
|
let owns: Option<(Uuid,)> = sqlx::query_as(
|
||||||
|
"select a.id from albums a where a.id = $1 and a.owner_id = $2",
|
||||||
|
)
|
||||||
|
.bind(photo.album_id)
|
||||||
|
.bind(user.id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
if owns.is_some() {
|
||||||
|
return Ok(photo);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
authorize_album_via_cookie(state, jar, photo.album_id, need_download).await?;
|
authorize_album_via_cookie(state, jar, photo.album_id, need_download).await?;
|
||||||
// Clients may only reach photos the share listing exposes.
|
// Clients may only reach photos the share listing exposes.
|
||||||
|
|||||||
+1
-14
@@ -3,7 +3,6 @@ pub mod client;
|
|||||||
pub mod images;
|
pub mod images;
|
||||||
pub mod photos;
|
pub mod photos;
|
||||||
pub mod shares;
|
pub mod shares;
|
||||||
pub mod xmp;
|
|
||||||
pub mod zip;
|
pub mod zip;
|
||||||
|
|
||||||
use axum::extract::{DefaultBodyLimit, Request, State};
|
use axum::extract::{DefaultBodyLimit, Request, State};
|
||||||
@@ -78,18 +77,10 @@ pub fn router(state: &AppState) -> Router<AppState> {
|
|||||||
get(shares::list).post(shares::create),
|
get(shares::list).post(shares::create),
|
||||||
)
|
)
|
||||||
.route("/api/albums/{id}/zip", post(zip::album_zip))
|
.route("/api/albums/{id}/zip", post(zip::album_zip))
|
||||||
.route("/api/albums/{id}/xmp", get(xmp::album_xmp))
|
|
||||||
.route("/api/albums/{id}/pending", get(albums::pending))
|
|
||||||
.route("/api/albums/{id}/photos/by-hash/{sha256}", get(photos::by_hash))
|
.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}", delete(photos::delete))
|
||||||
.route("/api/photos/{id}/rating", put(photos::set_owner_rating))
|
|
||||||
.route("/api/photos/{id}/verdict", put(photos::set_owner_verdict))
|
|
||||||
.route("/api/photos/{id}/reprocess", post(photos::reprocess))
|
.route("/api/photos/{id}/reprocess", post(photos::reprocess))
|
||||||
.route(
|
.route("/api/shares/{id}", delete(shares::delete))
|
||||||
"/api/shares/{id}",
|
|
||||||
delete(shares::delete).patch(shares::update),
|
|
||||||
)
|
|
||||||
.route("/api/shares/{id}/reset-lock", post(shares::reset_lock))
|
.route("/api/shares/{id}/reset-lock", post(shares::reset_lock))
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
@@ -109,10 +100,6 @@ pub fn router(state: &AppState) -> Router<AppState> {
|
|||||||
"/api/share/{token}/photos/{photo_id}/rating",
|
"/api/share/{token}/photos/{photo_id}/rating",
|
||||||
put(client::set_rating),
|
put(client::set_rating),
|
||||||
)
|
)
|
||||||
.route(
|
|
||||||
"/api/share/{token}/photos/{photo_id}/verdict",
|
|
||||||
put(client::set_verdict),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/api/share/{token}/photos/{photo_id}/tags",
|
"/api/share/{token}/photos/{photo_id}/tags",
|
||||||
put(client::set_tags),
|
put(client::set_tags),
|
||||||
|
|||||||
+43
-142
@@ -9,9 +9,11 @@ use sha2::Digest;
|
|||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth::AuthUser;
|
||||||
use crate::error::{ApiError, ApiResult};
|
use crate::error::{ApiError, ApiResult};
|
||||||
use crate::jobs;
|
use crate::jobs;
|
||||||
use crate::models::{FeedbackAggregate, JobKind, Photo, PhotoStatus, Verdict};
|
use crate::models::{JobKind, Photo, PhotoStatus};
|
||||||
|
use crate::owned;
|
||||||
use crate::s3;
|
use crate::s3;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -39,20 +41,34 @@ fn sanitize_filename(raw: &str) -> Result<String, ApiError> {
|
|||||||
Ok(cleaned.chars().take(150).collect())
|
Ok(cleaned.chars().take(150).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A dedup hit on a photo that previously failed processing means the user is
|
||||||
|
/// re-uploading to fix it — reset it and re-enqueue instead of handing back a
|
||||||
|
/// broken row that the UI would report as "already uploaded".
|
||||||
|
async fn heal_if_errored(state: &AppState, photo: Photo) -> ApiResult<Photo> {
|
||||||
|
if photo.status != PhotoStatus::Error {
|
||||||
|
return Ok(photo);
|
||||||
|
}
|
||||||
|
let mut tx = state.db.begin().await?;
|
||||||
|
let healed: Photo =
|
||||||
|
sqlx::query_as("update photos set status = $2, error = null where id = $1 returning *")
|
||||||
|
.bind(photo.id)
|
||||||
|
.bind(PhotoStatus::Uploaded.as_str())
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
jobs::ensure_process_photo(&mut tx, photo.id).await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(healed)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn upload(
|
pub async fn upload(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
Path(album_id): Path<Uuid>,
|
Path(album_id): Path<Uuid>,
|
||||||
Query(query): Query<UploadQuery>,
|
Query(query): Query<UploadQuery>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
body: Body,
|
body: Body,
|
||||||
) -> ApiResult<Json<Photo>> {
|
) -> ApiResult<Json<Photo>> {
|
||||||
let album_exists: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1")
|
owned::album(&state, album_id, user.id).await?;
|
||||||
.bind(album_id)
|
|
||||||
.fetch_optional(&state.db)
|
|
||||||
.await?;
|
|
||||||
if album_exists.is_none() {
|
|
||||||
return Err(ApiError::not_found());
|
|
||||||
}
|
|
||||||
|
|
||||||
let filename = sanitize_filename(&query.filename)?;
|
let filename = sanitize_filename(&query.filename)?;
|
||||||
let content_type = headers
|
let content_type = headers
|
||||||
@@ -97,7 +113,7 @@ pub async fn upload(
|
|||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
if let Some(existing) = existing {
|
if let Some(existing) = existing {
|
||||||
return Ok(Json(existing));
|
return Ok(Json(heal_if_errored(&state, existing).await?));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload to S3 first, then create the row and enqueue processing in one
|
// Upload to S3 first, then create the row and enqueue processing in one
|
||||||
@@ -156,7 +172,7 @@ pub async fn upload(
|
|||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
if let Some(winner) = winner {
|
if let Some(winner) = winner {
|
||||||
return Ok(Json(winner));
|
return Ok(Json(heal_if_errored(&state, winner).await?));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
@@ -178,8 +194,10 @@ pub async fn upload(
|
|||||||
/// content already exists in the album.
|
/// content already exists in the album.
|
||||||
pub async fn by_hash(
|
pub async fn by_hash(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
Path((album_id, sha256)): Path<(Uuid, String)>,
|
Path((album_id, sha256)): Path<(Uuid, String)>,
|
||||||
) -> ApiResult<Json<Photo>> {
|
) -> ApiResult<Json<Photo>> {
|
||||||
|
owned::album(&state, album_id, user.id).await?;
|
||||||
let photo: Option<Photo> =
|
let photo: Option<Photo> =
|
||||||
sqlx::query_as("select * from photos where album_id = $1 and sha256 = $2")
|
sqlx::query_as("select * from photos where album_id = $1 and sha256 = $2")
|
||||||
.bind(album_id)
|
.bind(album_id)
|
||||||
@@ -189,153 +207,36 @@ pub async fn by_hash(
|
|||||||
photo.map(Json).ok_or_else(ApiError::not_found)
|
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(
|
pub async fn delete(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
Path(photo_id): Path<Uuid>,
|
Path(photo_id): Path<Uuid>,
|
||||||
) -> ApiResult<Json<serde_json::Value>> {
|
) -> ApiResult<Json<serde_json::Value>> {
|
||||||
|
owned::photo(&state, photo_id, user.id).await?;
|
||||||
let mut tx = state.db.begin().await?;
|
let mut tx = state.db.begin().await?;
|
||||||
if delete_with_cleanup(&mut tx, &[photo_id]).await? == 0 {
|
let deleted = sqlx::query("delete from photos where id = $1")
|
||||||
|
.bind(photo_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if deleted.rows_affected() == 0 {
|
||||||
return Err(ApiError::not_found());
|
return Err(ApiError::not_found());
|
||||||
}
|
}
|
||||||
|
jobs::enqueue(
|
||||||
|
&mut *tx,
|
||||||
|
JobKind::DeleteS3Prefix,
|
||||||
|
serde_json::json!({ "prefix": s3::photo_prefix(photo_id) }),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
Ok(Json(serde_json::json!({ "ok": true })))
|
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 })))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn feedback_aggregate(
|
|
||||||
db: &sqlx::PgPool,
|
|
||||||
photo_id: Uuid,
|
|
||||||
) -> Result<FeedbackAggregate, sqlx::Error> {
|
|
||||||
let (top, accepts, rejects, owner_rating, owner_verdict): (
|
|
||||||
Option<i32>,
|
|
||||||
i64,
|
|
||||||
i64,
|
|
||||||
Option<i32>,
|
|
||||||
Option<String>,
|
|
||||||
) = sqlx::query_as(
|
|
||||||
"select
|
|
||||||
(select max(r.rating) from ratings r where r.photo_id = p.id),
|
|
||||||
(select count(*) from verdicts v where v.photo_id = p.id and v.verdict = 'accept'),
|
|
||||||
(select count(*) from verdicts v where v.photo_id = p.id and v.verdict = 'reject'),
|
|
||||||
p.owner_rating, p.owner_verdict
|
|
||||||
from photos p where p.id = $1",
|
|
||||||
)
|
|
||||||
.bind(photo_id)
|
|
||||||
.fetch_one(db)
|
|
||||||
.await?;
|
|
||||||
let mut agg = FeedbackAggregate {
|
|
||||||
top_rating: None,
|
|
||||||
accepts: accepts as i32,
|
|
||||||
rejects: rejects as i32,
|
|
||||||
};
|
|
||||||
if let Some(top) = top {
|
|
||||||
agg.add_rating(top);
|
|
||||||
}
|
|
||||||
agg.add_owner(owner_rating, owner_verdict.as_deref());
|
|
||||||
Ok(agg)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct OwnerRatingBody {
|
|
||||||
rating: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_owner_rating(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(photo_id): Path<Uuid>,
|
|
||||||
Json(body): Json<OwnerRatingBody>,
|
|
||||||
) -> ApiResult<Json<serde_json::Value>> {
|
|
||||||
if !(0..=5).contains(&body.rating) {
|
|
||||||
return Err(ApiError::bad_request("rating must be between 0 and 5"));
|
|
||||||
}
|
|
||||||
let updated = sqlx::query("update photos set owner_rating = $2 where id = $1")
|
|
||||||
.bind(photo_id)
|
|
||||||
.bind((body.rating > 0).then_some(body.rating))
|
|
||||||
.execute(&state.db)
|
|
||||||
.await?;
|
|
||||||
if updated.rows_affected() == 0 {
|
|
||||||
return Err(ApiError::not_found());
|
|
||||||
}
|
|
||||||
let aggregate = feedback_aggregate(&state.db, photo_id).await?;
|
|
||||||
Ok(Json(serde_json::json!({ "ok": true, "aggregate": aggregate })))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct OwnerVerdictBody {
|
|
||||||
verdict: Option<Verdict>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_owner_verdict(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(photo_id): Path<Uuid>,
|
|
||||||
Json(body): Json<OwnerVerdictBody>,
|
|
||||||
) -> ApiResult<Json<serde_json::Value>> {
|
|
||||||
let updated = sqlx::query("update photos set owner_verdict = $2 where id = $1")
|
|
||||||
.bind(photo_id)
|
|
||||||
.bind(body.verdict.map(Verdict::as_str))
|
|
||||||
.execute(&state.db)
|
|
||||||
.await?;
|
|
||||||
if updated.rows_affected() == 0 {
|
|
||||||
return Err(ApiError::not_found());
|
|
||||||
}
|
|
||||||
let aggregate = feedback_aggregate(&state.db, photo_id).await?;
|
|
||||||
Ok(Json(serde_json::json!({ "ok": true, "aggregate": aggregate })))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn reprocess(
|
pub async fn reprocess(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
Path(photo_id): Path<Uuid>,
|
Path(photo_id): Path<Uuid>,
|
||||||
) -> ApiResult<Json<serde_json::Value>> {
|
) -> ApiResult<Json<serde_json::Value>> {
|
||||||
|
owned::photo(&state, photo_id, user.id).await?;
|
||||||
let mut tx = state.db.begin().await?;
|
let mut tx = state.db.begin().await?;
|
||||||
let updated = sqlx::query("update photos set status = $2, error = null where id = $1")
|
let updated = sqlx::query("update photos set status = $2, error = null where id = $1")
|
||||||
.bind(photo_id)
|
.bind(photo_id)
|
||||||
|
|||||||
+19
-71
@@ -7,8 +7,9 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::random_token;
|
use crate::auth::{random_token, AuthUser};
|
||||||
use crate::error::{ApiError, ApiResult};
|
use crate::error::{ApiError, ApiResult};
|
||||||
|
use crate::owned;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
@@ -23,8 +24,6 @@ struct ShareAdminRow {
|
|||||||
created_at: DateTime<Utc>,
|
created_at: DateTime<Utc>,
|
||||||
rating_count: i64,
|
rating_count: i64,
|
||||||
tag_count: i64,
|
tag_count: i64,
|
||||||
accept_count: i64,
|
|
||||||
reject_count: i64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn share_json(state: &AppState, row: &ShareAdminRow) -> serde_json::Value {
|
fn share_json(state: &AppState, row: &ShareAdminRow) -> serde_json::Value {
|
||||||
@@ -40,30 +39,22 @@ fn share_json(state: &AppState, row: &ShareAdminRow) -> serde_json::Value {
|
|||||||
"created_at": row.created_at,
|
"created_at": row.created_at,
|
||||||
"rating_count": row.rating_count,
|
"rating_count": row.rating_count,
|
||||||
"tag_count": row.tag_count,
|
"tag_count": row.tag_count,
|
||||||
"accept_count": row.accept_count,
|
|
||||||
"reject_count": row.reject_count,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aggregates run as laterals so each feedback table is scanned once per
|
const SHARE_COLUMNS: &str = "s.id, s.token, s.label, s.password_hash, s.allow_download,
|
||||||
// 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,
|
s.expires_at, s.locked_until, s.created_at,
|
||||||
rc.rating_count, tc.tag_count, vc.accept_count, vc.reject_count
|
(select count(*) from ratings r where r.share_id = s.id) as rating_count,
|
||||||
from shares s
|
(select count(*) from tags t where t.share_id = s.id) as tag_count";
|
||||||
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(
|
pub async fn list(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
Path(album_id): Path<Uuid>,
|
Path(album_id): Path<Uuid>,
|
||||||
) -> ApiResult<Json<Vec<serde_json::Value>>> {
|
) -> ApiResult<Json<Vec<serde_json::Value>>> {
|
||||||
|
owned::album(&state, album_id, user.id).await?;
|
||||||
let rows: Vec<ShareAdminRow> = sqlx::query_as(&format!(
|
let rows: Vec<ShareAdminRow> = sqlx::query_as(&format!(
|
||||||
"{SHARE_SELECT} where s.album_id = $1 order by s.created_at desc"
|
"select {SHARE_COLUMNS} from shares s where s.album_id = $1 order by s.created_at desc"
|
||||||
))
|
))
|
||||||
.bind(album_id)
|
.bind(album_id)
|
||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
@@ -87,16 +78,11 @@ fn default_true() -> bool {
|
|||||||
|
|
||||||
pub async fn create(
|
pub async fn create(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
Path(album_id): Path<Uuid>,
|
Path(album_id): Path<Uuid>,
|
||||||
Json(body): Json<CreateShare>,
|
Json(body): Json<CreateShare>,
|
||||||
) -> ApiResult<Json<serde_json::Value>> {
|
) -> ApiResult<Json<serde_json::Value>> {
|
||||||
let album_exists: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1")
|
owned::album(&state, album_id, user.id).await?;
|
||||||
.bind(album_id)
|
|
||||||
.fetch_optional(&state.db)
|
|
||||||
.await?;
|
|
||||||
if album_exists.is_none() {
|
|
||||||
return Err(ApiError::not_found());
|
|
||||||
}
|
|
||||||
|
|
||||||
let password_hash = match body.password.as_deref().map(str::trim) {
|
let password_hash = match body.password.as_deref().map(str::trim) {
|
||||||
Some(pw) if !pw.is_empty() => {
|
Some(pw) if !pw.is_empty() => {
|
||||||
@@ -131,53 +117,11 @@ pub async fn create(
|
|||||||
.fetch_one(&state.db)
|
.fetch_one(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let row: ShareAdminRow = sqlx::query_as(&format!("{SHARE_SELECT} where s.id = $1"))
|
let row: ShareAdminRow =
|
||||||
.bind(share_id)
|
sqlx::query_as(&format!("select {SHARE_COLUMNS} from shares s where s.id = $1"))
|
||||||
.fetch_one(&state.db)
|
.bind(share_id)
|
||||||
.await?;
|
.fetch_one(&state.db)
|
||||||
Ok(Json(share_json(&state, &row)))
|
.await?;
|
||||||
}
|
|
||||||
|
|
||||||
/// 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)))
|
Ok(Json(share_json(&state, &row)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,8 +129,10 @@ pub async fn update(
|
|||||||
/// their way into the 15-minute lock).
|
/// their way into the 15-minute lock).
|
||||||
pub async fn reset_lock(
|
pub async fn reset_lock(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
Path(share_id): Path<Uuid>,
|
Path(share_id): Path<Uuid>,
|
||||||
) -> ApiResult<Json<serde_json::Value>> {
|
) -> ApiResult<Json<serde_json::Value>> {
|
||||||
|
owned::share(&state, share_id, user.id).await?;
|
||||||
let updated =
|
let updated =
|
||||||
sqlx::query("update shares set failed_attempts = 0, locked_until = null where id = $1")
|
sqlx::query("update shares set failed_attempts = 0, locked_until = null where id = $1")
|
||||||
.bind(share_id)
|
.bind(share_id)
|
||||||
@@ -200,8 +146,10 @@ pub async fn reset_lock(
|
|||||||
|
|
||||||
pub async fn delete(
|
pub async fn delete(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
Path(share_id): Path<Uuid>,
|
Path(share_id): Path<Uuid>,
|
||||||
) -> ApiResult<Json<serde_json::Value>> {
|
) -> ApiResult<Json<serde_json::Value>> {
|
||||||
|
owned::share(&state, share_id, user.id).await?;
|
||||||
let deleted = sqlx::query("delete from shares where id = $1")
|
let deleted = sqlx::query("delete from shares where id = $1")
|
||||||
.bind(share_id)
|
.bind(share_id)
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
|
|||||||
@@ -1,252 +0,0 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
|
||||||
|
|
||||||
use axum::extract::{Path, Query, State};
|
|
||||||
use axum::http::header;
|
|
||||||
use axum::response::{IntoResponse, Response};
|
|
||||||
use chrono::Utc;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::error::{ApiError, ApiResult};
|
|
||||||
use crate::models::{FeedbackAggregate, PhotoStatus};
|
|
||||||
use crate::state::AppState;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct XmpQuery {
|
|
||||||
/// Comma-separated share ids; absent = all shares.
|
|
||||||
shares: Option<String>,
|
|
||||||
/// Include the photographer's own feedback; defaults to true only when
|
|
||||||
/// no share filter is given.
|
|
||||||
own: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct Sidecar {
|
|
||||||
agg: FeedbackAggregate,
|
|
||||||
tags: BTreeSet<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ZIP of XMP sidecars, one per photo that has feedback from the selected
|
|
||||||
/// sources — drop them next to the RAWs and Capture One / Lightroom pick up
|
|
||||||
/// rating, color label and keywords on sync.
|
|
||||||
pub async fn album_xmp(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(album_id): Path<Uuid>,
|
|
||||||
Query(query): Query<XmpQuery>,
|
|
||||||
) -> ApiResult<Response> {
|
|
||||||
let album: Option<(String,)> = sqlx::query_as("select name from albums where id = $1")
|
|
||||||
.bind(album_id)
|
|
||||||
.fetch_optional(&state.db)
|
|
||||||
.await?;
|
|
||||||
let Some((album_name,)) = album else {
|
|
||||||
return Err(ApiError::not_found());
|
|
||||||
};
|
|
||||||
|
|
||||||
let share_filter: Option<Vec<Uuid>> = match &query.shares {
|
|
||||||
None => None,
|
|
||||||
Some(raw) => Some(
|
|
||||||
raw.split(',')
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(|s| {
|
|
||||||
Uuid::parse_str(s)
|
|
||||||
.map_err(|_| ApiError::bad_request(format!("invalid share id: {s}")))
|
|
||||||
})
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.map(|mut ids| {
|
|
||||||
ids.sort();
|
|
||||||
ids.dedup();
|
|
||||||
ids
|
|
||||||
})?,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
let include_own = query.own.unwrap_or(share_filter.is_none());
|
|
||||||
if let Some(ids) = &share_filter {
|
|
||||||
if ids.is_empty() && !include_own {
|
|
||||||
return Err(ApiError::bad_request("select at least one feedback source"));
|
|
||||||
}
|
|
||||||
if !ids.is_empty() {
|
|
||||||
let (found,): (i64,) =
|
|
||||||
sqlx::query_as("select count(*) from shares where album_id = $1 and id = any($2)")
|
|
||||||
.bind(album_id)
|
|
||||||
.bind(ids)
|
|
||||||
.fetch_one(&state.db)
|
|
||||||
.await?;
|
|
||||||
if found as usize != ids.len() {
|
|
||||||
return Err(ApiError::not_found());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let photos: Vec<(Uuid, String, Option<i32>, Option<String>)> = sqlx::query_as(
|
|
||||||
"select id, filename, owner_rating, owner_verdict
|
|
||||||
from photos where album_id = $1 and status = $2 order by filename",
|
|
||||||
)
|
|
||||||
.bind(album_id)
|
|
||||||
.bind(PhotoStatus::Ready.as_str())
|
|
||||||
.fetch_all(&state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let shares = share_filter.as_deref();
|
|
||||||
let (ratings, verdicts, tags) = tokio::try_join!(
|
|
||||||
super::albums::feedback_rows::<i32>(&state.db, super::albums::RATING_ROWS, album_id, shares),
|
|
||||||
super::albums::feedback_rows::<String>(&state.db, super::albums::VERDICT_ROWS, album_id, shares),
|
|
||||||
super::albums::feedback_rows::<String>(&state.db, super::albums::TAG_ROWS, album_id, shares),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let mut feedback: HashMap<Uuid, Sidecar> = HashMap::new();
|
|
||||||
for (photo_id, _, _, rating) in ratings {
|
|
||||||
feedback.entry(photo_id).or_default().agg.add_rating(rating);
|
|
||||||
}
|
|
||||||
for (photo_id, _, _, verdict) in verdicts {
|
|
||||||
feedback.entry(photo_id).or_default().agg.add_verdict(&verdict);
|
|
||||||
}
|
|
||||||
for (photo_id, _, _, tag) in tags {
|
|
||||||
feedback.entry(photo_id).or_default().tags.insert(tag);
|
|
||||||
}
|
|
||||||
|
|
||||||
// One sidecar per BASENAME: a RAW+JPEG pair shares its .xmp, so their
|
|
||||||
// feedback is merged — a disambiguated "name-2.xmp" would never be
|
|
||||||
// matched by any editor and its feedback silently lost.
|
|
||||||
let mut sidecars: BTreeMap<String, Sidecar> = BTreeMap::new();
|
|
||||||
for (photo_id, filename, owner_rating, owner_verdict) in &photos {
|
|
||||||
let mut f = feedback.remove(photo_id).unwrap_or_default();
|
|
||||||
if include_own {
|
|
||||||
f.agg.add_owner(*owner_rating, owner_verdict.as_deref());
|
|
||||||
}
|
|
||||||
if f.agg.is_empty() && f.tags.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let base = filename.rsplit_once('.').map(|(b, _)| b).unwrap_or(filename);
|
|
||||||
let entry = sidecars.entry(format!("{base}.xmp")).or_default();
|
|
||||||
entry.agg.merge(&f.agg);
|
|
||||||
entry.tags.extend(f.tags);
|
|
||||||
}
|
|
||||||
let entries: Vec<(String, Vec<u8>)> = sidecars
|
|
||||||
.into_iter()
|
|
||||||
.map(|(name, f)| {
|
|
||||||
(
|
|
||||||
name,
|
|
||||||
xmp_document(
|
|
||||||
f.agg.top_rating.map(|r| r.clamp(1, 5)),
|
|
||||||
f.agg.label(),
|
|
||||||
&f.tags,
|
|
||||||
)
|
|
||||||
.into_bytes(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let zip = build_stored_zip(&entries);
|
|
||||||
let safe_name: String = album_name
|
|
||||||
.chars()
|
|
||||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
|
|
||||||
.collect();
|
|
||||||
Ok((
|
|
||||||
[
|
|
||||||
(header::CONTENT_TYPE, "application/zip".to_string()),
|
|
||||||
(
|
|
||||||
header::CONTENT_DISPOSITION,
|
|
||||||
format!("attachment; filename=\"{safe_name}-xmp.zip\""),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
zip,
|
|
||||||
)
|
|
||||||
.into_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn escape_xml(value: &str) -> String {
|
|
||||||
value
|
|
||||||
.replace('&', "&")
|
|
||||||
.replace('<', "<")
|
|
||||||
.replace('>', ">")
|
|
||||||
.replace('"', """)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn xmp_document(rating: Option<i32>, label: Option<&str>, tags: &BTreeSet<String>) -> String {
|
|
||||||
let mut attrs = String::new();
|
|
||||||
if let Some(rating) = rating {
|
|
||||||
attrs.push_str(&format!(" xmp:Rating=\"{rating}\""));
|
|
||||||
}
|
|
||||||
if let Some(label) = label {
|
|
||||||
attrs.push_str(&format!(" xmp:Label=\"{label}\""));
|
|
||||||
}
|
|
||||||
let subject = if tags.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
let items: String = tags
|
|
||||||
.iter()
|
|
||||||
.map(|t| format!("<rdf:li>{}</rdf:li>", escape_xml(t)))
|
|
||||||
.collect();
|
|
||||||
format!("<dc:subject><rdf:Bag>{items}</rdf:Bag></dc:subject>")
|
|
||||||
};
|
|
||||||
format!(
|
|
||||||
"<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n\
|
|
||||||
<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n \
|
|
||||||
<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n \
|
|
||||||
<rdf:Description rdf:about=\"\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\" \
|
|
||||||
xmlns:dc=\"http://purl.org/dc/elements/1.1/\"{attrs}>{subject}</rdf:Description>\n \
|
|
||||||
</rdf:RDF>\n\
|
|
||||||
</x:xmpmeta>\n\
|
|
||||||
<?xpacket end=\"w\"?>"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimal stored (uncompressed) in-memory ZIP — the entries are a handful
|
|
||||||
/// of small text files, the streaming writer in zip.rs is for S3 payloads.
|
|
||||||
fn build_stored_zip(entries: &[(String, Vec<u8>)]) -> Vec<u8> {
|
|
||||||
let (dos_time, dos_date) = super::zip::dos_datetime(Utc::now());
|
|
||||||
let mut out: Vec<u8> = Vec::new();
|
|
||||||
let mut central: Vec<u8> = Vec::new();
|
|
||||||
for (name, data) in entries {
|
|
||||||
let offset = out.len() as u32;
|
|
||||||
let crc = crc32fast::hash(data);
|
|
||||||
let name_bytes = name.as_bytes();
|
|
||||||
let size = data.len() as u32;
|
|
||||||
|
|
||||||
out.extend_from_slice(&0x04034b50u32.to_le_bytes());
|
|
||||||
out.extend_from_slice(&20u16.to_le_bytes());
|
|
||||||
out.extend_from_slice(&0x0800u16.to_le_bytes()); // UTF-8 names
|
|
||||||
out.extend_from_slice(&0u16.to_le_bytes()); // stored
|
|
||||||
out.extend_from_slice(&dos_time.to_le_bytes());
|
|
||||||
out.extend_from_slice(&dos_date.to_le_bytes());
|
|
||||||
out.extend_from_slice(&crc.to_le_bytes());
|
|
||||||
out.extend_from_slice(&size.to_le_bytes());
|
|
||||||
out.extend_from_slice(&size.to_le_bytes());
|
|
||||||
out.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
|
|
||||||
out.extend_from_slice(&0u16.to_le_bytes());
|
|
||||||
out.extend_from_slice(name_bytes);
|
|
||||||
out.extend_from_slice(data);
|
|
||||||
|
|
||||||
central.extend_from_slice(&0x02014b50u32.to_le_bytes());
|
|
||||||
central.extend_from_slice(&20u16.to_le_bytes());
|
|
||||||
central.extend_from_slice(&20u16.to_le_bytes());
|
|
||||||
central.extend_from_slice(&0x0800u16.to_le_bytes());
|
|
||||||
central.extend_from_slice(&0u16.to_le_bytes());
|
|
||||||
central.extend_from_slice(&dos_time.to_le_bytes());
|
|
||||||
central.extend_from_slice(&dos_date.to_le_bytes());
|
|
||||||
central.extend_from_slice(&crc.to_le_bytes());
|
|
||||||
central.extend_from_slice(&size.to_le_bytes());
|
|
||||||
central.extend_from_slice(&size.to_le_bytes());
|
|
||||||
central.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
|
|
||||||
central.extend_from_slice(&0u16.to_le_bytes());
|
|
||||||
central.extend_from_slice(&0u16.to_le_bytes());
|
|
||||||
central.extend_from_slice(&0u16.to_le_bytes());
|
|
||||||
central.extend_from_slice(&0u16.to_le_bytes());
|
|
||||||
central.extend_from_slice(&0u32.to_le_bytes());
|
|
||||||
central.extend_from_slice(&offset.to_le_bytes());
|
|
||||||
central.extend_from_slice(name_bytes);
|
|
||||||
}
|
|
||||||
let cd_offset = out.len() as u32;
|
|
||||||
out.extend_from_slice(¢ral);
|
|
||||||
let count = entries.len() as u16;
|
|
||||||
out.extend_from_slice(&0x06054b50u32.to_le_bytes());
|
|
||||||
out.extend_from_slice(&0u16.to_le_bytes());
|
|
||||||
out.extend_from_slice(&0u16.to_le_bytes());
|
|
||||||
out.extend_from_slice(&count.to_le_bytes());
|
|
||||||
out.extend_from_slice(&count.to_le_bytes());
|
|
||||||
out.extend_from_slice(&(central.len() as u32).to_le_bytes());
|
|
||||||
out.extend_from_slice(&cd_offset.to_le_bytes());
|
|
||||||
out.extend_from_slice(&0u16.to_le_bytes());
|
|
||||||
out
|
|
||||||
}
|
|
||||||
+47
-60
@@ -81,12 +81,13 @@ async fn album_name(state: &AppState, album_id: Uuid) -> Result<String, ApiError
|
|||||||
|
|
||||||
pub async fn album_zip(
|
pub async fn album_zip(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: crate::auth::AuthUser,
|
||||||
Path(album_id): Path<Uuid>,
|
Path(album_id): Path<Uuid>,
|
||||||
Form(request): Form<ZipRequest>,
|
Form(request): Form<ZipRequest>,
|
||||||
) -> ApiResult<Response> {
|
) -> ApiResult<Response> {
|
||||||
let name = album_name(&state, album_id).await?;
|
let album = crate::owned::album(&state, album_id, user.id).await?;
|
||||||
let photos = ready_photos(&state, album_id, &parse_ids(&request.ids)?).await?;
|
let photos = ready_photos(&state, album_id, &parse_ids(&request.ids)?).await?;
|
||||||
stream_zip(state, photos, &name)
|
stream_zip(state, photos, &album.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn share_zip(
|
pub async fn share_zip(
|
||||||
@@ -122,7 +123,7 @@ fn unique_entry_name(used: &mut HashSet<String>, filename: &str) -> String {
|
|||||||
|
|
||||||
/// MS-DOS timestamp (2-second resolution, no timezone; years 1980+ only —
|
/// MS-DOS timestamp (2-second resolution, no timezone; years 1980+ only —
|
||||||
/// callers clamp earlier dates).
|
/// callers clamp earlier dates).
|
||||||
pub(super) fn dos_datetime(t: DateTime<Utc>) -> (u16, u16) {
|
fn dos_datetime(t: DateTime<Utc>) -> (u16, u16) {
|
||||||
let time = ((t.hour() as u16) << 11) | ((t.minute() as u16) << 5) | (t.second() as u16 / 2);
|
let time = ((t.hour() as u16) << 11) | ((t.minute() as u16) << 5) | (t.second() as u16 / 2);
|
||||||
let date = (((t.year() - 1980) as u16) << 9) | ((t.month() as u16) << 5) | (t.day() as u16);
|
let date = (((t.year() - 1980) as u16) << 9) | ((t.month() as u16) << 5) | (t.day() as u16);
|
||||||
(time, date)
|
(time, date)
|
||||||
@@ -136,9 +137,6 @@ struct Entry {
|
|||||||
offset: u64,
|
offset: u64,
|
||||||
dos_time: u16,
|
dos_time: u16,
|
||||||
dos_date: 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 {
|
struct ZipPlan {
|
||||||
@@ -179,7 +177,6 @@ fn plan_zip(photos: &[Photo]) -> Result<ZipPlan, ApiError> {
|
|||||||
offset: entry_offset,
|
offset: entry_offset,
|
||||||
dos_time,
|
dos_time,
|
||||||
dos_date,
|
dos_date,
|
||||||
crc: photo.crc32.map(|v| v as u32),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let cd_offset = offset;
|
let cd_offset = offset;
|
||||||
@@ -281,23 +278,26 @@ fn stream_zip(state: AppState, photos: Vec<Photo>, album_name: &str) -> ApiResul
|
|||||||
.map_err(|e| anyhow::anyhow!("building response: {e}").into())
|
.map_err(|e| anyhow::anyhow!("building response: {e}").into())
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Fetched {
|
struct Fetched {
|
||||||
/// CRC already known — the body streams straight into the response.
|
file: tokio::fs::File,
|
||||||
Direct(Box<aws_sdk_s3::operation::get_object::GetObjectOutput>),
|
crc: u32,
|
||||||
/// Pre-hash photo: spooled to a temp file to compute the CRC first.
|
/// sha256 hex — always computed so legacy photos (crc-only) get their
|
||||||
Spooled(tokio::fs::File, u32),
|
/// content hash backfilled, restoring upload dedup for them.
|
||||||
|
sha256: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start fetching an original. With a known CRC this only opens the S3
|
/// Fetch an original and spool it to an anonymous temp file, computing crc32
|
||||||
/// response (the body is consumed later, straight into the zip stream);
|
/// and sha256 and verifying the byte count the zip plan promised. Spooling
|
||||||
/// otherwise the object is spooled to an anonymous temp file to compute the
|
/// (rather than streaming the live S3 body straight through) is deliberate:
|
||||||
/// CRC, verifying the byte count the zip plan promised.
|
/// the prefetched entry is fully read immediately, so no S3 connection sits
|
||||||
|
/// idle across the previous entry's (possibly slow) client stream — which S3
|
||||||
|
/// idle-timeouts would otherwise reset mid-download.
|
||||||
fn fetch_entry(
|
fn fetch_entry(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
key: String,
|
key: String,
|
||||||
expected_size: u64,
|
expected_size: u64,
|
||||||
crc_known: bool,
|
|
||||||
) -> JoinHandle<anyhow::Result<Fetched>> {
|
) -> JoinHandle<anyhow::Result<Fetched>> {
|
||||||
|
use sha2::Digest;
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let object = state
|
let object = state
|
||||||
@@ -308,12 +308,10 @@ fn fetch_entry(
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("fetching {key}: {e}"))?;
|
.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 file = tokio::fs::File::from_std(tempfile::tempfile()?);
|
||||||
let mut reader = object.body.into_async_read();
|
let mut reader = object.body.into_async_read();
|
||||||
let mut hasher = crc32fast::Hasher::new();
|
let mut crc = crc32fast::Hasher::new();
|
||||||
|
let mut sha = sha2::Sha256::new();
|
||||||
let mut written: u64 = 0;
|
let mut written: u64 = 0;
|
||||||
let mut buf = vec![0u8; 128 * 1024];
|
let mut buf = vec![0u8; 128 * 1024];
|
||||||
loop {
|
loop {
|
||||||
@@ -321,7 +319,8 @@ fn fetch_entry(
|
|||||||
if n == 0 {
|
if n == 0 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
hasher.update(&buf[..n]);
|
crc.update(&buf[..n]);
|
||||||
|
sha.update(&buf[..n]);
|
||||||
file.write_all(&buf[..n]).await?;
|
file.write_all(&buf[..n]).await?;
|
||||||
written += n as u64;
|
written += n as u64;
|
||||||
}
|
}
|
||||||
@@ -331,7 +330,11 @@ fn fetch_entry(
|
|||||||
);
|
);
|
||||||
file.flush().await?;
|
file.flush().await?;
|
||||||
file.seek(std::io::SeekFrom::Start(0)).await?;
|
file.seek(std::io::SeekFrom::Start(0)).await?;
|
||||||
Ok(Fetched::Spooled(file, hasher.finalize()))
|
Ok(Fetched {
|
||||||
|
file,
|
||||||
|
crc: crc.finalize(),
|
||||||
|
sha256: hex::encode(sha.finalize()),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,30 +347,23 @@ async fn write_zip(
|
|||||||
const FLAGS: u16 = 0x0800;
|
const FLAGS: u16 = 0x0800;
|
||||||
let mut crcs = Vec::with_capacity(plan.entries.len());
|
let mut crcs = Vec::with_capacity(plan.entries.len());
|
||||||
|
|
||||||
// Prefetch: start fetching the next object while streaming the current one.
|
// Prefetch: spool the next object to a temp file while streaming the
|
||||||
|
// current one — the prefetched body is drained immediately, never held
|
||||||
|
// open across the current entry's client stream.
|
||||||
let mut pending: Option<JoinHandle<anyhow::Result<Fetched>>> = None;
|
let mut pending: Option<JoinHandle<anyhow::Result<Fetched>>> = None;
|
||||||
for (i, entry) in plan.entries.iter().enumerate() {
|
for (i, entry) in plan.entries.iter().enumerate() {
|
||||||
let current = match pending.take() {
|
let current = match pending.take() {
|
||||||
Some(handle) => handle,
|
Some(handle) => handle,
|
||||||
None => fetch_entry(state, entry.s3_key.clone(), entry.size, entry.crc.is_some()),
|
None => fetch_entry(state, entry.s3_key.clone(), entry.size),
|
||||||
};
|
};
|
||||||
if let Some(next) = plan.entries.get(i + 1) {
|
if let Some(next) = plan.entries.get(i + 1) {
|
||||||
pending = Some(fetch_entry(
|
pending = Some(fetch_entry(state, next.s3_key.clone(), next.size));
|
||||||
state,
|
|
||||||
next.s3_key.clone(),
|
|
||||||
next.size,
|
|
||||||
next.crc.is_some(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
let fetched = current
|
let mut fetched = current
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("fetch task failed: {e}"))?
|
.map_err(|e| anyhow::anyhow!("fetch task failed: {e}"))?
|
||||||
.map_err(|e| anyhow::anyhow!("photo {}: {e:#}", entry.photo_id))?;
|
.map_err(|e| anyhow::anyhow!("photo {}: {e:#}", entry.photo_id))?;
|
||||||
let crc = match &fetched {
|
crcs.push(fetched.crc);
|
||||||
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());
|
let mut lfh = Vec::with_capacity(30 + entry.name.len());
|
||||||
lfh.extend_from_slice(&0x04034b50u32.to_le_bytes());
|
lfh.extend_from_slice(&0x04034b50u32.to_le_bytes());
|
||||||
@@ -376,35 +372,26 @@ async fn write_zip(
|
|||||||
lfh.extend_from_slice(&0u16.to_le_bytes()); // method: stored
|
lfh.extend_from_slice(&0u16.to_le_bytes()); // method: stored
|
||||||
lfh.extend_from_slice(&entry.dos_time.to_le_bytes());
|
lfh.extend_from_slice(&entry.dos_time.to_le_bytes());
|
||||||
lfh.extend_from_slice(&entry.dos_date.to_le_bytes());
|
lfh.extend_from_slice(&entry.dos_date.to_le_bytes());
|
||||||
lfh.extend_from_slice(&crc.to_le_bytes());
|
lfh.extend_from_slice(&fetched.crc.to_le_bytes());
|
||||||
lfh.extend_from_slice(&(entry.size as u32).to_le_bytes()); // compressed
|
lfh.extend_from_slice(&(entry.size as u32).to_le_bytes()); // compressed
|
||||||
lfh.extend_from_slice(&(entry.size as u32).to_le_bytes()); // uncompressed
|
lfh.extend_from_slice(&(entry.size as u32).to_le_bytes()); // uncompressed
|
||||||
lfh.extend_from_slice(&(entry.name.len() as u16).to_le_bytes());
|
lfh.extend_from_slice(&(entry.name.len() as u16).to_le_bytes());
|
||||||
lfh.extend_from_slice(&0u16.to_le_bytes()); // extra len
|
lfh.extend_from_slice(&0u16.to_le_bytes()); // extra len
|
||||||
lfh.extend_from_slice(&entry.name);
|
lfh.extend_from_slice(&entry.name);
|
||||||
out.write_all(&lfh).await?;
|
out.write_all(&lfh).await?;
|
||||||
match fetched {
|
tokio::io::copy(&mut fetched.file, &mut out).await?;
|
||||||
Fetched::Direct(object) => {
|
|
||||||
let mut reader = object.body.into_async_read();
|
// Self-heal legacy photos: backfill both hashes so future zips and
|
||||||
let copied = tokio::io::copy(&mut reader, &mut out).await?;
|
// upload dedup both work for them.
|
||||||
anyhow::ensure!(
|
let _ = sqlx::query(
|
||||||
copied == entry.size,
|
"update photos set crc32 = coalesce(crc32, $2), sha256 = coalesce(sha256, $3)
|
||||||
"{} is {copied} bytes in s3 but {} in the database",
|
where id = $1",
|
||||||
entry.s3_key,
|
)
|
||||||
entry.size
|
.bind(entry.photo_id)
|
||||||
);
|
.bind(i64::from(fetched.crc))
|
||||||
}
|
.bind(&fetched.sha256)
|
||||||
Fetched::Spooled(mut file, crc) => {
|
.execute(&state.db)
|
||||||
tokio::io::copy(&mut file, &mut out).await?;
|
.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.
|
// Central directory.
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
//! Tenant-isolation matrix: every admin endpoint must treat another user's
|
||||||
|
//! resources as nonexistent (404), and unauthenticated requests as 401.
|
||||||
|
//!
|
||||||
|
//! Needs a disposable Postgres database:
|
||||||
|
//! TEST_DATABASE_URL=postgres://photos:photos@localhost:5432/photos_test cargo test
|
||||||
|
//! Skips silently when TEST_DATABASE_URL is unset. S3 is never contacted —
|
||||||
|
//! the matrix stops at the ownership checks by construction.
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{header, Request, StatusCode};
|
||||||
|
use cookie::{Cookie, CookieJar, Key};
|
||||||
|
use sha2::{Digest, Sha512};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use photos::config::Config;
|
||||||
|
use photos::state::AppState;
|
||||||
|
|
||||||
|
const SECRET: &str = "test-secret-test-secret-test-secret-1234";
|
||||||
|
|
||||||
|
fn test_config(database_url: String) -> Config {
|
||||||
|
Config {
|
||||||
|
database_url,
|
||||||
|
bind_addr: "127.0.0.1:0".into(),
|
||||||
|
public_url: "http://localhost:8080".into(),
|
||||||
|
session_secret: SECRET.into(),
|
||||||
|
s3_bucket: "photos".into(),
|
||||||
|
// Unroutable on purpose: no test may reach S3.
|
||||||
|
s3_endpoint: Some("http://127.0.0.1:9".into()),
|
||||||
|
s3_region: "us-east-1".into(),
|
||||||
|
s3_access_key: "test".into(),
|
||||||
|
s3_secret_key: "test".into(),
|
||||||
|
s3_force_path_style: true,
|
||||||
|
oidc_issuer: "https://auth.invalid".into(),
|
||||||
|
oidc_client_id: "x".into(),
|
||||||
|
oidc_client_secret: "x".into(),
|
||||||
|
allowed_emails: vec!["a@test".into(), "b@test".into()],
|
||||||
|
static_dir: "frontend/dist".into(),
|
||||||
|
worker_concurrency: 1,
|
||||||
|
dev_autologin_email: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_for(user_id: Uuid, email: &str) -> String {
|
||||||
|
let key = Key::from(&Sha512::digest(SECRET.as_bytes()));
|
||||||
|
let exp = chrono::Utc::now().timestamp() + 3600;
|
||||||
|
// Use the production payload builder so the test can't drift from what
|
||||||
|
// user_from_jar parses.
|
||||||
|
let mut jar = CookieJar::new();
|
||||||
|
jar.signed_mut(&key).add(Cookie::new(
|
||||||
|
"photos_session",
|
||||||
|
photos::auth::session_payload(user_id, email, exp),
|
||||||
|
));
|
||||||
|
format!("photos_session={}", jar.get("photos_session").unwrap().value())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request(
|
||||||
|
router: &axum::Router,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
cookie: Option<&str>,
|
||||||
|
json: Option<serde_json::Value>,
|
||||||
|
) -> (StatusCode, serde_json::Value) {
|
||||||
|
let mut builder = Request::builder().method(method).uri(path);
|
||||||
|
if let Some(cookie) = cookie {
|
||||||
|
builder = builder.header(header::COOKIE, cookie);
|
||||||
|
}
|
||||||
|
let body = match json {
|
||||||
|
Some(value) => {
|
||||||
|
builder = builder.header(header::CONTENT_TYPE, "application/json");
|
||||||
|
Body::from(value.to_string())
|
||||||
|
}
|
||||||
|
// Zip endpoints take a form; everything else ignores the body.
|
||||||
|
None if path.ends_with("/zip") => {
|
||||||
|
builder = builder.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
|
||||||
|
Body::from("ids=")
|
||||||
|
}
|
||||||
|
None => Body::empty(),
|
||||||
|
};
|
||||||
|
let response = router
|
||||||
|
.clone()
|
||||||
|
.oneshot(builder.body(body).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let status = response.status();
|
||||||
|
let bytes = axum::body::to_bytes(response.into_body(), 1 << 20)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let value = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
|
||||||
|
(status, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_user(state: &AppState, email: &str) -> Uuid {
|
||||||
|
let (id,): (Uuid,) = sqlx::query_as(
|
||||||
|
"insert into users (oidc_subject, email) values ($1, $2) returning id",
|
||||||
|
)
|
||||||
|
.bind(format!("test:{email}"))
|
||||||
|
.bind(email)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tenant_isolation_matrix() {
|
||||||
|
let Ok(database_url) = std::env::var("TEST_DATABASE_URL") else {
|
||||||
|
eprintln!("TEST_DATABASE_URL not set — skipping tenancy matrix");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let state = AppState::new(test_config(database_url)).await.unwrap();
|
||||||
|
sqlx::query("truncate users, albums, photos, shares, ratings, tags, jobs cascade")
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let alice = seed_user(&state, "a@test").await;
|
||||||
|
let bob = seed_user(&state, "b@test").await;
|
||||||
|
let cookie_a = session_for(alice, "a@test");
|
||||||
|
let cookie_b = session_for(bob, "b@test");
|
||||||
|
let router = photos::routes::router(&state).with_state(state.clone());
|
||||||
|
|
||||||
|
// Alice creates an album through the real API.
|
||||||
|
let (status, album) = request(
|
||||||
|
&router,
|
||||||
|
"POST",
|
||||||
|
"/api/albums",
|
||||||
|
Some(&cookie_a),
|
||||||
|
Some(serde_json::json!({ "name": "Alice's Wedding" })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let album_id = album["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
// Seed a photo (kept non-ready so no code path reaches S3) and a share.
|
||||||
|
let photo_id = Uuid::new_v4();
|
||||||
|
sqlx::query(
|
||||||
|
"insert into photos (id, album_id, filename, content_type, size_bytes, sha256)
|
||||||
|
values ($1, $2::uuid, 'a.jpg', 'image/jpeg', 3, 'hash-a')",
|
||||||
|
)
|
||||||
|
.bind(photo_id)
|
||||||
|
.bind(&album_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let share_id = Uuid::new_v4();
|
||||||
|
sqlx::query("insert into shares (id, album_id, token) values ($1, $2::uuid, 'tenanttesttoken123456789')")
|
||||||
|
.bind(share_id)
|
||||||
|
.bind(&album_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// ---- Bob vs Alice's resources: everything must be a 404 (or absent). ----
|
||||||
|
let bob_hits: &[(&str, String, Option<serde_json::Value>)] = &[
|
||||||
|
("GET", format!("/api/albums/{album_id}"), None),
|
||||||
|
(
|
||||||
|
"PATCH",
|
||||||
|
format!("/api/albums/{album_id}"),
|
||||||
|
Some(serde_json::json!({ "name": "stolen" })),
|
||||||
|
),
|
||||||
|
("DELETE", format!("/api/albums/{album_id}"), None),
|
||||||
|
("POST", format!("/api/albums/{album_id}/photos?filename=x.jpg"), None),
|
||||||
|
("GET", format!("/api/albums/{album_id}/shares"), None),
|
||||||
|
(
|
||||||
|
"POST",
|
||||||
|
format!("/api/albums/{album_id}/shares"),
|
||||||
|
Some(serde_json::json!({ "label": "x" })),
|
||||||
|
),
|
||||||
|
("POST", format!("/api/albums/{album_id}/zip"), None),
|
||||||
|
("GET", format!("/api/albums/{album_id}/photos/by-hash/hash-a"), None),
|
||||||
|
("DELETE", format!("/api/photos/{photo_id}"), None),
|
||||||
|
("POST", format!("/api/photos/{photo_id}/reprocess"), None),
|
||||||
|
("DELETE", format!("/api/shares/{share_id}"), None),
|
||||||
|
("POST", format!("/api/shares/{share_id}/reset-lock"), None),
|
||||||
|
];
|
||||||
|
for (method, path, body) in bob_hits {
|
||||||
|
let (status, _) = request(&router, method, path, Some(&cookie_b), body.clone()).await;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"cross-tenant {method} {path} must 404"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let (status, list) = request(&router, "GET", "/api/albums", Some(&cookie_b), None).await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(list.as_array().unwrap().len(), 0, "bob must see no albums");
|
||||||
|
|
||||||
|
// ---- Dual-auth image routes must ALSO reject a non-owning photographer.
|
||||||
|
// Bob owns no share and doesn't own the album, so authorize_photo falls
|
||||||
|
// through to the share-cookie path and 401s BEFORE any S3 access — the
|
||||||
|
// cross-tenant original leak these routes previously allowed. ----
|
||||||
|
for path in [
|
||||||
|
format!("/api/img/{photo_id}/thumb"),
|
||||||
|
format!("/api/img/{photo_id}/preview"),
|
||||||
|
format!("/api/photos/{photo_id}/original"),
|
||||||
|
] {
|
||||||
|
let (status, _) = request(&router, "GET", &path, Some(&cookie_b), None).await;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"cross-tenant image GET {path} must not authorize"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Alice keeps full access to her own resources. ----
|
||||||
|
let (status, list) = request(&router, "GET", "/api/albums", Some(&cookie_a), None).await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(list.as_array().unwrap().len(), 1);
|
||||||
|
let (status, _) = request(
|
||||||
|
&router,
|
||||||
|
"GET",
|
||||||
|
&format!("/api/albums/{album_id}"),
|
||||||
|
Some(&cookie_a),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let (status, _) = request(
|
||||||
|
&router,
|
||||||
|
"GET",
|
||||||
|
&format!("/api/albums/{album_id}/photos/by-hash/hash-a"),
|
||||||
|
Some(&cookie_a),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
// Ownership check runs before the body is read: empty upload = 400, not 404.
|
||||||
|
let (status, _) = request(
|
||||||
|
&router,
|
||||||
|
"POST",
|
||||||
|
&format!("/api/albums/{album_id}/photos?filename=x.jpg"),
|
||||||
|
Some(&cookie_a),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||||
|
// No ready photos yet: zip is a 400 for the owner, never an S3 call.
|
||||||
|
let (status, _) = request(
|
||||||
|
&router,
|
||||||
|
"POST",
|
||||||
|
&format!("/api/albums/{album_id}/zip"),
|
||||||
|
Some(&cookie_a),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
// ---- No session at all: 401 on the admin surface. ----
|
||||||
|
for path in ["/api/albums", "/api/me"] {
|
||||||
|
let (status, _) = request(&router, "GET", path, None, None).await;
|
||||||
|
assert_eq!(status, StatusCode::UNAUTHORIZED, "{path} without session");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user