6 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
nils b5512aa7e5 Album view: filter by client feedback; mobile share-panel layout
ci / docker (push) Successful in 8m10s
- filter bar over verdicts and average star rating (All / thumbs /
  undecided / 3+ / 4+ / 5) with live counts; gallery, lightbox,
  selection, ZIP download and bulk delete all operate on the filtered
  view
- share rows and the create form stack vertically on small screens
2026-07-17 17:37:23 +02:00
nils 002e51f36e Static serving: correct cache headers, no index fallback for assets
ci / docker (push) Successful in 13m11s
- /assets/*: immutable one-year cache on hits, plain 404 on misses
  (previously a missing asset fell back to index.html served as its
  content type, breaking CSS/JS after every deploy for cached clients)
- index.html and SPA routes: no-cache, so deploys are visible immediately
2026-07-17 17:30:13 +02:00
nils 7167af8984 Mobile: real swipe physics, pinch zoom, touch-first lightbox
ci / docker (push) Successful in 17s
- swipe sideways to browse (track follows the finger, FLIP settle),
  flick up/down to vote Tinder-style with ghost fly-off and the next
  photo zooming in behind; votes and navigation commit synchronously on
  release — animations are pure decoration, so closing or keypresses
  mid-animation can't drop or duplicate an action
- two fingers pinch-zoom the photo (springs back, never votes)
- voting the last photo fades the modal out over the gallery
- gesture engine extracted to useSwipe: per-move motion via direct DOM
  writes (no React render per touchmove), named threshold constants
- touch devices: selection checkmarks always visible, nav arrows yield
  to swiping, finger-sized vote/rating targets, safe-area padding
2026-07-17 17:18:51 +02:00
nils 037b68fc9d Fix review findings on bulk delete, share editing, selection
ci / docker (push) Successful in 9s
- expiry editor commits on blur/Enter with explicit clear button — no more
  per-keystroke PATCHes transiently expiring live links
- shift-range walks the filtered view, anchors reset on clear, updaters
  kept pure (StrictMode-safe)
- bulk delete surfaces errors, 404s on zero deletions, and shares one
  delete+cleanup path with single and album delete (s3::photo_prefix is
  the only prefix source)
- expiry end-of-day convention extracted; share rows update from the
  PATCH response instead of reloading the list
- Gallery container always renders, restoring the simple observer effect
2026-07-17 16:42:28 +02:00
29 changed files with 2678 additions and 773 deletions
Generated
+1
View File
@@ -2574,6 +2574,7 @@ dependencies = [
"time",
"tokio",
"tokio-util",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
+2 -1
View File
@@ -37,7 +37,8 @@ tempfile = "3"
time = "0.3"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] }
tower-http = { version = "0.6", features = ["fs", "trace"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["fs", "set-header", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
urlencoding = "2"
+30 -9
View File
@@ -5,6 +5,23 @@ share them with clients via private (optionally password-protected) links,
collect accept/reject votes, ratings and tags, and let clients download
originals.
## Screenshots
The album view — drag-and-drop upload, justified gallery, and each client's
feedback (votes, stars, tags) overlaid on the thumbnails:
![Album view with upload zone and feedback overlays](docs/screenshots/album-admin.jpg)
What clients see on a share link — vote on favorites, filter by verdict,
download selects or the whole album as a ZIP:
![Client gallery with verdict filters](docs/screenshots/share-client.jpg)
The lightbox — accept/reject, star rating, tags, and keyboard-driven culling
(`P`/`X`/`U`, `1``5`):
![Lightbox with voting and rating controls](docs/screenshots/lightbox.jpg)
## Architecture
```
@@ -37,10 +54,13 @@ originals.
revokes access immediately. Clients use unguessable share tokens, optionally
gated by an argon2-hashed password (10 wrong guesses lock the link for
15 minutes).
- **Frontend**: React + Vite SPA — justified gallery, lightbox with
- **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.
upload with progress. Touch-first on mobile: swipe sideways to browse,
flick a photo up/down to accept/reject (real physics — votes commit
instantly, the animation is decoration), pinch to zoom, always-visible
selection checkmarks. Voting the last photo fades back to the gallery.
## Local development
@@ -88,20 +108,20 @@ All configuration is via environment variables:
## Deploying to Kubernetes
Build and push the image (single image contains `server`, `worker`, and the
built frontend):
The prebuilt image is at `git.draic.info/nils/photos` (single image contains
`server`, `worker`, and the built frontend). To build your own instead:
```sh
docker build -t ghcr.io/YOU/photos:0.1.0 .
docker push ghcr.io/YOU/photos:0.1.0
docker build -t registry.example.com/you/photos:0.5.0 .
docker push registry.example.com/you/photos:0.5.0
```
Install the chart, pointing it at your existing Postgres and S3:
```sh
helm install photos deploy/chart \
--set image.repository=ghcr.io/YOU/photos \
--set image.tag=0.1.0 \
--set image.repository=git.draic.info/nils/photos \
--set image.tag=0.5.0 \
--set publicUrl=https://photos.example.com \
--set ingress.host=photos.example.com \
--set config.oidcIssuer=https://auth.example.com \
@@ -142,7 +162,8 @@ Notes:
stored **per link**, so create one link per client to keep feedback
separate. The album view shows all feedback grouped by link label, and
clients can filter their gallery by verdict (e.g. review only what's still
undecided, or select-all + download the accepted set).
undecided, or select-all + download the accepted set). Culling works with
keyboard (`P`/`X` auto-advance) on desktop and swipe gestures on mobile.
- Clients (and you) can multi-select photos and download them — or the whole
album — as a ZIP. Archives are streamed (each file spools briefly through a
temp file for its checksum, then pipelines while the next one prefetches),
Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

+116 -80
View File
@@ -1,90 +1,126 @@
import { useCallback, useRef, useState } from 'react'
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 }) {
const [width, setWidth] = useState(0)
const observerRef = useRef(null)
// Callback ref instead of mount effect: the container div unmounts whenever
// the photo list is empty (e.g. a filter with no matches), so the observer
// must re-attach to each new element — a once-per-mount effect would leave
// the re-rendered gallery unobserved at width 0, rendering nothing.
const containerRef = useCallback((el) => {
observerRef.current?.disconnect()
observerRef.current = null
if (!el) return
observerRef.current = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width))
observerRef.current.observe(el)
}, [])
if (photos.length === 0) return null
const gap = 6
const targetHeight = width < 700 ? 170 : 240
const rows = width > 0 ? layoutRows(photos, width, targetHeight, gap) : []
// 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 [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(() => {
if (externalIndex >= 0) {
setCursor(externalIndex)
setCursorVisible(true)
}
}, [externalIndex])
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>
)
}
+110 -21
View File
@@ -1,18 +1,33 @@
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
// come from the same entry, so the help overlay can't drift from behavior:
// { keys: ['p'], help: ['P', 'accept…'], run: (photo, key) => … }.
// Keys fire only outside text inputs and while the help overlay is closed.
export default function Lightbox({ photos, index, onClose, onNav, footer, actions }) {
// `gestures` ({ up, down }) maps vertical touch flicks on the photo — the
// horizontal axis always navigates, so voting and browsing never collide.
// `closing` fades the whole modal out; `onClosed` fires when the fade ends
// (the page then actually unmounts the lightbox).
export default function Lightbox({
photos,
index,
onClose,
onNav,
footer,
actions,
gestures,
closing,
onClosed,
}) {
const photo = photos[index]
const [showHelp, setShowHelp] = useState(false)
@@ -22,18 +37,13 @@ export default function Lightbox({ photos, index, onClose, onNav, footer, action
// which a fast second keystroke hits a stale closure (and e.g. re-votes
// the previous photo).
const live = useRef({})
live.current = { index, count: photos.length, photo, onClose, onNav, actions, showHelp }
live.current = { index, count: photos.length, photo, onClose, onNav, actions, showHelp, closing }
useEffect(() => {
// Only text entry captures keys (Escape leaves the field); focus on a
// 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 (isTyping(e.target)) {
if (s.closing) return
if (isTypingTarget(e.target)) {
if (e.key === 'Escape') e.target.blur()
return
}
@@ -49,12 +59,17 @@ export default function Lightbox({ photos, index, onClose, onNav, footer, action
}
// 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
@@ -74,10 +89,42 @@ export default function Lightbox({ photos, index, onClose, onNav, footer, action
}
}, [])
const preview = (p) => imgUrl(p, 'preview')
const prevPhoto = photos[index - 1]
const nextPhoto = photos[index + 1]
const swipe = useSwipe({
photo,
index,
count: photos.length,
onNav,
gestures,
hasNext: index < photos.length - 1,
})
// Immediate neighbors are real DOM slides (loaded by definition); warm the
// browser cache a few photos further out so fast swiping never hits the
// network.
useEffect(() => {
for (const i of [index + 2, index + 3, index - 2]) {
if (photos[i]) new Image().src = preview(photos[i])
}
}, [index, photos])
if (!photo) return null
// Did the vote fly off the photo we're still showing (no advance target)?
const exit = swipe.exit
const exitSelf = exit && exit.photo.id === photo.id
return (
<div className="lightbox" onClick={onClose}>
<div
className={`lightbox${closing ? ' lb-closing' : ''}`}
onClick={onClose}
onAnimationEnd={(e) => {
if (e.animationName === 'lb-fade-out') onClosed?.()
}}
>
<div className="lb-top" onClick={(e) => e.stopPropagation()}>
<span className="lb-name">{photo.filename}</span>
<span className="lb-count">
@@ -104,13 +151,55 @@ export default function Lightbox({ photos, index, onClose, onNav, footer, action
>
</button>
<div className="lb-stage">
<img
className="lb-img"
src={imgUrl(photo, 'preview')}
alt={photo.filename}
onClick={(e) => e.stopPropagation()}
/>
<div className="lb-stage" {...swipe.handlers}>
<div
className="lb-track"
ref={swipe.trackRef}
style={swipe.trackStyle}
onTransitionEnd={swipe.onTrackTransitionEnd}
>
{prevPhoto && (
<div key={prevPhoto.id} className="lb-slide" style={{ transform: 'translateX(-100vw)' }}>
<img className="lb-img" src={preview(prevPhoto)} alt="" />
</div>
)}
<div key={photo.id} className="lb-slide" style={{ zIndex: 1 }}>
<img
ref={swipe.imgRef}
className={`lb-img${exit ? (exitSelf ? ' lb-hidden' : ' lb-enter') : ''}`}
style={swipe.imgStyle}
onTransitionEnd={swipe.onImgTransitionEnd}
src={preview(photo)}
alt={photo.filename}
onClick={(e) => e.stopPropagation()}
/>
</div>
{nextPhoto &&
(swipe.showBehind ? (
<div
key={nextPhoto.id}
ref={swipe.behindRef}
className="lb-slide lb-behind"
style={swipe.behindStyle}
>
<img className="lb-img" src={preview(nextPhoto)} alt="" />
</div>
) : (
<div key={nextPhoto.id} className="lb-slide" style={{ transform: 'translateX(100vw)' }}>
<img className="lb-img" src={preview(nextPhoto)} alt="" />
</div>
))}
</div>
{exit && (
<div
className="lb-slide lb-ghost"
style={{ '--dy': `${exit.dy}px`, '--rot': `${exit.dy / 40}deg` }}
onAnimationEnd={swipe.clearExit}
>
<img className={`lb-img lb-exit-${exit.dir}`} src={preview(exit.photo)} alt="" />
</div>
)}
{swipe.showBadge && <div ref={swipe.badgeRef} className="lb-flick" />}
</div>
<button
className="lb-nav lb-next"
+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>
)
}
+5 -2
View File
@@ -1,3 +1,6 @@
export const ACCEPT_GLYPH = '👍'
export const REJECT_GLYPH = '👎'
// Accept/reject vote. `value` is 'accept', 'reject' or null; clicking the
// active thumb clears it. Without onChange it renders read-only.
export default function Thumbs({ value, onChange, small }) {
@@ -17,8 +20,8 @@ export default function Thumbs({ value, onChange, small }) {
)
return (
<span className={`thumbs${small ? ' thumbs-small' : ''}`}>
{thumb('accept', '👍', 'Accept (P)')}
{thumb('reject', '👎', 'Reject (X)')}
{thumb('accept', ACCEPT_GLYPH, 'Accept (P)')}
{thumb('reject', REJECT_GLYPH, 'Reject (X)')}
</span>
)
}
+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>
)
}
+334 -436
View File
@@ -1,355 +1,38 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, postDownload, 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 FEEDBACK_FILTERS = [
{ key: 'all', label: 'All' },
{ key: 'accept', label: ACCEPT_GLYPH },
{ key: 'reject', label: REJECT_GLYPH },
{ key: 'undecided', label: 'Undecided' },
]
const UPLOAD_CONCURRENCY = 3
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()
// 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 (
<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,
// End of the chosen day in the photographer's local timezone —
// date-only strings would parse as UTC midnight and expire a day early.
expires_at: form.expires_at
? new Date(`${form.expires_at}T23:59:59`).toISOString()
: null,
},
})
setForm({ label: '', password: '', allow_download: true, expires_at: '' })
setError(null)
load()
} catch (e) {
setError(e.message)
}
}
const copy = async (share) => {
await navigator.clipboard.writeText(share.url)
setCopied(share.id)
setTimeout(() => setCopied(null), 1500)
}
const update = async (shareId, patch) => {
try {
await api(`/api/shares/${shareId}`, { method: 'PATCH', body: patch })
setError(null)
} catch (e) {
setError(e.message)
}
load()
}
// ISO timestamp -> local yyyy-mm-dd for the date input.
const localDate = (iso) => {
const d = new Date(iso)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
return (
<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>
<label className="row field-label" title="Expiry date — empty means the link never expires">
<span className="muted">expires</span>
<input
type="date"
value={s.expires_at ? localDate(s.expires_at) : ''}
onChange={(e) =>
update(s.id, {
// End of the chosen day in local time, like the create form.
expires_at: e.target.value
? new Date(`${e.target.value}T23:59:59`).toISOString()
: null,
})
}
/>
</label>
{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() {
@@ -366,29 +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 { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(ready)
const lightbox = useLightbox(ready)
const ready = useMemo(
() => (detail?.photos ?? []).filter((p) => p.status === 'ready'),
[detail],
)
const feedback = detail?.feedback ?? {}
const aggregates = detail?.aggregates ?? {}
// Whose feedback the filters and overlays look at: null = everyone
// (clients + own, server-aggregated), 'own', or a share id.
const [client, setClient] = useState(null)
// One pass per (album data, scope): everything downstream — filter,
// counts, overlay — reads this map instead of re-deriving per render.
const scoped = useMemo(() => {
const map = new Map()
for (const p of ready) {
const fb = feedback[p.id]
let top = null
let accepts = 0
let rejects = 0
let tags = 0
if (client === 'own') {
top = p.owner_rating ?? null
if (p.owner_verdict === 'accept') accepts = 1
if (p.owner_verdict === 'reject') rejects = 1
} else if (client) {
for (const r of fb?.ratings ?? [])
if (r.share_id === client) top = Math.max(top ?? 0, r.rating)
for (const v of fb?.verdicts ?? [])
if (v.share_id === client) v.verdict === 'accept' ? (accepts += 1) : (rejects += 1)
for (const t of fb?.tags ?? []) if (t.share_id === client) tags += 1
} else {
const agg = aggregates[p.id]
top = agg?.top_rating ?? null
accepts = agg?.accepts ?? 0
rejects = agg?.rejects ?? 0
tags = fb?.tags.length ?? 0
}
map.set(p.id, { top, accepts, rejects, tags })
}
return map
}, [detail, client])
const [filter, setFilter] = useState('all')
const [minStars, setMinStars] = useState(0)
const [linksOpen, setLinksOpen] = useState(false)
const [xmpOpen, setXmpOpen] = useState(false)
const matchesVerdict = (s, key) => {
if (key === 'all') return true
if (key === 'accept') return s.accepts > 0
if (key === 'reject') return s.rejects > 0
return s.accepts + s.rejects === 0
}
const shown = useMemo(
() =>
ready.filter((p) => {
const s = scoped.get(p.id)
return matchesVerdict(s, filter) && (minStars === 0 || (s.top ?? 0) >= minStars)
}),
[ready, scoped, filter, minStars],
)
const filterCounts = useMemo(() => {
const counts = { all: ready.length, accept: 0, reject: 0, undecided: 0 }
for (const p of ready) {
const s = scoped.get(p.id)
if (s.accepts > 0) counts.accept += 1
if (s.rejects > 0) counts.reject += 1
if (s.accepts + s.rejects === 0) counts.undecided += 1
}
return counts
}, [ready, scoped])
const clientTotals = useMemo(() => {
const totals = new Map()
const entry = (id, label) => {
if (!totals.has(id))
totals.set(id, { label: label || 'client', accepts: 0, rejects: 0, ratings: 0, tags: 0 })
return totals.get(id)
}
for (const fb of Object.values(feedback)) {
for (const r of fb.ratings) entry(r.share_id, r.share_label).ratings += 1
for (const v of fb.verdicts)
entry(v.share_id, v.share_label)[v.verdict === 'accept' ? 'accepts' : 'rejects'] += 1
for (const t of fb.tags) entry(t.share_id, t.share_label).tags += 1
}
return [...totals.entries()]
}, [detail])
const ownTotals = useMemo(() => {
const totals = { accepts: 0, rejects: 0, ratings: 0, tags: 0 }
for (const p of ready) {
if (p.owner_verdict === 'accept') totals.accepts += 1
if (p.owner_verdict === 'reject') totals.rejects += 1
if (p.owner_rating) totals.ratings += 1
}
return totals
}, [ready])
// The active scope's pill stays visible even at zero counts, so clearing
// your last vote can't strand the filter without a control.
const strip = [
['own', { label: 'you', ...ownTotals }],
...clientTotals,
].filter(([key, t]) => key === client || t.accepts + t.rejects + t.ratings + t.tags > 0)
// Reset only when the scoped share is gone entirely (deleted link).
useEffect(() => {
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, feedback } = detail
const notReady = photos.filter((p) => p.status !== 'ready')
const avgRating = (photoId) => {
const ratings = feedback[photoId]?.ratings || []
if (ratings.length === 0) return null
return ratings.reduce((sum, r) => sum + r.rating, 0) / ratings.length
}
const { album, photos } = detail
const rename = async () => {
const name = prompt('Album name', album.name)
@@ -412,10 +250,15 @@ export default function AlbumPage() {
}
const removeSelected = async () => {
if (!confirm(`Delete ${selected.size} selected photo${selected.size === 1 ? '' : 's'}? This cannot be undone.`))
const ids = shownSelected.map((p) => p.id)
if (!confirm(`Delete ${ids.length} selected photo${ids.length === 1 ? '' : 's'}? This cannot be undone.`))
return
await api('/api/photos/delete', { method: 'POST', body: { ids: [...selected] } })
clear()
try {
await api('/api/photos/delete', { method: 'POST', body: { ids } })
clear()
} catch (e) {
alert(`Delete failed: ${e.message}`)
}
load()
}
@@ -429,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>
@@ -438,109 +294,119 @@ export default function AlbumPage() {
</div>
</div>
<UploadZone albumId={id} onUploaded={load} />
{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>
)}
{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>
{ready.length > 0 && (
<div className="filter-bar filter-bar-admin">
{FEEDBACK_FILTERS.map((f) => (
<button
key={f.key}
className={`filter-chip${filter === f.key ? ' active' : ''}`}
onClick={() => setFilter(f.key)}
>
{f.label} {filterCounts[f.key]}
</button>
))}
<span
className={`filter-stars${minStars > 0 ? ' active' : ''}`}
title="Minimum 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>
)}
<Gallery
photos={ready}
photos={shown}
onOpen={lightbox.openAt}
selected={selected}
onToggleSelect={toggle}
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>
)}
<SelectionBar
count={selected.size}
total={ready.length}
selectedBytes={selectedBytes}
totalBytes={totalBytes}
onSelectAll={() => selectAll(ready)}
count={shownSelected.length}
total={shown.length}
selectedBytes={sumBytes(shownSelected)}
totalBytes={sumBytes(shown)}
onSelectAll={() => selectAll(shown)}
onClear={clear}
onDownload={() => postDownload(`/api/albums/${id}/zip`, [...selected].join(','))}
onDownloadAll={() => postDownload(`/api/albums/${id}/zip`)}
onDownload={() =>
postDownload(`/api/albums/${id}/zip`, shownSelected.map((p) => p.id).join(','))
}
onDownloadAll={() =>
postDownload(`/api/albums/${id}/zip`, filter === 'all' ? '' : shown.map((p) => p.id).join(','))
}
onDelete={removeSelected}
/>
{lightbox.index >= 0 && (
<Lightbox
photos={ready}
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"
@@ -556,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>
)}
</>
)
}
+29 -66
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' },
]
@@ -86,59 +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)
}
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>
@@ -171,8 +127,8 @@ export default function SharePage() {
<h1>{view.album_name}</h1>
{view.album_description && <p className="muted">{view.album_description}</p>}
<p className="muted">
{photos.length} photo{photos.length === 1 ? '' : 's'} · click a photo to view, rate and
tag · press <kbd>?</kbd> in the viewer for shortcuts
{photos.length} photo{photos.length === 1 ? '' : 's'} · tap a photo to view, rate and
tag · swipe to accept, to reject · <kbd>?</kbd> shows keyboard shortcuts
</p>
{photos.length > 0 && (
<div className="filter-bar">
@@ -193,12 +149,19 @@ export default function SharePage() {
photos={visible}
onOpen={lightbox.openAt}
selected={view.allow_download ? selected : undefined}
onToggleSelect={view.allow_download ? toggle : undefined}
// Shift-ranges must walk the filtered view, not the full album
// otherwise hidden photos get swept into the selection.
onToggleSelect={
view.allow_download ? (id, shift) => toggle(id, shift, visible) : undefined
}
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
@@ -233,11 +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}
{...culling.lightboxProps}
footer={(p) => (
<div className="client-footer">
<Thumbs value={p.my_verdict} onChange={(v) => setVerdict(p, v)} />
@@ -262,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>
)}
</>
+416 -59
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);
}
@@ -451,6 +606,18 @@ progress {
.lb-stage {
flex: 1;
min-height: 0;
position: relative;
overflow: hidden;
/* Swipes are handled in JS; stop the browser from panning/zooming. */
touch-action: none;
}
.lb-track {
position: absolute;
inset: 0;
}
.lb-slide {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
@@ -462,6 +629,79 @@ progress {
object-fit: contain;
cursor: default;
}
.lb-flick {
z-index: 2;
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 5rem;
opacity: 0;
pointer-events: none;
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.6);
}
/* Next photo waiting behind a vote drag, before its zoom-in. */
.lb-behind {
transform: scale(0.22);
opacity: 0;
}
/* Ghost of a just-voted photo flying off; the vote itself is already saved. */
.lb-ghost {
z-index: 3;
pointer-events: none;
}
.lb-exit-up {
animation: lb-exit-up 0.26s ease-in forwards;
}
.lb-exit-down {
animation: lb-exit-down 0.26s ease-in forwards;
}
@keyframes lb-exit-up {
from {
transform: translateY(var(--dy)) rotate(var(--rot));
}
to {
transform: translateY(-120vh) rotate(var(--rot));
opacity: 0;
}
}
@keyframes lb-exit-down {
from {
transform: translateY(var(--dy)) rotate(var(--rot));
}
to {
transform: translateY(120vh) rotate(var(--rot));
opacity: 0;
}
}
.lb-hidden {
opacity: 0;
}
/* Whole-modal fade after voting the last photo — the gallery is behind it. */
.lightbox.lb-closing {
animation: lb-fade-out 0.26s ease forwards;
pointer-events: none;
}
@keyframes lb-fade-out {
to {
opacity: 0;
}
}
/* The photo taking over after a vote zooms in organically. */
.lb-enter {
animation: lb-enter 0.26s ease-out;
}
@keyframes lb-enter {
from {
transform: scale(0.3);
opacity: 0.2;
}
to {
transform: scale(1);
opacity: 1;
}
}
.lb-nav {
position: absolute;
top: 50%;
@@ -489,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;
@@ -504,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 */
@@ -586,6 +861,26 @@ progress {
color: var(--text);
border-color: var(--accent);
}
.filter-bar-admin {
justify-content: flex-start;
margin: 0 0 0.75rem;
}
.filter-stars {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: var(--panel);
border: 1px solid var(--panel-2);
border-radius: 999px;
padding: 0.1rem 0.7rem;
font-size: 0.85rem;
}
.filter-stars.active {
border-color: var(--accent);
}
.filter-stars .star {
cursor: pointer;
}
/* transient action feedback (keyboard votes that navigate away) */
.action-flash {
@@ -688,12 +983,74 @@ kbd {
padding: 0;
}
/* Touch devices: no hover selection checkmarks must always be visible,
and the overlay nav arrows give way to swiping. */
@media (hover: none) {
.g-check {
opacity: 1;
}
.lb-nav {
display: none;
}
/* No visible arrows to keep clear of — give the photo the width. */
.lb-slide {
padding: 0 0.75rem;
}
}
@media (max-width: 700px) {
.lb-stage {
.gallery {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.lb-slide {
padding: 0 0.5rem;
}
.login-card {
padding: 2rem 1.5rem;
margin: 0 1rem;
}
.share-head {
padding: 1.25rem 0.75rem 0.25rem;
}
.lb-footer {
padding: 0.5rem 0.6rem calc(0.8rem + env(safe-area-inset-bottom));
}
.client-footer {
gap: 0.8rem;
}
/* Finger-sized vote and rating targets in the client lightbox. */
.client-footer .thumb {
font-size: 1.7rem;
}
.client-footer .star {
font-size: 1.8rem;
}
.select-bar {
flex-wrap: wrap;
justify-content: center;
max-width: calc(100vw - 1.5rem);
padding: 0.5rem 0.75rem;
bottom: calc(0.75rem + env(safe-area-inset-bottom));
}
/* Share rows: info and controls stack instead of fighting for one line. */
.share-row {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
padding: 0.75rem 0;
}
.share-row .row {
flex-wrap: wrap;
row-gap: 0.5rem;
}
.share-form {
grid-template-columns: 1fr;
}
.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 -10
View File
@@ -1,21 +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,
openAt: (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)
},
}
}
+25 -13
View File
@@ -18,30 +18,42 @@ export default function useSelection(photos) {
}, [photos])
// Shift-toggle selects the whole range from the previously toggled photo
// (both directions), so contiguous runs don't need per-photo clicks.
const toggle = (photoId, shift = false) =>
// (both directions), so contiguous runs don't need per-photo clicks. The
// range walks `list` — the photo order the user is actually looking at —
// which callers with a filtered view must pass explicitly, so hidden
// photos are never swept into the selection. Anchor updates and range
// computation stay out of the setSelected updater: React may re-invoke
// updaters (StrictMode does), so they must be pure.
const toggle = (photoId, shift = false, list = photos) => {
const anchor = lastToggled.current
lastToggled.current = photoId
if (shift && anchor) {
const a = list.findIndex((p) => p.id === anchor)
const b = list.findIndex((p) => p.id === photoId)
if (a >= 0 && b >= 0) {
const range = list.slice(Math.min(a, b), Math.max(a, b) + 1).map((p) => p.id)
setSelected((prev) => new Set([...prev, ...range]))
return
}
}
setSelected((prev) => {
const next = new Set(prev)
if (shift && lastToggled.current) {
const a = photos.findIndex((p) => p.id === lastToggled.current)
const b = photos.findIndex((p) => p.id === photoId)
if (a >= 0 && b >= 0) {
for (const p of photos.slice(Math.min(a, b), Math.max(a, b) + 1)) next.add(p.id)
lastToggled.current = photoId
return next
}
}
if (next.has(photoId)) next.delete(photoId)
else next.add(photoId)
lastToggled.current = photoId
return next
})
}
// Adds `list` (the caller's currently visible photos) to the selection —
// additive, so selecting all of one filtered view keeps picks from another.
const selectAll = (list) =>
setSelected((prev) => new Set([...prev, ...list.map((p) => p.id)]))
const clear = () => setSelected(new Set())
const clear = () => {
// Reset the anchor too — a shift-click after Clear must start fresh, not
// extend a range from a photo selected before the wipe.
lastToggled.current = null
setSelected(new Set())
}
const selectedBytes = photos.reduce((sum, p) => sum + (selected.has(p.id) ? p.size_bytes : 0), 0)
const totalBytes = photos.reduce((sum, p) => sum + p.size_bytes, 0)
+230
View File
@@ -0,0 +1,230 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { ACCEPT_GLYPH, REJECT_GLYPH } from './components/Thumbs'
const COMMIT_MS = 260
const CANCEL_MS = 180
const DEAD_ZONE = 12 // px of travel before the axis locks
const FLICK_MS = 250 // releases faster than this commit at the lower threshold
const H_THRESHOLD = { slow: 70, fast: 30 }
const V_THRESHOLD = { slow: 90, fast: 40 }
const V_SATURATE = 160 // px of vertical drag for full badge/preview intensity
const ZOOM_MAX = 4
// Touch engine for the lightbox: horizontal swipes navigate, vertical flicks
// dispatch gestures.up/down (when provided), two fingers pinch-zoom the
// current photo (springs back on release, never votes).
//
// Navigation and votes are dispatched SYNCHRONOUSLY on release; every
// animation afterwards is pure decoration (a ghost flying off, the track
// settling). Closing the lightbox, key presses or list changes during an
// animation therefore can't drop, duplicate or misdirect an action.
//
// Per-move motion is written straight to the DOM via refs — dragging costs
// no React renders; state changes happen only at axis lock and release.
export default function useSwipe({ photo, index, count, onNav, gestures, hasNext }) {
const [dragAxis, setDragAxis] = useState(null) // 'h' | 'v' | 'z' while a finger is down
const [spring, setSpring] = useState(null) // 'h' | 'v' | 'z': sub-threshold release gliding back
const [settleFrom, setSettleFrom] = useState(null) // px the track settles from after a nav commit
const [settleRun, setSettleRun] = useState(false)
const [exit, setExit] = useState(null) // { photo, dir, dy }: ghost flying off after a vote
const trackRef = useRef(null)
const imgRef = useRef(null)
const behindRef = useRef(null)
const badgeRef = useRef(null)
const g = useRef(null)
// FLIP: paint the track at the release offset around the NEW index first,
// force a style flush, then let it transition to center.
useLayoutEffect(() => {
if (settleFrom == null) {
setSettleRun(false)
return
}
trackRef.current?.getBoundingClientRect()
setSettleRun(true)
}, [settleFrom])
const clearManual = () => {
for (const ref of [trackRef, imgRef, behindRef, badgeRef]) {
if (ref.current) {
ref.current.style.transform = ''
ref.current.style.opacity = ''
ref.current.style.transition = ''
ref.current.style.transformOrigin = ''
}
}
}
const onTouchStart = (e) => {
// Fresh input takes over: decorative animations yield immediately.
setSpring(null)
setSettleFrom(null)
if (e.touches.length === 2 && imgRef.current) {
// Second finger at any point turns the gesture into a pinch — a pinch
// never navigates and never votes.
clearManual()
const [a, b] = e.touches
const rect = imgRef.current.getBoundingClientRect()
const cx = (a.clientX + b.clientX) / 2
const cy = (a.clientY + b.clientY) / 2
imgRef.current.style.transformOrigin = `${cx - rect.left}px ${cy - rect.top}px`
g.current = {
mode: 'pinch',
d0: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY),
cx,
cy,
}
setDragAxis('z')
return
}
if (e.touches.length !== 1) {
g.current = null
setDragAxis(null)
clearManual()
return
}
const t = e.touches[0]
g.current = { mode: 'swipe', x0: t.clientX, y0: t.clientY, t0: e.timeStamp, axis: null, dx: 0, dy: 0 }
}
const onTouchMove = (e) => {
const s = g.current
if (!s) return
if (s.mode === 'pinch') {
if (e.touches.length < 2 || !imgRef.current) return
const [a, b] = e.touches
const scale = Math.min(
ZOOM_MAX,
Math.max(1, Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) / s.d0),
)
const mx = (a.clientX + b.clientX) / 2 - s.cx
const my = (a.clientY + b.clientY) / 2 - s.cy
imgRef.current.style.transition = 'none'
imgRef.current.style.transform = `translate(${mx}px, ${my}px) scale(${scale})`
return
}
if (e.touches.length !== 1) return
const t = e.touches[0]
s.dx = t.clientX - s.x0
s.dy = t.clientY - s.y0
if (!s.axis) {
if (Math.hypot(s.dx, s.dy) < DEAD_ZONE) return
s.axis = Math.abs(s.dx) >= Math.abs(s.dy) || !gestures ? 'h' : 'v'
setDragAxis(s.axis)
}
if (s.axis === 'h') {
if (trackRef.current) {
trackRef.current.style.transition = 'none'
trackRef.current.style.transform = `translateX(${s.dx}px)`
}
} else {
if (imgRef.current) {
imgRef.current.style.transition = 'none'
imgRef.current.style.transform = `translateY(${s.dy}px) rotate(${s.dy / 40}deg)`
}
const p = Math.min(1, Math.abs(s.dy) / V_SATURATE)
if (behindRef.current) {
behindRef.current.style.transition = 'none'
behindRef.current.style.transform = `scale(${0.22 + 0.15 * p})`
behindRef.current.style.opacity = p
}
if (badgeRef.current) {
badgeRef.current.style.opacity = p
badgeRef.current.textContent = s.dy < 0 ? ACCEPT_GLYPH : REJECT_GLYPH
}
}
}
const onTouchEnd = (e) => {
const s = g.current
if (!s) return
if (s.mode === 'pinch') {
if (e.touches.length > 0) return
g.current = null
setDragAxis(null)
setSpring('z')
return
}
g.current = null
setDragAxis(null)
if (!s.axis) {
clearManual()
return
}
const quick = e.timeStamp - s.t0 < FLICK_MS
const past = (v, th) => Math.abs(v) > th.slow || (quick && Math.abs(v) > th.fast)
if (s.axis === 'h') {
if (s.dx < 0 && past(s.dx, H_THRESHOLD) && index < count - 1) {
onNav(index + 1)
setSettleFrom(s.dx + window.innerWidth)
} else if (s.dx > 0 && past(s.dx, H_THRESHOLD) && index > 0) {
onNav(index - 1)
setSettleFrom(s.dx - window.innerWidth)
} else {
setSpring('h')
}
} else if (past(s.dy, V_THRESHOLD)) {
const dir = s.dy < 0 ? 'up' : 'down'
gestures?.[dir]?.(photo)
clearManual()
setExit({ photo, dir, dy: s.dy })
} else {
setSpring('v')
}
}
const onTouchCancel = () => {
g.current = null
setDragAxis(null)
clearManual()
}
const trackStyle =
settleFrom != null
? settleRun
? { transform: 'translateX(0)', transition: `transform ${COMMIT_MS}ms ease-out` }
: { transform: `translateX(${settleFrom}px)`, transition: 'none' }
: spring === 'h'
? { transform: 'translateX(0)', transition: `transform ${CANCEL_MS}ms ease-out` }
: undefined
const imgStyle =
spring === 'v'
? { transform: 'translateY(0) rotate(0deg)', transition: `transform ${CANCEL_MS}ms ease-out` }
: spring === 'z'
? { transform: 'none', transition: `transform ${CANCEL_MS}ms ease-out` }
: undefined
const behindStyle =
spring === 'v'
? { transform: 'scale(0.22)', opacity: 0, transition: `all ${CANCEL_MS}ms ease-out` }
: undefined
return {
handlers: { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel },
trackRef,
imgRef,
behindRef,
badgeRef,
trackStyle,
imgStyle,
behindStyle,
showBehind: (dragAxis === 'v' || spring === 'v') && hasNext,
showBadge: dragAxis === 'v',
exit,
clearExit: () => setExit(null),
onTrackTransitionEnd: (e) => {
if (e.target !== trackRef.current) return
setSettleFrom(null)
if (spring === 'h') setSpring(null)
},
onImgTransitionEnd: (e) => {
if (e.target !== imgRef.current) return
if (spring === 'v' || spring === 'z') {
setSpring(null)
clearManual()
}
},
}
}
+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'));
+26 -3
View File
@@ -1,4 +1,8 @@
use axum::http::{header, HeaderValue};
use tower::ServiceBuilder;
use tower_http::services::fs::ServeFileSystemResponseBody;
use tower_http::services::{ServeDir, ServeFile};
use tower_http::set_header::SetResponseHeaderLayer;
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;
@@ -17,11 +21,30 @@ async fn main() -> anyhow::Result<()> {
let state = AppState::new(config).await?;
let static_dir = state.config.static_dir.clone();
let index = std::path::Path::new(&static_dir).join("index.html");
// .fallback (not .not_found_service) so SPA routes get index.html with a 200
let spa = ServeDir::new(&static_dir).fallback(ServeFile::new(index));
let static_root = std::path::Path::new(&static_dir);
// Hashed assets cache forever — except 404s, which would outlive the next deploy.
let assets = ServiceBuilder::new()
.map_response(|mut response: axum::http::Response<ServeFileSystemResponseBody>| {
if response.status().is_success() {
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
}
response
})
.service(ServeDir::new(static_root.join("assets")));
// index.html revalidates every load (it names the asset hashes);
// .fallback (not .not_found_service) so SPA routes get it with a 200.
let spa = ServiceBuilder::new()
.layer(SetResponseHeaderLayer::overriding(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
))
.service(ServeDir::new(static_root).fallback(ServeFile::new(static_root.join("index.html"))));
let app = photos::routes::router(&state)
.nest_service("/assets", assets)
.fallback_service(spa)
.layer(TraceLayer::new_for_http())
.with_state(state.clone());
+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)]
+93 -47
View File
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::models::{Album, JobKind, Photo, PhotoStatus};
use crate::models::{Album, 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>,
@@ -232,17 +282,13 @@ pub async fn delete(
if locked.is_none() {
return Err(ApiError::not_found());
}
// Delete photos and enqueue their S3 cleanup atomically, in one statement.
sqlx::query(
"with deleted as (delete from photos where album_id = $1 returning id)
insert into jobs (kind, payload)
select $2, jsonb_build_object('prefix', 'photos/' || id || '/')
from deleted",
)
.bind(album_id)
.bind(JobKind::DeleteS3Prefix.as_str())
.execute(&mut *tx)
.await?;
// Delete photos through the shared path so the S3 cleanup convention
// lives in one place; the album lock above keeps this set complete.
let photo_ids: Vec<Uuid> = sqlx::query_scalar("select id from photos where album_id = $1")
.bind(album_id)
.fetch_all(&mut *tx)
.await?;
super::photos::delete_with_cleanup(&mut tx, &photo_ids).await?;
sqlx::query("delete from albums where id = $1")
.bind(album_id)
.execute(&mut *tx)
+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}",
+117 -25
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;
@@ -189,24 +189,42 @@ pub async fn by_hash(
photo.map(Json).ok_or_else(ApiError::not_found)
}
/// The one deletion path: delete the given photos and enqueue their S3
/// cleanup jobs in the same transaction. Prefixes come from s3::photo_prefix
/// so the key layout has a single source of truth. Returns how many photos
/// were actually deleted.
pub(super) async fn delete_with_cleanup(
tx: &mut sqlx::PgConnection,
ids: &[Uuid],
) -> Result<u64, sqlx::Error> {
let deleted: Vec<Uuid> = sqlx::query_scalar("delete from photos where id = any($1) returning id")
.bind(ids)
.fetch_all(&mut *tx)
.await?;
if deleted.is_empty() {
return Ok(0);
}
let prefixes: Vec<String> = deleted.iter().map(|id| s3::photo_prefix(*id)).collect();
sqlx::query(
"insert into jobs (kind, payload)
select $1, jsonb_build_object('prefix', p)
from unnest($2::text[]) as p",
)
.bind(JobKind::DeleteS3Prefix.as_str())
.bind(&prefixes)
.execute(&mut *tx)
.await?;
Ok(deleted.len() as u64)
}
pub async fn delete(
State(state): State<AppState>,
Path(photo_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
let mut tx = state.db.begin().await?;
let deleted = sqlx::query("delete from photos where id = $1")
.bind(photo_id)
.execute(&mut *tx)
.await?;
if deleted.rows_affected() == 0 {
if delete_with_cleanup(&mut tx, &[photo_id]).await? == 0 {
return Err(ApiError::not_found());
}
jobs::enqueue(
&mut *tx,
JobKind::DeleteS3Prefix,
serde_json::json!({ "prefix": s3::photo_prefix(photo_id) }),
)
.await?;
tx.commit().await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
@@ -216,8 +234,6 @@ pub struct DeleteManyBody {
ids: Vec<Uuid>,
}
/// Bulk delete: photos vanish and their S3 cleanup jobs are enqueued in one
/// statement, same as album deletion.
pub async fn delete_many(
State(state): State<AppState>,
Json(body): Json<DeleteManyBody>,
@@ -226,18 +242,94 @@ pub async fn delete_many(
return Err(ApiError::bad_request("ids must not be empty"));
}
let mut tx = state.db.begin().await?;
let jobs = sqlx::query(
"with deleted as (delete from photos where id = any($1) returning id)
insert into jobs (kind, payload)
select $2, jsonb_build_object('prefix', 'photos/' || id || '/')
from deleted",
)
.bind(&body.ids)
.bind(JobKind::DeleteS3Prefix.as_str())
.execute(&mut *tx)
.await?;
let deleted = delete_with_cleanup(&mut tx, &body.ids).await?;
// Consistent with the single-photo route: deleting nothing is an error,
// not a silent success (e.g. a stale tab re-deleting already-gone photos).
if deleted == 0 {
return Err(ApiError::not_found());
}
tx.commit().await?;
Ok(Json(serde_json::json!({ "ok": true, "deleted": jobs.rows_affected() })))
Ok(Json(serde_json::json!({ "ok": true, "deleted": deleted })))
}
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(
+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)