2 Commits
Author SHA1 Message Date
nils cb79da89fb Owner feedback, server-side aggregation, grid keyboard culling, XMP sources
ci / docker (push) Successful in 11m52s
- photographer verdict/rating on photos (migration 0005), PUT endpoints
  returning the fresh per-photo aggregate; FeedbackAggregate in models is
  the single home of the merge policy (max rating, accept beats reject,
  owner counts), used by album detail, XMP export and vote responses
- feedback rows carry share_id; per-client filter pills (incl. 'you')
  scope filters/counts/overlays by id — labels are display-only
- XMP export modal with per-source checkboxes (?shares=…&own=…), share
  validation, id dedup, and basename merging so RAW+JPEG pairs share one
  sidecar instead of losing feedback to an unmatchable name
- grid keyboard culling: arrow cursor (clamped, outline after first use),
  Space opens / closes the viewer, Enter/S toggle select, P/X/U and star
  keys act on the cursor photo; keyboard votes never auto-navigate
- lightbox freezes the visible list while open, so voting a photo out of
  the active filter no longer closes the viewer mid-run
- perf: memoized per-scope derivation map, content-visibility on grid
  cells, lightweight /pending poll decoupled from vote patches, aggregate
  recompute in one SQL statement, out-of-order response guard
- structure: AlbumPage split into components (Modal, UploadZone +
  ActivityOverlay with failed-count, SharesPanel, XmpModal), shared
  useEscape with typing guard, expiry year guard in endOfDayIso
2026-07-18 01:15:15 +02:00
nils 46dd8df55c Album filters: star threshold as interactive stars control
ci / docker (push) Successful in 17s
Replaces the three fixed star chips with the Stars component as a
min-average selector, combinable with the verdict chips
2026-07-17 18:59:26 +02:00
20 changed files with 2017 additions and 774 deletions
+4 -4
View File
@@ -54,7 +54,7 @@ The lightbox — accept/reject, star rating, tags, and keyboard-driven culling
revokes access immediately. Clients use unguessable share tokens, optionally
gated by an argon2-hashed password (10 wrong guesses lock the link for
15 minutes).
- **Frontend**: React + Vite SPA — justified gallery, lightbox with
- **Frontend**: React + Vite SPA — Lightroom-style photo grid (fixed cells, no crop), lightbox with
accept/reject thumbs, rating stars and tag chips, keyboard-driven culling
(`P`/`X`/`U`, `1``5`, `?` shows all shortcuts), drag-and-drop multi-file
upload with progress. Touch-first on mobile: swipe sideways to browse,
@@ -112,8 +112,8 @@ The prebuilt image is at `git.draic.info/nils/photos` (single image contains
`server`, `worker`, and the built frontend). To build your own instead:
```sh
docker build -t registry.example.com/you/photos:0.4.2 .
docker push registry.example.com/you/photos:0.4.2
docker build -t registry.example.com/you/photos:0.5.0 .
docker push registry.example.com/you/photos:0.5.0
```
Install the chart, pointing it at your existing Postgres and S3:
@@ -121,7 +121,7 @@ Install the chart, pointing it at your existing Postgres and S3:
```sh
helm install photos deploy/chart \
--set image.repository=git.draic.info/nils/photos \
--set image.tag=0.4.2 \
--set image.tag=0.5.0 \
--set publicUrl=https://photos.example.com \
--set ingress.host=photos.example.com \
--set config.oidcIssuer=https://auth.example.com \
+112 -71
View File
@@ -1,85 +1,126 @@
import { useEffect, useRef, useState } from 'react'
import { imgUrl } from '../api'
import { isTypingTarget } from '../useEscape'
// True justified layout: pack photos greedily into rows at their real aspect
// ratios, then scale each row's height so it fills the container width
// exactly. No cropping, no stretch, and the last row simply renders at the
// target height instead of being padded by a spacer.
function layoutRows(photos, containerWidth, targetHeight, gap) {
const rows = []
let row = []
let arSum = 0
let index = 0
for (const photo of photos) {
const ar = photo.width && photo.height ? photo.width / photo.height : 1.5
row.push({ photo, ar, index: index++ })
arSum += ar
const gaps = (row.length - 1) * gap
if (arSum * targetHeight + gaps >= containerWidth) {
rows.push({ items: row, height: (containerWidth - gaps) / arSum })
row = []
arSum = 0
}
}
if (row.length > 0) {
const gaps = (row.length - 1) * gap
rows.push({
items: row,
height: Math.min(targetHeight, (containerWidth - gaps) / arSum),
})
}
return rows
}
export default function Gallery({ photos, onOpen, overlay, selected, onToggleSelect }) {
// Lightroom-style grid: fixed square cells, photos fully visible via
// object-fit: contain. Cell size depends only on the container width, so a
// photo renders identically no matter which other photos are in view.
//
// Keyboard (while `keyboard` is true, i.e. no lightbox/modal open): arrow
// keys move a cursor through the grid, Space opens the viewer on it,
// Enter toggles selection, and the page's culling `actions` (P/X/U, stars,
// S) run against the cursor photo — same table as in the lightbox.
// `externalIndex` mirrors the lightbox position into the cursor, so closing
// the viewer continues where the culling run ended.
export default function Gallery({
photos,
onOpen,
overlay,
selected,
onToggleSelect,
keyboard = false,
externalIndex = -1,
actions,
}) {
const selecting = selected && selected.size > 0
const containerRef = useRef(null)
const [width, setWidth] = useState(0)
const [cursor, setCursor] = useState(-1)
// The outline only shows once the keyboard (or the viewer) was actually
// used — a fresh page must not present a photo as pre-marked.
const [cursorVisible, setCursorVisible] = useState(false)
const live = useRef({})
live.current = { cursor, photos, onOpen, onToggleSelect, actions }
useEffect(() => {
const observer = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width))
observer.observe(containerRef.current)
return () => observer.disconnect()
}, [])
if (externalIndex >= 0) {
setCursor(externalIndex)
setCursorVisible(true)
}
}, [externalIndex])
// The container renders even with zero photos — unmounting it would detach
// the observer and leave a later non-empty render stuck at width 0.
const gap = 6
const targetHeight = width < 700 ? 170 : 240
const rows = width > 0 ? layoutRows(photos, width, targetHeight, gap) : []
const selecting = selected && selected.size > 0
useEffect(() => {
if (keyboard && cursor < 0 && photos.length > 0) setCursor(0)
}, [keyboard, cursor, photos.length])
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.cursor >= 0 ? s.photos[s.cursor] : null
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, Math.max(0, s.cursor + 1))
else if (e.key === 'ArrowLeft') next = Math.max(0, s.cursor - 1)
else if (e.key === 'ArrowDown') next = s.cursor < 0 ? 0 : Math.min(count - 1, s.cursor + columns)
else if (e.key === 'ArrowUp') next = s.cursor < 0 ? 0 : Math.max(0, s.cursor - columns)
else if (e.key === ' ') {
if (photo) {
e.preventDefault()
s.onOpen?.(s.cursor)
}
return
} else if (e.key === 'Enter') {
if (photo && s.onToggleSelect) {
e.preventDefault()
s.onToggleSelect(photo.id)
}
return
} else {
const key = e.key.toLowerCase()
const action = s.actions?.find((a) => a.keys.includes(key))
if (action && photo) action.run(photo, key)
return
}
e.preventDefault()
setCursor(next)
setCursorVisible(true)
el?.children[next]?.scrollIntoView({ block: 'nearest' })
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [keyboard])
return (
<div ref={containerRef} className={`gallery${selecting ? ' selecting' : ''}`}>
{rows.map((row) => (
<div key={row.items[0].photo.id} className="g-row" style={{ height: row.height }}>
{row.items.map(({ photo: p, ar, index }) => {
const isSelected = selected ? selected.has(p.id) : false
return (
<div
key={p.id}
className={`g-item${isSelected ? ' selected' : ''}`}
style={{ width: ar * row.height }}
onClick={() => onOpen && onOpen(index)}
{photos.map((p, index) => {
const isSelected = selected ? selected.has(p.id) : false
return (
<div
key={p.id}
className={`g-item${isSelected ? ' selected' : ''}${cursorVisible && index === cursor ? ' focused' : ''}`}
onClick={() => onOpen && onOpen(index)}
>
<img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} />
{onToggleSelect && (
<button
className="g-check"
title={isSelected ? 'Deselect' : 'Select'}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(p.id, e.shiftKey)
}}
>
<img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} />
{onToggleSelect && (
<button
className="g-check"
title={isSelected ? 'Deselect' : 'Select'}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(p.id, e.shiftKey)
}}
>
</button>
)}
{overlay && overlay(p)}
</div>
)
})}
</div>
))}
</button>
)}
{overlay && overlay(p)}
</div>
)
})}
</div>
)
}
+16 -20
View File
@@ -1,12 +1,12 @@
import { useEffect, useRef, useState } from 'react'
import { imgUrl } from '../api'
import { isTypingTarget } from '../useEscape'
import useSwipe from '../useSwipe'
const BASE_SHORTCUTS = [
['← / →', 'previous / next photo'],
['Space', 'next photo (Shift+Space back)'],
['?', 'show / hide shortcuts'],
['Esc', 'close'],
['Space / Esc', 'close'],
]
// `actions` defines the page's shortcuts as one table — display and dispatch
@@ -40,16 +40,10 @@ export default function Lightbox({
live.current = { index, count: photos.length, photo, onClose, onNav, actions, showHelp, closing }
useEffect(() => {
// Only text entry captures keys (Escape leaves the field); focus on a
// checkbox or button must not disable lightbox navigation.
const isTyping = (el) =>
el.tagName === 'TEXTAREA' ||
el.isContentEditable ||
(el.tagName === 'INPUT' && !['checkbox', 'radio', 'button'].includes(el.type))
const handler = (e) => {
const s = live.current
if (s.closing) return
if (isTyping(e.target)) {
if (isTypingTarget(e.target)) {
if (e.key === 'Escape') e.target.blur()
return
}
@@ -65,12 +59,17 @@ export default function Lightbox({
}
// 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)) {
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.key === ' ' && e.shiftKey)) {
if (e.key === 'ArrowLeft') {
e.preventDefault()
if (s.index > 0) s.onNav(s.index - 1)
return
@@ -115,7 +114,8 @@ export default function Lightbox({
if (!photo) return null
// Did the vote fly off the photo we're still showing (no advance target)?
const exitSelf = swipe.exit && swipe.exit.photo.id === photo.id
const exit = swipe.exit
const exitSelf = exit && exit.photo.id === photo.id
return (
<div
@@ -166,7 +166,7 @@ export default function Lightbox({
<div key={photo.id} className="lb-slide" style={{ zIndex: 1 }}>
<img
ref={swipe.imgRef}
className={`lb-img${swipe.exit ? (exitSelf ? ' lb-hidden' : ' lb-enter') : ''}`}
className={`lb-img${exit ? (exitSelf ? ' lb-hidden' : ' lb-enter') : ''}`}
style={swipe.imgStyle}
onTransitionEnd={swipe.onImgTransitionEnd}
src={preview(photo)}
@@ -190,17 +190,13 @@ export default function Lightbox({
</div>
))}
</div>
{swipe.exit && (
{exit && (
<div
className="lb-slide lb-ghost"
style={{ '--dy': `${swipe.exit.dy}px`, '--rot': `${swipe.exit.dy / 40}deg` }}
style={{ '--dy': `${exit.dy}px`, '--rot': `${exit.dy / 40}deg` }}
onAnimationEnd={swipe.clearExit}
>
<img
className={`lb-img lb-exit-${swipe.exit.dir}`}
src={preview(swipe.exit.photo)}
alt=""
/>
<img className={`lb-img lb-exit-${exit.dir}`} src={preview(exit.photo)} alt="" />
</div>
)}
{swipe.showBadge && <div ref={swipe.badgeRef} className="lb-flick" />}
+19
View File
@@ -0,0 +1,19 @@
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>
)
}
+282
View File
@@ -0,0 +1,282 @@
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>
)
}
+306
View File
@@ -0,0 +1,306 @@
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>
)
}
+80
View File
@@ -0,0 +1,80 @@
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>
)
}
+279 -488
View File
@@ -1,404 +1,38 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, postDownload, sha256Hex, uploadFile } from '../api'
import { api, postDownload } from '../api'
import Gallery from '../components/Gallery'
import Lightbox from '../components/Lightbox'
import SelectionBar, { fmtBytes } from '../components/SelectionBar'
import Modal from '../components/Modal'
import SelectionBar from '../components/SelectionBar'
import SharesPanel from '../components/SharesPanel'
import Stars from '../components/Stars'
import Thumbs from '../components/Thumbs'
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'
function fmtEta(seconds) {
if (!isFinite(seconds) || seconds < 0) return ''
if (seconds < 60) return `${Math.ceil(seconds)}s`
if (seconds < 3600) return `${Math.ceil(seconds / 60)} min`
return `${Math.floor(seconds / 3600)}h ${Math.ceil((seconds % 3600) / 60)} min`
}
const UPLOAD_CONCURRENCY = 3
const FEEDBACK_FILTERS = [
{ key: 'all', label: 'All' },
{ key: 'accept', label: '👍' },
{ key: 'reject', label: '👎' },
{ key: 'accept', label: ACCEPT_GLYPH },
{ key: 'reject', label: REJECT_GLYPH },
{ key: 'undecided', label: 'Undecided' },
{ key: 'star3', label: '★ 3+' },
{ key: 'star4', label: '★ 4+' },
{ key: 'star5', label: '★ 5' },
]
// 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))
// Feedback regrouped per client link for the lightbox footer — the
// meaningful reading unit is "what did this person say".
const clientFeedback = (fb) => {
const map = new Map()
const entry = (id, label) => {
if (!map.has(id)) map.set(id, { label: label || 'client', tags: [] })
return map.get(id)
}
return (
<label className="row field-label" title="Expiry date — empty means the link never expires">
<span className="muted">expires</span>
<input
type="date"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') e.target.blur()
}}
/>
{draft ? (
<button
type="button"
className="btn btn-ghost"
title="Remove expiry — link never expires"
onClick={() => {
setDraft('')
onCommit(null)
}}
>
</button>
) : (
<span className="muted">never</span>
)}
</label>
)
}
function UploadZone({ albumId, onUploaded }) {
const [queue, setQueue] = useState([])
const [dragging, setDragging] = useState(false)
const [speed, setSpeed] = useState(0)
const inputRef = useRef(null)
const running = useRef(0)
const pending = useRef([])
const lastRefresh = useRef(0)
const loadedRef = useRef(0)
const totalBytes = queue.reduce((sum, item) => sum + item.file.size, 0)
const loadedBytes = queue.reduce(
(sum, item) =>
sum + (item.status === 'done' ? item.file.size : (item.progress || 0) * item.file.size),
0,
)
loadedRef.current = loadedBytes
const active = queue.some((item) =>
['uploading', 'queued', 'checking'].includes(item.status),
)
// Sample throughput once a second (EMA-smoothed) while uploads run.
useEffect(() => {
if (!active) {
setSpeed(0)
return
}
let last = { loaded: loadedRef.current, time: Date.now() }
const timer = setInterval(() => {
const now = Date.now()
const instant = (loadedRef.current - last.loaded) / ((now - last.time) / 1000)
last = { loaded: loadedRef.current, time: now }
setSpeed((prev) => (prev > 0 ? prev * 0.7 + instant * 0.3 : instant))
}, 1000)
return () => clearInterval(timer)
}, [active])
// Refresh the album at most every 5s during a bulk upload (the processing
// poll keeps it fresh anyway), plus once when the queue drains.
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 = (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()
}
return (
<div
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,
expires_at: endOfDayIso(form.expires_at),
},
})
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)
}
const update = async (shareId, patch) => {
try {
const updated = await api(`/api/shares/${shareId}`, { method: 'PATCH', body: patch })
setShares((list) => list.map((s) => (s.id === shareId ? updated : s)))
setError(null)
} catch (e) {
setError(e.message)
// Re-sync the controlled inputs with what the server actually has.
load()
}
}
return (
<section className="panel">
<h2>Client links</h2>
{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.rating_count} ratings, {s.tag_count} tags, 👍 {s.accept_count} 👎{' '}
{s.reject_count}
</span>
</div>
<div className="row">
<label className="select-toggle" title="Allow this link to download originals/ZIPs">
<input
type="checkbox"
checked={s.allow_download}
onChange={(e) => update(s.id, { allow_download: e.target.checked })}
/>
downloads
</label>
<ExpiryDate value={s.expires_at} onCommit={(iso) => update(s.id, { expires_at: iso })} />
{s.locked && (
<button
className="btn"
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>
)
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
for (const t of fb.tags) entry(t.share_id, t.share_label).tags.push(t.tag)
return [...map.entries()]
}
export default function AlbumPage() {
@@ -415,61 +49,184 @@ export default function AlbumPage() {
load()
}, [load])
// Poll while any photo is still being processed by the workers.
const hasPending = detail?.photos.some((p) => p.status === 'uploaded' || p.status === 'processing')
// While workers are busy, poll the lightweight pending endpoint and only
// refetch the full album when something actually changed. The signature
// 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(() => {
if (!hasPending) return
const t = setInterval(load, 4000)
const t = setInterval(async () => {
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)
}, [hasPending, load])
}, [hasPending, id, load])
const ready = (detail?.photos ?? []).filter((p) => p.status === 'ready')
const ready = useMemo(
() => (detail?.photos ?? []).filter((p) => p.status === 'ready'),
[detail],
)
const feedback = detail?.feedback ?? {}
const aggregates = detail?.aggregates ?? {}
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
}
// Whose feedback the filters and overlays look at: null = everyone
// (clients + own, server-aggregated), 'own', or a share id.
const [client, setClient] = useState(null)
// Filter over client feedback: verdicts, or minimum average star rating.
const [filter, setFilter] = useState('all')
const matchesFilter = (p, key) => {
if (key === 'all') return true
const verdicts = feedback[p.id]?.verdicts || []
if (key === 'accept') return verdicts.some((v) => v.verdict === 'accept')
if (key === 'reject') return verdicts.some((v) => v.verdict === 'reject')
if (key === 'undecided') return verdicts.length === 0
return (avgRating(p.id) ?? 0) >= Number(key.slice(4))
}
const shown = ready.filter((p) => matchesFilter(p, filter))
const filterCounts = useMemo(() => {
const counts = { all: ready.length, accept: 0, reject: 0, undecided: 0, star3: 0, star4: 0, star5: 0 }
// 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 verdicts = feedback[p.id]?.verdicts || []
if (verdicts.some((v) => v.verdict === 'accept')) counts.accept += 1
if (verdicts.some((v) => v.verdict === 'reject')) counts.reject += 1
if (verdicts.length === 0) counts.undecided += 1
const avg = avgRating(p.id) ?? 0
if (avg >= 3) counts.star3 += 1
if (avg >= 4) counts.star4 += 1
if (avg >= 5) counts.star5 += 1
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])
// Selection spans all ready photos; the bar and bulk actions cover only
// the current view, like on the share page.
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(() => {
if (client && client !== 'own' && !clientTotals.some(([id]) => id === client)) setClient(null)
}, [client, clientTotals])
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 (!detail) return <p className="muted">Loading</p>
const { album, photos } = detail
const notReady = photos.filter((p) => p.status !== 'ready')
const rename = async () => {
const name = prompt('Album name', album.name)
@@ -515,6 +272,19 @@ export default function AlbumPage() {
{album.name}
</h1>
<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}>
Rename
</button>
@@ -524,35 +294,35 @@ export default function AlbumPage() {
</div>
</div>
<UploadZone albumId={id} onUploaded={load} />
{notReady.length > 0 && (
<section className="panel">
<h2>Processing</h2>
<ul className="pending-list">
{notReady.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={() => api(`/api/photos/${p.id}/reprocess`, { method: 'POST' }).then(load)}
>
Retry
</button>
<button className="btn btn-danger" onClick={() => removePhoto(p.id)}>
Delete
</button>
</span>
) : (
<span className="muted">{p.status}</span>
)}
</li>
))}
</ul>
</section>
{strip.length > 0 && (
<div className="feedback-strip">
{strip.map(([key, t]) => (
<button
key={key}
className={`client-fb client-fb-btn${client === key ? ' active' : ''}`}
title={
client === key
? 'Show everyones 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 && (
@@ -566,6 +336,13 @@ export default function AlbumPage() {
{f.label} {filterCounts[f.key]}
</button>
))}
<span
className={`filter-stars${minStars > 0 ? ' active' : ''}`}
title="Minimum top rating in the selected scope — click a star to filter, click it again to clear"
>
<Stars value={minStars} onChange={(n) => setMinStars(n)} small />
{minStars > 0 && <span className="muted"> {minStars}</span>}
</span>
</div>
)}
@@ -574,25 +351,25 @@ export default function AlbumPage() {
onOpen={lightbox.openAt}
selected={selected}
onToggleSelect={(photoId, shift) => toggle(photoId, shift, shown)}
keyboard={lightbox.index < 0 && !linksOpen && !xmpOpen}
externalIndex={lightbox.index}
actions={culling.keyActions}
overlay={(p) => {
const avg = avgRating(p.id)
const tagCount = feedback[p.id]?.tags.length || 0
const verdicts = feedback[p.id]?.verdicts || []
const accepts = verdicts.filter((v) => v.verdict === 'accept').length
const rejects = verdicts.length - accepts
if (avg === null && tagCount === 0 && accepts === 0 && rejects === 0) return null
const s = scoped.get(p.id)
if (!s || (s.top === null && s.tags === 0 && s.accepts === 0 && s.rejects === 0))
return null
return (
<div className="g-overlay">
{accepts > 0 && <span>👍 {accepts}</span>}
{rejects > 0 && <span>👎 {rejects}</span>}
{avg !== null && <span> {avg.toFixed(1)}</span>}
{tagCount > 0 && <span># {tagCount}</span>}
{s.accepts > 0 && <span>👍 {s.accepts}</span>}
{s.rejects > 0 && <span>👎 {s.rejects}</span>}
{s.top !== null && <span>{'★'.repeat(s.top)}</span>}
{s.tags > 0 && <span># {s.tags}</span>}
</div>
)
}}
/>
{ready.length === 0 && notReady.length === 0 && (
<p className="muted">No photos yet drop some above.</p>
<p className="muted">No photos yet use Upload or drop files anywhere on the page.</p>
)}
{ready.length > 0 && shown.length === 0 && (
<p className="muted">No photos match this filter.</p>
@@ -616,38 +393,20 @@ export default function AlbumPage() {
{lightbox.index >= 0 && (
<Lightbox
photos={shown}
photos={lightbox.view}
index={lightbox.index}
onClose={lightbox.close}
onNav={lightbox.openAt}
actions={[
{ keys: ['s'], help: ['S', 'select for download'], run: (p) => toggle(p.id) },
]}
{...culling.lightboxProps}
footer={(p) => {
const fb = feedback[p.id] || { ratings: [], verdicts: [], tags: [] }
const groups = clientFeedback(feedback[p.id] || { ratings: [], verdicts: [], tags: [] })
return (
<div className="admin-footer">
<div className="feedback">
{fb.ratings.length === 0 && fb.verdicts.length === 0 && fb.tags.length === 0 && (
<span className="muted">No client feedback yet</span>
)}
{fb.verdicts.map((v, i) => (
<span key={`v${i}`} className="feedback-item">
{v.share_label || 'client'}: <Thumbs value={v.verdict} small />
</span>
))}
{fb.ratings.map((r, i) => (
<span key={`r${i}`} className="feedback-item">
{r.share_label || 'client'}: <Stars value={r.rating} small />
</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">
<div className="admin-actions">
<span className="own-feedback">
<Thumbs value={p.owner_verdict} onChange={(v) => setOwnerVerdict(p, v)} />
<Stars value={p.owner_rating || 0} onChange={(r) => setOwnerRating(p, r)} />
</span>
<label className="select-toggle">
<input
type="checkbox"
@@ -663,13 +422,45 @@ export default function AlbumPage() {
Delete
</button>
</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>
)
}}
/>
)}
<SharesPanel albumId={id} />
{linksOpen && (
<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>
)}
</>
)
}
+22 -80
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom'
import { api, postDownload } from '../api'
import Gallery from '../components/Gallery'
@@ -6,14 +6,15 @@ import Lightbox from '../components/Lightbox'
import SelectionBar from '../components/SelectionBar'
import Stars from '../components/Stars'
import TagEditor from '../components/TagEditor'
import Thumbs from '../components/Thumbs'
import Thumbs, { ACCEPT_GLYPH, REJECT_GLYPH } from '../components/Thumbs'
import useCulling from '../useCulling'
import useLightbox from '../useLightbox'
import useSelection from '../useSelection'
const FILTERS = [
{ key: 'all', label: 'All' },
{ key: 'accept', label: '👍' },
{ key: 'reject', label: '👎' },
{ key: 'accept', label: ACCEPT_GLYPH },
{ key: 'reject', label: REJECT_GLYPH },
{ key: 'undecided', label: 'Undecided' },
]
@@ -43,12 +44,6 @@ export default function SharePage() {
const visibleSelected = visible.filter((p) => selected.has(p.id))
const sumBytes = (list) => list.reduce((sum, p) => sum + p.size_bytes, 0)
const lightbox = useLightbox(visible)
// Voting the last photo ends the run: fade the modal out over the gallery
// instead of cutting hard.
const [lightboxFading, setLightboxFading] = useState(false)
useEffect(() => {
if (lightbox.index < 0) setLightboxFading(false)
}, [lightbox.index])
const load = useCallback(
() => api(`/api/share/${token}`).then(setView).catch((e) => setError(e.message)),
@@ -92,61 +87,14 @@ export default function SharePage() {
saveFeedback(photo, { my_verdict: verdict }, 'verdict', { verdict })
const setTags = (photo, tags) => saveFeedback(photo, { my_tags: tags }, 'tags', { tags })
// Keyboard votes navigate away from the photo they change, so a transient
// toast names what just happened to it — without it the jump reads as
// "did that register?".
const [flash, setFlash] = useState(null)
const flashSeq = useRef(0)
const flashTimer = useRef()
const showFlash = (text) => {
flashSeq.current += 1
setFlash({ text, key: flashSeq.current })
clearTimeout(flashTimer.current)
flashTimer.current = setTimeout(() => setFlash(null), 1400)
}
useEffect(() => () => clearTimeout(flashTimer.current), [])
// Culling flow: vote, then advance to the photo that was next in the
// current view. Under a filter that hides the voted photo, the advance
// target stays visible, so the run continues seamlessly.
const voteAndAdvance = (photo, verdict) => {
const next = visible[lightbox.index + 1]
setVerdict(photo, verdict)
showFlash(
verdict === 'accept'
? `👍 ${photo.filename}`
: verdict === 'reject'
? `👎 ${photo.filename}`
: `${photo.filename} cleared`,
)
if (next) lightbox.show(next.id)
// Last photo voted: the run is done — fade back to the gallery.
else setLightboxFading(true)
}
const 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: ['15', '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) }]
: []),
]
const culling = useCulling({
visible: lightbox.view,
lightbox,
setVerdict,
setRating,
toggle,
canSelect: !!view?.allow_download,
})
if (error) return <div className="center-page">{error}</div>
if (!view) return <div className="center-page">Loading</div>
@@ -206,11 +154,14 @@ export default function SharePage() {
onToggleSelect={
view.allow_download ? (id, shift) => toggle(id, shift, visible) : undefined
}
keyboard={lightbox.index < 0}
externalIndex={lightbox.index}
actions={culling.keyActions}
overlay={(p) =>
p.my_verdict || p.my_rating || p.my_tags.length > 0 ? (
<div className="g-overlay">
{p.my_verdict && <span>{p.my_verdict === 'accept' ? '👍' : '👎'}</span>}
{p.my_rating && <span> {p.my_rating}</span>}
{p.my_rating && <span>{'★'.repeat(p.my_rating)}</span>}
{p.my_tags.length > 0 && <span># {p.my_tags.length}</span>}
</div>
) : null
@@ -245,20 +196,11 @@ export default function SharePage() {
)}
{lightbox.index >= 0 && (
<Lightbox
photos={visible}
photos={lightbox.view}
index={lightbox.index}
onClose={lightbox.close}
onNav={lightbox.openAt}
actions={keyActions}
closing={lightboxFading}
onClosed={() => {
setLightboxFading(false)
lightbox.close()
}}
gestures={{
up: (p) => voteAndAdvance(p, 'accept'),
down: (p) => voteAndAdvance(p, 'reject'),
}}
{...culling.lightboxProps}
footer={(p) => (
<div className="client-footer">
<Thumbs value={p.my_verdict} onChange={(v) => setVerdict(p, v)} />
@@ -283,9 +225,9 @@ export default function SharePage() {
)}
/>
)}
{flash && (
<div key={flash.key} className="action-flash">
{flash.text}
{culling.flash && (
<div key={culling.flash.key} className="action-flash">
{culling.flash.text}
</div>
)}
</>
+273 -61
View File
@@ -10,9 +10,16 @@
--muted: #9a978f;
--accent: #d9a441;
--danger: #e5645a;
--accept: #7ac97a;
--radius: 8px;
}
html {
/* Reserve the scrollbar gutter — content width must not shift when a
filter change toggles the scrollbar. */
scrollbar-gutter: stable;
}
body {
margin: 0;
background: var(--bg);
@@ -105,6 +112,11 @@ input {
input:focus {
outline: 1px solid var(--accent);
}
input[type='checkbox'] {
accent-color: var(--accent);
width: 16px;
height: 16px;
}
.btn {
display: inline-block;
background: var(--panel-2);
@@ -158,10 +170,9 @@ input:focus {
border-radius: var(--radius);
overflow: hidden;
text-decoration: none;
transition: transform 0.1s;
}
.album-card:hover {
transform: translateY(-2px);
.album-card:hover .album-cover img {
filter: brightness(1.08);
}
.album-cover {
aspect-ratio: 3 / 2;
@@ -188,41 +199,41 @@ input:focus {
gap: 0.5rem;
}
/* justified gallery */
/* photo grid (Lightroom-style fixed cells) */
.gallery {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 6px;
margin: 1rem 0;
}
.g-row {
display: flex;
gap: 6px;
}
.g-item {
position: relative;
flex: none;
aspect-ratio: 1 / 1;
border-radius: 4px;
overflow: hidden;
cursor: pointer;
background: var(--panel);
content-visibility: auto;
contain-intrinsic-size: 300px;
}
.g-item img {
width: 100%;
height: 100%;
object-fit: cover;
padding: 8px;
object-fit: contain;
display: block;
}
.g-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
bottom: 6px;
left: 6px;
display: inline-flex;
align-items: center;
gap: 0.6rem;
padding: 0.35rem 0.55rem;
padding: 0.2rem 0.55rem;
font-size: 0.8rem;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.75));
background: rgba(0, 0, 0, 0.55);
border-radius: 6px;
color: #ffd97a;
}
@@ -257,6 +268,10 @@ input:focus {
outline: 3px solid var(--accent);
outline-offset: -3px;
}
.g-item.focused {
outline: 2px solid var(--text);
outline-offset: -2px;
}
.select-bar {
position: fixed;
bottom: 1.25rem;
@@ -284,41 +299,8 @@ input:focus {
color: var(--muted);
cursor: pointer;
}
.select-toggle input {
accent-color: var(--accent);
width: 16px;
height: 16px;
}
/* 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 */
.upload-list {
list-style: none;
margin: 1rem 0 0;
@@ -349,6 +331,121 @@ progress {
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 */
.panel {
background: var(--panel);
@@ -387,14 +484,72 @@ progress {
.share-info .muted {
font-size: 0.82rem;
}
.share-form {
.share-stats {
display: inline-flex;
gap: 0.3rem;
}
.share-feedback {
margin-top: 0.35rem;
}
.feedback-strip {
display: flex;
flex-wrap: wrap;
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;
margin-top: 1rem;
border-top: 1px solid var(--panel-2);
padding-top: 0.9rem;
}
.share-form label {
.share-form-title {
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;
color: var(--muted);
}
@@ -574,14 +729,49 @@ progress {
.lb-footer {
padding: 0.7rem 1rem 1rem;
}
.client-footer,
.admin-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 {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
}
.admin-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 1.25rem;
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 {
display: flex;
align-items: center;
@@ -589,10 +779,10 @@ progress {
flex-wrap: wrap;
font-size: 0.85rem;
}
.feedback-item {
.own-feedback {
display: inline-flex;
align-items: center;
gap: 0.3rem;
gap: 0.5rem;
}
/* stars */
@@ -675,6 +865,22 @@ progress {
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 {
@@ -793,6 +999,9 @@ kbd {
}
@media (max-width: 700px) {
.gallery {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.lb-slide {
padding: 0 0.5rem;
}
@@ -835,10 +1044,13 @@ kbd {
row-gap: 0.5rem;
}
.share-form {
flex-direction: column;
align-items: stretch;
grid-template-columns: 1fr;
}
.share-form > input {
.share-form .field {
display: block;
}
.share-form .field > input {
width: 100%;
margin-top: 0.2rem;
}
}
+86
View File
@@ -0,0 +1,86 @@
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: ['15', '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()
},
},
}
}
+22
View File
@@ -0,0 +1,22 @@
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])
}
+29 -12
View File
@@ -1,23 +1,40 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useRef, 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.
// 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 index = openId ? photos.findIndex((p) => p.id === openId) : -1
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) setOpenId(null)
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,
// Bounds-safe: callers may hold a stale index (the list can shrink
// between render and dispatch).
openAt: (i) => photos[i] && setOpenId(photos[i].id),
show: (id) => setOpenId(id),
close: () => setOpenId(null),
view,
openAt: (i) => view[i] && open(view[i].id),
show: (id) => open(id),
close: () => {
frozen.current = null
setOpenId(null)
},
}
}
+4
View File
@@ -0,0 +1,4 @@
-- 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'));
+58
View File
@@ -66,6 +66,62 @@ impl Verdict {
}
}
/// 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)]
pub enum JobKind {
ProcessPhoto,
@@ -134,6 +190,8 @@ pub struct Photo {
pub height: Option<i32>,
pub taken_at: Option<DateTime<Utc>>,
pub processed_at: Option<DateTime<Utc>>,
pub owner_rating: Option<i32>,
pub owner_verdict: Option<String>,
#[serde(skip_serializing)]
pub sha256: Option<String>,
#[serde(skip_serializing)]
+86 -36
View File
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::models::{Album, Photo, PhotoStatus};
use crate::models::{Album, FeedbackAggregate, Photo, PhotoStatus};
use crate::state::AppState;
#[derive(Serialize, sqlx::FromRow)]
@@ -69,18 +69,21 @@ pub async fn create(
#[derive(Serialize)]
pub struct ShareRating {
pub share_id: Uuid,
pub share_label: String,
pub rating: i32,
}
#[derive(Serialize)]
pub struct ShareTag {
pub share_id: Uuid,
pub share_label: String,
pub tag: String,
}
#[derive(Serialize)]
pub struct ShareVerdict {
pub share_id: Uuid,
pub share_label: String,
pub verdict: String,
}
@@ -92,24 +95,42 @@ pub struct PhotoFeedback {
pub tags: Vec<ShareTag>,
}
async fn feedback_rows<T>(
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,
) -> Result<Vec<(Uuid, String, T)>, sqlx::Error>
shares: Option<&[Uuid]>,
) -> Result<Vec<(Uuid, Uuid, String, T)>, sqlx::Error>
where
(Uuid, String, T): for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow> + Send + Unpin,
(Uuid, Uuid, String, T): for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow> + Send + Unpin,
{
sqlx::query_as(sql).bind(album_id).fetch_all(db).await
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, String, T)>,
push: impl Fn(&mut PhotoFeedback, String, T),
rows: Vec<(Uuid, Uuid, String, T)>,
push: impl Fn(&mut PhotoFeedback, Uuid, String, T),
) {
for (photo_id, share_label, value) in rows {
push(feedback.entry(photo_id).or_default(), share_label, value);
for (photo_id, share_id, share_label, value) in rows {
push(feedback.entry(photo_id).or_default(), share_id, share_label, value);
}
}
@@ -118,6 +139,7 @@ pub struct AlbumDetail {
pub album: Album,
pub photos: Vec<Photo>,
pub feedback: HashMap<Uuid, PhotoFeedback>,
pub aggregates: HashMap<Uuid, FeedbackAggregate>,
}
pub async fn get_one(
@@ -136,57 +158,85 @@ pub async fn get_one(
.fetch_all(&state.db)
.await?;
// The three feedback kinds are independent (photo_id, share label, value)
// 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,
"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,
),
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();
fold_feedback(&mut feedback, ratings, |f, share_label, rating| {
fold_feedback(&mut feedback, ratings, |f, share_id, share_label, rating| {
f.ratings.push(ShareRating {
share_id,
share_label,
rating,
})
});
fold_feedback(&mut feedback, verdicts, |f, share_label, verdict| {
fold_feedback(&mut feedback, verdicts, |f, share_id, share_label, verdict| {
f.verdicts.push(ShareVerdict {
share_id,
share_label,
verdict,
})
});
fold_feedback(&mut feedback, tags, |f, share_label, tag| {
f.tags.push(ShareTag { share_label, tag })
fold_feedback(&mut feedback, tags, |f, share_id, share_label, tag| {
f.tags.push(ShareTag {
share_id,
share_label,
tag,
})
});
Ok(Json(AlbumDetail {
album,
photos,
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)]
pub struct UpdateAlbum {
name: Option<String>,
+5
View File
@@ -3,6 +3,7 @@ pub mod client;
pub mod images;
pub mod photos;
pub mod shares;
pub mod xmp;
pub mod zip;
use axum::extract::{DefaultBodyLimit, Request, State};
@@ -77,9 +78,13 @@ pub fn router(state: &AppState) -> Router<AppState> {
get(shares::list).post(shares::create),
)
.route("/api/albums/{id}/zip", post(zip::album_zip))
.route("/api/albums/{id}/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/photos/delete", post(photos::delete_many))
.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/shares/{id}",
+81 -1
View File
@@ -11,7 +11,7 @@ use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::jobs;
use crate::models::{JobKind, Photo, PhotoStatus};
use crate::models::{FeedbackAggregate, JobKind, Photo, PhotoStatus, Verdict};
use crate::s3;
use crate::state::AppState;
@@ -252,6 +252,86 @@ pub async fn delete_many(
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(
State(state): State<AppState>,
Path(photo_id): Path<Uuid>,
+252
View File
@@ -0,0 +1,252 @@
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
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(&central);
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
}
+1 -1
View File
@@ -122,7 +122,7 @@ fn unique_entry_name(used: &mut HashSet<String>, filename: &str) -> String {
/// MS-DOS timestamp (2-second resolution, no timezone; years 1980+ only —
/// callers clamp earlier dates).
fn dos_datetime(t: DateTime<Utc>) -> (u16, u16) {
pub(super) 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 date = (((t.year() - 1980) as u16) << 9) | ((t.month() as u16) << 5) | (t.day() as u16);
(time, date)