Compare commits
3
Commits
900287b7aa
..
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6259ca84d8 | ||
|
|
a6819809a7 | ||
|
|
16d2a56a78 |
+2
-2
@@ -16,10 +16,10 @@ S3_FORCE_PATH_STYLE=true
|
|||||||
OIDC_ISSUER=https://auth.example.com
|
OIDC_ISSUER=https://auth.example.com
|
||||||
OIDC_CLIENT_ID=photos
|
OIDC_CLIENT_ID=photos
|
||||||
OIDC_CLIENT_SECRET=change-me
|
OIDC_CLIENT_SECRET=change-me
|
||||||
ALLOWED_EMAILS=u797+claude@posteo.de
|
ALLOWED_EMAILS=you@example.com
|
||||||
|
|
||||||
# DEV ONLY: skip OIDC and sign straight in as this email. Never set in production.
|
# DEV ONLY: skip OIDC and sign straight in as this email. Never set in production.
|
||||||
# DEV_AUTOLOGIN_EMAIL=u797+claude@posteo.de
|
# DEV_AUTOLOGIN_EMAIL=you@example.com
|
||||||
|
|
||||||
WORKER_CONCURRENCY=2
|
WORKER_CONCURRENCY=2
|
||||||
RUST_LOG=info,sqlx=warn
|
RUST_LOG=info,sqlx=warn
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -54,10 +36,8 @@ The lightbox — accept/reject, star rating, tags, and keyboard-driven culling
|
|||||||
revokes access immediately. Clients use unguessable share tokens, optionally
|
revokes access immediately. 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 — justified gallery, 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.
|
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
@@ -155,11 +135,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).
|
|
||||||
- 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),
|
||||||
@@ -172,8 +150,8 @@ 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
|
||||||
|
|
||||||
|
|||||||
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 |
@@ -36,13 +36,14 @@ export default function Gallery({ photos, onOpen, overlay, selected, onToggleSel
|
|||||||
const [width, setWidth] = useState(0)
|
const [width, setWidth] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const el = containerRef.current
|
||||||
|
if (!el) return
|
||||||
const observer = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width))
|
const observer = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width))
|
||||||
observer.observe(containerRef.current)
|
observer.observe(el)
|
||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// The container renders even with zero photos — unmounting it would detach
|
if (photos.length === 0) return null
|
||||||
// the observer and leave a later non-empty render stuck at width 0.
|
|
||||||
const gap = 6
|
const gap = 6
|
||||||
const targetHeight = width < 700 ? 170 : 240
|
const targetHeight = width < 700 ? 170 : 240
|
||||||
const rows = width > 0 ? layoutRows(photos, width, targetHeight, gap) : []
|
const rows = width > 0 ? layoutRows(photos, width, targetHeight, gap) : []
|
||||||
@@ -68,7 +69,7 @@ export default function Gallery({ photos, onOpen, overlay, selected, onToggleSel
|
|||||||
title={isSelected ? 'Deselect' : 'Select'}
|
title={isSelected ? 'Deselect' : 'Select'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onToggleSelect(p.id, e.shiftKey)
|
onToggleSelect(p.id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
✓
|
✓
|
||||||
|
|||||||
@@ -1,71 +1,18 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { imgUrl } from '../api'
|
import { imgUrl } from '../api'
|
||||||
|
|
||||||
const BASE_SHORTCUTS = [
|
export default function Lightbox({ photos, index, onClose, onNav, footer }) {
|
||||||
['← / →', 'previous / next photo'],
|
|
||||||
['Space', 'next photo (Shift+Space back)'],
|
|
||||||
['?', 'show / hide shortcuts'],
|
|
||||||
['Esc', 'close'],
|
|
||||||
]
|
|
||||||
|
|
||||||
// `actions` defines the page's shortcuts as one table — display and dispatch
|
|
||||||
// come from the same entry, so the help overlay can't drift from behavior:
|
|
||||||
// { keys: ['p'], help: ['P', 'accept…'], run: (photo, key) => … }.
|
|
||||||
// Keys fire only outside text inputs and while the help overlay is closed.
|
|
||||||
export default function Lightbox({ photos, index, onClose, onNav, footer, actions }) {
|
|
||||||
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 }
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only text entry captures keys (Escape leaves the field); focus on a
|
const onKey = (e) => {
|
||||||
// checkbox or button must not disable lightbox navigation.
|
if (e.key === 'Escape') onClose()
|
||||||
const isTyping = (el) =>
|
if (e.key === 'ArrowRight' && index < photos.length - 1) onNav(index + 1)
|
||||||
el.tagName === 'TEXTAREA' ||
|
if (e.key === 'ArrowLeft' && index > 0) onNav(index - 1)
|
||||||
el.isContentEditable ||
|
|
||||||
(el.tagName === 'INPUT' && !['checkbox', 'radio', 'button'].includes(el.type))
|
|
||||||
const handler = (e) => {
|
|
||||||
const s = live.current
|
|
||||||
if (isTyping(e.target)) {
|
|
||||||
if (e.key === 'Escape') e.target.blur()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.metaKey || e.ctrlKey || e.altKey) return
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
if (s.showHelp) setShowHelp(false)
|
|
||||||
else s.onClose()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.key === '?') {
|
|
||||||
setShowHelp((h) => !h)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// With the help overlay up, keys must not act on the photo behind it.
|
|
||||||
if (s.showHelp) return
|
|
||||||
if (e.key === 'ArrowRight' || (e.key === ' ' && !e.shiftKey)) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (s.index < s.count - 1) s.onNav(s.index + 1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.key === 'ArrowLeft' || (e.key === ' ' && e.shiftKey)) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (s.index > 0) s.onNav(s.index - 1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const key = e.key.toLowerCase()
|
|
||||||
const action = s.actions?.find((a) => a.keys.includes(key))
|
|
||||||
if (action && s.photo) action.run(s.photo, key)
|
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', 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'
|
||||||
@@ -83,13 +30,6 @@ export default function Lightbox({ photos, index, onClose, onNav, footer, action
|
|||||||
<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>
|
||||||
@@ -127,25 +67,6 @@ export default function Lightbox({ photos, index, onClose, onNav, footer, action
|
|||||||
{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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,24 +0,0 @@
|
|||||||
// 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 (P)')}
|
|
||||||
{thumb('reject', '👎', 'Reject (X)')}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,6 @@ import Gallery from '../components/Gallery'
|
|||||||
import Lightbox from '../components/Lightbox'
|
import Lightbox from '../components/Lightbox'
|
||||||
import SelectionBar, { fmtBytes } from '../components/SelectionBar'
|
import SelectionBar, { fmtBytes } from '../components/SelectionBar'
|
||||||
import Stars from '../components/Stars'
|
import Stars from '../components/Stars'
|
||||||
import Thumbs from '../components/Thumbs'
|
|
||||||
import useLightbox from '../useLightbox'
|
|
||||||
import useSelection from '../useSelection'
|
import useSelection from '../useSelection'
|
||||||
|
|
||||||
function fmtEta(seconds) {
|
function fmtEta(seconds) {
|
||||||
@@ -18,67 +16,6 @@ function fmtEta(seconds) {
|
|||||||
|
|
||||||
const UPLOAD_CONCURRENCY = 3
|
const UPLOAD_CONCURRENCY = 3
|
||||||
|
|
||||||
// Expiry convention, in one place for the create form and the row editor:
|
|
||||||
// end of the chosen day in the photographer's local timezone — date-only
|
|
||||||
// strings would parse as UTC midnight and expire a day early.
|
|
||||||
const endOfDayIso = (day) => (day ? new Date(`${day}T23:59:59`).toISOString() : null)
|
|
||||||
|
|
||||||
// ISO timestamp -> local yyyy-mm-dd for date inputs.
|
|
||||||
const localDate = (iso) => {
|
|
||||||
const d = new Date(iso)
|
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Staged expiry editor: commits on blur/Enter, never per keystroke — typing
|
|
||||||
// a year fires change events with bogus intermediate dates (year 0002) that
|
|
||||||
// must not hit the live link. The ✕ clears explicitly; Safari's date input
|
|
||||||
// has no native clear control.
|
|
||||||
function ExpiryDate({ value, onCommit }) {
|
|
||||||
const current = value ? localDate(value) : ''
|
|
||||||
const [draft, setDraft] = useState(current)
|
|
||||||
useEffect(() => setDraft(current), [current])
|
|
||||||
|
|
||||||
const commit = () => {
|
|
||||||
if (draft === current) return
|
|
||||||
// A half-typed year (e.g. 0002) can survive until blur; don't persist it.
|
|
||||||
if (draft && draft.slice(0, 4) < '2000') {
|
|
||||||
setDraft(current)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onCommit(endOfDayIso(draft))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label className="row field-label" title="Expiry date — empty means the link never expires">
|
|
||||||
<span className="muted">expires</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={draft}
|
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
|
||||||
onBlur={commit}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') e.target.blur()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{draft ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost"
|
|
||||||
title="Remove expiry — link never expires"
|
|
||||||
onClick={() => {
|
|
||||||
setDraft('')
|
|
||||||
onCommit(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="muted">never</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function UploadZone({ albumId, onUploaded }) {
|
function UploadZone({ albumId, onUploaded }) {
|
||||||
const [queue, setQueue] = useState([])
|
const [queue, setQueue] = useState([])
|
||||||
const [dragging, setDragging] = useState(false)
|
const [dragging, setDragging] = useState(false)
|
||||||
@@ -259,7 +196,11 @@ function SharesPanel({ albumId }) {
|
|||||||
label: form.label,
|
label: form.label,
|
||||||
password: form.password || null,
|
password: form.password || null,
|
||||||
allow_download: form.allow_download,
|
allow_download: form.allow_download,
|
||||||
expires_at: endOfDayIso(form.expires_at),
|
// 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: '' })
|
setForm({ label: '', password: '', allow_download: true, expires_at: '' })
|
||||||
@@ -276,18 +217,6 @@ function SharesPanel({ albumId }) {
|
|||||||
setTimeout(() => setCopied(null), 1500)
|
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 (
|
return (
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<h2>Client links</h2>
|
<h2>Client links</h2>
|
||||||
@@ -302,20 +231,15 @@ function SharesPanel({ albumId }) {
|
|||||||
<span className="muted">
|
<span className="muted">
|
||||||
{s.has_password ? '🔒 password' : 'no password'}
|
{s.has_password ? '🔒 password' : 'no password'}
|
||||||
{' · '}
|
{' · '}
|
||||||
{s.rating_count} ratings, {s.tag_count} tags, 👍 {s.accept_count} 👎{' '}
|
{s.allow_download ? 'downloads on' : 'downloads off'}
|
||||||
{s.reject_count}
|
{s.expires_at
|
||||||
|
? ` · expires ${new Date(s.expires_at).toLocaleDateString()}`
|
||||||
|
: ' · never expires'}
|
||||||
|
{' · '}
|
||||||
|
{s.rating_count} ratings, {s.tag_count} tags
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<label className="select-toggle" title="Allow this link to download originals/ZIPs">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={s.allow_download}
|
|
||||||
onChange={(e) => update(s.id, { allow_download: e.target.checked })}
|
|
||||||
/>
|
|
||||||
downloads
|
|
||||||
</label>
|
|
||||||
<ExpiryDate value={s.expires_at} onCommit={(iso) => update(s.id, { expires_at: iso })} />
|
|
||||||
{s.locked && (
|
{s.locked && (
|
||||||
<button
|
<button
|
||||||
className="btn"
|
className="btn"
|
||||||
@@ -396,6 +320,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)),
|
||||||
@@ -415,7 +342,14 @@ export default function AlbumPage() {
|
|||||||
|
|
||||||
const ready = (detail?.photos ?? []).filter((p) => p.status === 'ready')
|
const ready = (detail?.photos ?? []).filter((p) => p.status === 'ready')
|
||||||
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(ready)
|
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(ready)
|
||||||
const lightbox = useLightbox(ready)
|
const lightboxIndex = ready.findIndex((p) => p.id === lightboxId)
|
||||||
|
|
||||||
|
// If the open photo leaves the ready list (deleted elsewhere, reprocess),
|
||||||
|
// close for good — otherwise the lightbox would pop back open when the
|
||||||
|
// photo returns to ready.
|
||||||
|
useEffect(() => {
|
||||||
|
if (lightboxId && lightboxIndex < 0) setLightboxId(null)
|
||||||
|
}, [lightboxId, lightboxIndex])
|
||||||
|
|
||||||
if (error) return <p className="error">{error}</p>
|
if (error) return <p className="error">{error}</p>
|
||||||
if (!detail) return <p className="muted">Loading…</p>
|
if (!detail) return <p className="muted">Loading…</p>
|
||||||
@@ -445,23 +379,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 () => {
|
|
||||||
if (!confirm(`Delete ${selected.size} selected photo${selected.size === 1 ? '' : 's'}? This cannot be undone.`))
|
|
||||||
return
|
|
||||||
try {
|
|
||||||
await api('/api/photos/delete', { method: 'POST', body: { ids: [...selected] } })
|
|
||||||
clear()
|
|
||||||
} catch (e) {
|
|
||||||
alert(`Delete failed: ${e.message}`)
|
|
||||||
}
|
|
||||||
load()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
@@ -514,20 +436,15 @@ export default function AlbumPage() {
|
|||||||
|
|
||||||
<Gallery
|
<Gallery
|
||||||
photos={ready}
|
photos={ready}
|
||||||
onOpen={lightbox.openAt}
|
onOpen={(i) => setLightboxId(ready[i].id)}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
onToggleSelect={toggle}
|
onToggleSelect={toggle}
|
||||||
overlay={(p) => {
|
overlay={(p) => {
|
||||||
const avg = avgRating(p.id)
|
const avg = avgRating(p.id)
|
||||||
const tagCount = feedback[p.id]?.tags.length || 0
|
const tagCount = feedback[p.id]?.tags.length || 0
|
||||||
const verdicts = feedback[p.id]?.verdicts || []
|
if (avg === null && tagCount === 0) return null
|
||||||
const accepts = verdicts.filter((v) => v.verdict === 'accept').length
|
|
||||||
const rejects = verdicts.length - accepts
|
|
||||||
if (avg === null && tagCount === 0 && accepts === 0 && rejects === 0) return null
|
|
||||||
return (
|
return (
|
||||||
<div className="g-overlay">
|
<div className="g-overlay">
|
||||||
{accepts > 0 && <span>👍 {accepts}</span>}
|
|
||||||
{rejects > 0 && <span>👎 {rejects}</span>}
|
|
||||||
{avg !== null && <span>★ {avg.toFixed(1)}</span>}
|
{avg !== null && <span>★ {avg.toFixed(1)}</span>}
|
||||||
{tagCount > 0 && <span># {tagCount}</span>}
|
{tagCount > 0 && <span># {tagCount}</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -543,35 +460,26 @@ export default function AlbumPage() {
|
|||||||
total={ready.length}
|
total={ready.length}
|
||||||
selectedBytes={selectedBytes}
|
selectedBytes={selectedBytes}
|
||||||
totalBytes={totalBytes}
|
totalBytes={totalBytes}
|
||||||
onSelectAll={() => selectAll(ready)}
|
onSelectAll={selectAll}
|
||||||
onClear={clear}
|
onClear={clear}
|
||||||
onDownload={() => postDownload(`/api/albums/${id}/zip`, [...selected].join(','))}
|
onDownload={() => postDownload(`/api/albums/${id}/zip`, [...selected].join(','))}
|
||||||
onDownloadAll={() => postDownload(`/api/albums/${id}/zip`)}
|
onDownloadAll={() => postDownload(`/api/albums/${id}/zip`)}
|
||||||
onDelete={removeSelected}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{lightbox.index >= 0 && (
|
{lightboxIndex >= 0 && (
|
||||||
<Lightbox
|
<Lightbox
|
||||||
photos={ready}
|
photos={ready}
|
||||||
index={lightbox.index}
|
index={lightboxIndex}
|
||||||
onClose={lightbox.close}
|
onClose={() => setLightboxId(null)}
|
||||||
onNav={lightbox.openAt}
|
onNav={(i) => setLightboxId(ready[i].id)}
|
||||||
actions={[
|
|
||||||
{ keys: ['s'], help: ['S', 'select for download'], run: (p) => toggle(p.id) },
|
|
||||||
]}
|
|
||||||
footer={(p) => {
|
footer={(p) => {
|
||||||
const fb = 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="feedback">
|
<div className="feedback">
|
||||||
{fb.ratings.length === 0 && fb.verdicts.length === 0 && fb.tags.length === 0 && (
|
{fb.ratings.length === 0 && fb.tags.length === 0 && (
|
||||||
<span className="muted">No client feedback yet</span>
|
<span className="muted">No client feedback yet</span>
|
||||||
)}
|
)}
|
||||||
{fb.verdicts.map((v, i) => (
|
|
||||||
<span key={`v${i}`} className="feedback-item">
|
|
||||||
{v.share_label || 'client'}: <Thumbs value={v.verdict} small />
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{fb.ratings.map((r, i) => (
|
{fb.ratings.map((r, i) => (
|
||||||
<span key={`r${i}`} className="feedback-item">
|
<span key={`r${i}`} className="feedback-item">
|
||||||
{r.share_label || 'client'}: <Stars value={r.rating} small />
|
{r.share_label || 'client'}: <Stars value={r.rating} small />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, 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,43 +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 from '../components/Thumbs'
|
|
||||||
import useLightbox from '../useLightbox'
|
|
||||||
import useSelection from '../useSelection'
|
import useSelection from '../useSelection'
|
||||||
|
|
||||||
const FILTERS = [
|
|
||||||
{ key: 'all', label: 'All' },
|
|
||||||
{ key: 'accept', label: '👍' },
|
|
||||||
{ key: 'reject', label: '👎' },
|
|
||||||
{ key: 'undecided', label: 'Undecided' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const matchesFilter = (photo, key) =>
|
|
||||||
key === 'all' || (key === 'undecided' ? !photo.my_verdict : photo.my_verdict === key)
|
|
||||||
|
|
||||||
export default function SharePage() {
|
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)),
|
||||||
@@ -70,75 +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 })
|
|
||||||
|
|
||||||
// Keyboard votes navigate away from the photo they change, so a transient
|
const setTags = async (photo, tags) => {
|
||||||
// toast names what just happened to it — without it the jump reads as
|
patchPhoto(photo.id, { my_tags: tags })
|
||||||
// "did that register?".
|
try {
|
||||||
const [flash, setFlash] = useState(null)
|
await api(`/api/share/${token}/photos/${photo.id}/tags`, {
|
||||||
const flashSeq = useRef(0)
|
method: 'PUT',
|
||||||
const flashTimer = useRef()
|
body: { tags },
|
||||||
const showFlash = (text) => {
|
})
|
||||||
flashSeq.current += 1
|
} catch {
|
||||||
setFlash({ text, key: flashSeq.current })
|
load()
|
||||||
clearTimeout(flashTimer.current)
|
}
|
||||||
flashTimer.current = setTimeout(() => setFlash(null), 1400)
|
|
||||||
}
|
}
|
||||||
useEffect(() => () => clearTimeout(flashTimer.current), [])
|
|
||||||
|
|
||||||
// Culling flow: vote, then advance to the photo that was next in the
|
|
||||||
// current view. Under a filter that hides the voted photo, the advance
|
|
||||||
// target stays visible, so the run continues seamlessly.
|
|
||||||
const voteAndAdvance = (photo, verdict) => {
|
|
||||||
const next = visible[lightbox.index + 1]
|
|
||||||
setVerdict(photo, verdict)
|
|
||||||
showFlash(
|
|
||||||
verdict === 'accept'
|
|
||||||
? `👍 ${photo.filename}`
|
|
||||||
: verdict === 'reject'
|
|
||||||
? `👎 ${photo.filename}`
|
|
||||||
: `↺ ${photo.filename} cleared`,
|
|
||||||
)
|
|
||||||
if (next) lightbox.show(next.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyActions = [
|
|
||||||
{ keys: ['p'], help: ['P', 'accept and go to next'], run: (p) => voteAndAdvance(p, 'accept') },
|
|
||||||
{ keys: ['x'], help: ['X', 'reject and go to next'], run: (p) => voteAndAdvance(p, 'reject') },
|
|
||||||
{
|
|
||||||
keys: ['u'],
|
|
||||||
help: ['U', 'clear accept / reject'],
|
|
||||||
// When clearing hides the photo from the current filter, advance like
|
|
||||||
// a vote so the lightbox doesn't just close.
|
|
||||||
run: (p) =>
|
|
||||||
matchesFilter({ my_verdict: null }, filter)
|
|
||||||
? setVerdict(p, null)
|
|
||||||
: voteAndAdvance(p, null),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: ['1', '2', '3', '4', '5'],
|
|
||||||
help: ['1–5', 'star rating'],
|
|
||||||
run: (p, key) => setRating(p, Number(key)),
|
|
||||||
},
|
|
||||||
{ keys: ['0'], help: ['0', 'clear star rating'], run: (p) => setRating(p, 0) },
|
|
||||||
...(view?.allow_download
|
|
||||||
? [{ keys: ['s'], help: ['S', 'select for download'], run: (p) => toggle(p.id) }]
|
|
||||||
: []),
|
|
||||||
]
|
|
||||||
|
|
||||||
if (error) return <div className="center-page">{error}</div>
|
if (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>
|
||||||
@@ -171,80 +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'} · click a photo to view, rate and
|
{view.photos.length} photo{view.photos.length === 1 ? '' : 's'} · click a photo to view,
|
||||||
tag · press <kbd>?</kbd> in the viewer for 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
|
|
||||||
}
|
|
||||||
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>★ {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={visible}
|
photos={view.photos}
|
||||||
index={lightbox.index}
|
index={lightbox}
|
||||||
onClose={lightbox.close}
|
onClose={() => setLightbox(-1)}
|
||||||
onNav={lightbox.openAt}
|
onNav={setLightbox}
|
||||||
actions={keyActions}
|
|
||||||
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 && (
|
||||||
@@ -266,11 +164,6 @@ export default function SharePage() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{flash && (
|
|
||||||
<div key={flash.key} className="action-flash">
|
|
||||||
{flash.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -534,130 +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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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;
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
// Lightbox state tracked by photo id, not index — the list can reorder
|
|
||||||
// (polling refetch) or shrink (filter change, delete) underneath an open
|
|
||||||
// lightbox. When the open photo leaves the list, close for good — otherwise
|
|
||||||
// the lightbox would pop back open when the photo returns to the list.
|
|
||||||
export default function useLightbox(photos) {
|
|
||||||
const [openId, setOpenId] = useState(null)
|
|
||||||
const index = openId ? photos.findIndex((p) => p.id === openId) : -1
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (openId && index < 0) setOpenId(null)
|
|
||||||
}, [openId, index])
|
|
||||||
|
|
||||||
return {
|
|
||||||
index,
|
|
||||||
openAt: (i) => setOpenId(photos[i].id),
|
|
||||||
show: (id) => setOpenId(id),
|
|
||||||
close: () => 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,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
-20
@@ -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,25 +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",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum JobKind {
|
pub enum JobKind {
|
||||||
ProcessPhoto,
|
ProcessPhoto,
|
||||||
|
|||||||
+46
-78
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::error::{ApiError, ApiResult};
|
use crate::error::{ApiError, ApiResult};
|
||||||
use crate::models::{Album, Photo, PhotoStatus};
|
use crate::models::{Album, JobKind, Photo, PhotoStatus};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
#[derive(Serialize, sqlx::FromRow)]
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
@@ -79,40 +79,12 @@ pub struct ShareTag {
|
|||||||
pub tag: String,
|
pub tag: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct ShareVerdict {
|
|
||||||
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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn feedback_rows<T>(
|
|
||||||
db: &sqlx::PgPool,
|
|
||||||
sql: &str,
|
|
||||||
album_id: Uuid,
|
|
||||||
) -> Result<Vec<(Uuid, String, T)>, sqlx::Error>
|
|
||||||
where
|
|
||||||
(Uuid, String, T): for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow> + Send + Unpin,
|
|
||||||
{
|
|
||||||
sqlx::query_as(sql).bind(album_id).fetch_all(db).await
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fold_feedback<T>(
|
|
||||||
feedback: &mut HashMap<Uuid, PhotoFeedback>,
|
|
||||||
rows: Vec<(Uuid, String, T)>,
|
|
||||||
push: impl Fn(&mut PhotoFeedback, String, T),
|
|
||||||
) {
|
|
||||||
for (photo_id, share_label, value) in rows {
|
|
||||||
push(feedback.entry(photo_id).or_default(), share_label, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct AlbumDetail {
|
pub struct AlbumDetail {
|
||||||
pub album: Album,
|
pub album: Album,
|
||||||
@@ -136,49 +108,41 @@ pub async fn get_one(
|
|||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// The three feedback kinds are independent (photo_id, share label, value)
|
|
||||||
// queries — run them concurrently and fold with one shared shape.
|
|
||||||
let (ratings, verdicts, tags) = tokio::try_join!(
|
|
||||||
feedback_rows::<i32>(
|
|
||||||
&state.db,
|
|
||||||
"select r.photo_id, s.label, r.rating
|
|
||||||
from ratings r join shares s on s.id = r.share_id
|
|
||||||
where s.album_id = $1",
|
|
||||||
album_id,
|
|
||||||
),
|
|
||||||
feedback_rows::<String>(
|
|
||||||
&state.db,
|
|
||||||
"select v.photo_id, s.label, v.verdict
|
|
||||||
from verdicts v join shares s on s.id = v.share_id
|
|
||||||
where s.album_id = $1",
|
|
||||||
album_id,
|
|
||||||
),
|
|
||||||
feedback_rows::<String>(
|
|
||||||
&state.db,
|
|
||||||
"select t.photo_id, s.label, t.tag
|
|
||||||
from tags t join shares s on s.id = t.share_id
|
|
||||||
where s.album_id = $1
|
|
||||||
order by t.created_at",
|
|
||||||
album_id,
|
|
||||||
),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let mut feedback: HashMap<Uuid, PhotoFeedback> = HashMap::new();
|
let mut feedback: HashMap<Uuid, PhotoFeedback> = HashMap::new();
|
||||||
fold_feedback(&mut feedback, ratings, |f, 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_label,
|
from ratings r join shares s on s.id = r.share_id
|
||||||
rating,
|
where s.album_id = $1",
|
||||||
})
|
)
|
||||||
});
|
.bind(album_id)
|
||||||
fold_feedback(&mut feedback, verdicts, |f, share_label, verdict| {
|
.fetch_all(&state.db)
|
||||||
f.verdicts.push(ShareVerdict {
|
.await?;
|
||||||
share_label,
|
for (photo_id, share_label, rating) in ratings {
|
||||||
verdict,
|
feedback
|
||||||
})
|
.entry(photo_id)
|
||||||
});
|
.or_default()
|
||||||
fold_feedback(&mut feedback, tags, |f, share_label, tag| {
|
.ratings
|
||||||
f.tags.push(ShareTag { share_label, tag })
|
.push(ShareRating {
|
||||||
});
|
share_label,
|
||||||
|
rating,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let tags: Vec<(Uuid, String, String)> = sqlx::query_as(
|
||||||
|
"select t.photo_id, s.label, t.tag
|
||||||
|
from tags t join shares s on s.id = t.share_id
|
||||||
|
where s.album_id = $1
|
||||||
|
order by t.created_at",
|
||||||
|
)
|
||||||
|
.bind(album_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
for (photo_id, share_label, tag) in tags {
|
||||||
|
feedback
|
||||||
|
.entry(photo_id)
|
||||||
|
.or_default()
|
||||||
|
.tags
|
||||||
|
.push(ShareTag { share_label, tag });
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(AlbumDetail {
|
Ok(Json(AlbumDetail {
|
||||||
album,
|
album,
|
||||||
@@ -232,13 +196,17 @@ pub async fn delete(
|
|||||||
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>,
|
||||||
|
|||||||
+1
-9
@@ -78,13 +78,9 @@ pub fn router(state: &AppState) -> Router<AppState> {
|
|||||||
)
|
)
|
||||||
.route("/api/albums/{id}/zip", post(zip::album_zip))
|
.route("/api/albums/{id}/zip", post(zip::album_zip))
|
||||||
.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}/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(),
|
||||||
@@ -104,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),
|
||||||
|
|||||||
+11
-52
@@ -189,69 +189,28 @@ 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>,
|
||||||
Path(photo_id): Path<Uuid>,
|
Path(photo_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?;
|
||||||
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 })))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn reprocess(
|
pub async fn reprocess(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(photo_id): Path<Uuid>,
|
Path(photo_id): Path<Uuid>,
|
||||||
|
|||||||
+9
-63
@@ -23,8 +23,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 +38,20 @@ 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>,
|
||||||
Path(album_id): Path<Uuid>,
|
Path(album_id): Path<Uuid>,
|
||||||
) -> ApiResult<Json<Vec<serde_json::Value>>> {
|
) -> ApiResult<Json<Vec<serde_json::Value>>> {
|
||||||
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)
|
||||||
@@ -131,53 +119,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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user