diff --git a/README.md b/README.md index 65969bb..673107b 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ The lightbox — accept/reject, star rating, tags, and keyboard-driven culling revokes access immediately. Clients use unguessable share tokens, optionally gated by an argon2-hashed password (10 wrong guesses lock the link for 15 minutes). -- **Frontend**: React + Vite SPA — justified gallery, lightbox with +- **Frontend**: React + Vite SPA — Lightroom-style photo grid (fixed cells, no crop), lightbox with accept/reject thumbs, rating stars and tag chips, keyboard-driven culling (`P`/`X`/`U`, `1`–`5`, `?` shows all shortcuts), drag-and-drop multi-file upload with progress. Touch-first on mobile: swipe sideways to browse, @@ -112,8 +112,8 @@ The prebuilt image is at `git.draic.info/nils/photos` (single image contains `server`, `worker`, and the built frontend). To build your own instead: ```sh -docker build -t registry.example.com/you/photos:0.4.3 . -docker push registry.example.com/you/photos:0.4.3 +docker build -t registry.example.com/you/photos:0.5.0 . +docker push registry.example.com/you/photos:0.5.0 ``` Install the chart, pointing it at your existing Postgres and S3: @@ -121,7 +121,7 @@ Install the chart, pointing it at your existing Postgres and S3: ```sh helm install photos deploy/chart \ --set image.repository=git.draic.info/nils/photos \ - --set image.tag=0.4.3 \ + --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 \ diff --git a/frontend/src/components/Gallery.jsx b/frontend/src/components/Gallery.jsx index 9e08a9b..7ea5cc0 100644 --- a/frontend/src/components/Gallery.jsx +++ b/frontend/src/components/Gallery.jsx @@ -1,85 +1,126 @@ import { useEffect, useRef, useState } from 'react' import { imgUrl } from '../api' +import { isTypingTarget } from '../useEscape' -// True justified layout: pack photos greedily into rows at their real aspect -// ratios, then scale each row's height so it fills the container width -// exactly. No cropping, no stretch, and the last row simply renders at the -// target height instead of being padded by a spacer. -function layoutRows(photos, containerWidth, targetHeight, gap) { - const rows = [] - let row = [] - let arSum = 0 - let index = 0 - for (const photo of photos) { - const ar = photo.width && photo.height ? photo.width / photo.height : 1.5 - row.push({ photo, ar, index: index++ }) - arSum += ar - const gaps = (row.length - 1) * gap - if (arSum * targetHeight + gaps >= containerWidth) { - rows.push({ items: row, height: (containerWidth - gaps) / arSum }) - row = [] - arSum = 0 - } - } - if (row.length > 0) { - const gaps = (row.length - 1) * gap - rows.push({ - items: row, - height: Math.min(targetHeight, (containerWidth - gaps) / arSum), - }) - } - return rows -} - -export default function Gallery({ photos, onOpen, overlay, selected, onToggleSelect }) { +// Lightroom-style grid: fixed square cells, photos fully visible via +// object-fit: contain. Cell size depends only on the container width, so a +// photo renders identically no matter which other photos are in view. +// +// Keyboard (while `keyboard` is true, i.e. no lightbox/modal open): arrow +// keys move a cursor through the grid, Space opens the viewer on it, +// Enter toggles selection, and the page's culling `actions` (P/X/U, stars, +// S) run against the cursor photo — same table as in the lightbox. +// `externalIndex` mirrors the lightbox position into the cursor, so closing +// the viewer continues where the culling run ended. +export default function Gallery({ + photos, + onOpen, + overlay, + selected, + onToggleSelect, + keyboard = false, + externalIndex = -1, + actions, +}) { + const selecting = selected && selected.size > 0 const containerRef = useRef(null) - const [width, setWidth] = useState(0) + const [cursor, setCursor] = useState(-1) + // The outline only shows once the keyboard (or the viewer) was actually + // used — a fresh page must not present a photo as pre-marked. + const [cursorVisible, setCursorVisible] = useState(false) + const live = useRef({}) + live.current = { cursor, photos, onOpen, onToggleSelect, actions } useEffect(() => { - const observer = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width)) - observer.observe(containerRef.current) - return () => observer.disconnect() - }, []) + if (externalIndex >= 0) { + setCursor(externalIndex) + setCursorVisible(true) + } + }, [externalIndex]) - // The container renders even with zero photos — unmounting it would detach - // the observer and leave a later non-empty render stuck at width 0. - const gap = 6 - const targetHeight = width < 700 ? 170 : 240 - const rows = width > 0 ? layoutRows(photos, width, targetHeight, gap) : [] - const selecting = selected && selected.size > 0 + useEffect(() => { + if (keyboard && cursor < 0 && photos.length > 0) setCursor(0) + }, [keyboard, cursor, photos.length]) + + useEffect(() => { + if (cursor >= photos.length) setCursor(Math.max(0, photos.length - 1)) + }, [cursor, photos.length]) + + useEffect(() => { + if (!keyboard) return + const handler = (e) => { + const tag = e.target.tagName + if (isTypingTarget(e.target) || tag === 'SELECT') return + // Focused buttons/links keep their native Space/Enter; everything else + // still reaches the grid. + const clickable = ['BUTTON', 'A', 'LABEL', 'INPUT'].includes(tag) + if (clickable && (e.key === ' ' || e.key === 'Enter')) return + if (e.metaKey || e.ctrlKey || e.altKey) return + const s = live.current + if (s.photos.length === 0) return + const photo = s.cursor >= 0 ? s.photos[s.cursor] : null + const el = containerRef.current + const columns = el ? getComputedStyle(el).gridTemplateColumns.split(' ').length : 1 + const count = s.photos.length + let next = null + if (e.key === 'ArrowRight') next = Math.min(count - 1, Math.max(0, s.cursor + 1)) + else if (e.key === 'ArrowLeft') next = Math.max(0, s.cursor - 1) + else if (e.key === 'ArrowDown') next = s.cursor < 0 ? 0 : Math.min(count - 1, s.cursor + columns) + else if (e.key === 'ArrowUp') next = s.cursor < 0 ? 0 : Math.max(0, s.cursor - columns) + else if (e.key === ' ') { + if (photo) { + e.preventDefault() + s.onOpen?.(s.cursor) + } + return + } else if (e.key === 'Enter') { + if (photo && s.onToggleSelect) { + e.preventDefault() + s.onToggleSelect(photo.id) + } + return + } else { + const key = e.key.toLowerCase() + const action = s.actions?.find((a) => a.keys.includes(key)) + if (action && photo) action.run(photo, key) + return + } + e.preventDefault() + setCursor(next) + setCursorVisible(true) + el?.children[next]?.scrollIntoView({ block: 'nearest' }) + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [keyboard]) return (
- {rows.map((row) => ( -
- {row.items.map(({ photo: p, ar, index }) => { - const isSelected = selected ? selected.has(p.id) : false - return ( -
onOpen && onOpen(index)} + {photos.map((p, index) => { + const isSelected = selected ? selected.has(p.id) : false + return ( +
onOpen && onOpen(index)} + > + {p.filename} + {onToggleSelect && ( + - )} - {overlay && overlay(p)} -
- ) - })} -
- ))} + ✓ + + )} + {overlay && overlay(p)} +
+ ) + })}
) } diff --git a/frontend/src/components/Lightbox.jsx b/frontend/src/components/Lightbox.jsx index a442491..1421117 100644 --- a/frontend/src/components/Lightbox.jsx +++ b/frontend/src/components/Lightbox.jsx @@ -1,12 +1,12 @@ import { useEffect, useRef, useState } from 'react' import { imgUrl } from '../api' +import { isTypingTarget } from '../useEscape' import useSwipe from '../useSwipe' const BASE_SHORTCUTS = [ ['← / →', 'previous / next photo'], - ['Space', 'next photo (Shift+Space back)'], ['?', 'show / hide shortcuts'], - ['Esc', 'close'], + ['Space / Esc', 'close'], ] // `actions` defines the page's shortcuts as one table — display and dispatch @@ -40,16 +40,10 @@ export default function Lightbox({ live.current = { index, count: photos.length, photo, onClose, onNav, actions, showHelp, closing } useEffect(() => { - // Only text entry captures keys (Escape leaves the field); focus on a - // checkbox or button must not disable lightbox navigation. - const isTyping = (el) => - el.tagName === 'TEXTAREA' || - el.isContentEditable || - (el.tagName === 'INPUT' && !['checkbox', 'radio', 'button'].includes(el.type)) const handler = (e) => { const s = live.current if (s.closing) return - if (isTyping(e.target)) { + if (isTypingTarget(e.target)) { if (e.key === 'Escape') e.target.blur() return } @@ -65,12 +59,17 @@ export default function Lightbox({ } // With the help overlay up, keys must not act on the photo behind it. if (s.showHelp) return - if (e.key === 'ArrowRight' || (e.key === ' ' && !e.shiftKey)) { + if (e.key === ' ') { + e.preventDefault() + s.onClose() + return + } + if (e.key === 'ArrowRight') { e.preventDefault() if (s.index < s.count - 1) s.onNav(s.index + 1) return } - if (e.key === 'ArrowLeft' || (e.key === ' ' && e.shiftKey)) { + if (e.key === 'ArrowLeft') { e.preventDefault() if (s.index > 0) s.onNav(s.index - 1) return @@ -115,7 +114,8 @@ export default function Lightbox({ if (!photo) return null // Did the vote fly off the photo we're still showing (no advance target)? - const exitSelf = swipe.exit && swipe.exit.photo.id === photo.id + const exit = swipe.exit + const exitSelf = exit && exit.photo.id === photo.id return (
))}
- {swipe.exit && ( + {exit && (
- +
)} {swipe.showBadge &&
} diff --git a/frontend/src/components/Modal.jsx b/frontend/src/components/Modal.jsx new file mode 100644 index 0000000..65012e2 --- /dev/null +++ b/frontend/src/components/Modal.jsx @@ -0,0 +1,19 @@ +import useEscape from '../useEscape' + +export default function Modal({ title, onClose, children }) { + useEscape(onClose) + + return ( +
+
e.stopPropagation()}> +
+

{title}

+ +
+ {children} +
+
+ ) +} diff --git a/frontend/src/components/SharesPanel.jsx b/frontend/src/components/SharesPanel.jsx new file mode 100644 index 0000000..4dd71cd --- /dev/null +++ b/frontend/src/components/SharesPanel.jsx @@ -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 ( + + ) + } + + return ( + + ) +} + +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 ( +
+ {shares.length === 0 &&

No links yet.

} + {shares.map((s) => ( +
+
+ + {s.label || 'unnamed link'} + {s.has_password && 🔒} + {s.locked && ( + <> + locked (too many wrong passwords) + + + )} + + + {s.accept_count + s.reject_count + s.rating_count + s.tag_count > 0 && ( + + + {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}} + + + )} +
+
+ + update(s.id, { expires_at: iso })} /> + + +
+
+ ))} +
+

New link

+ + + + +
+ +
+
+ {error &&

{error}

} +
+ ) +} diff --git a/frontend/src/components/UploadZone.jsx b/frontend/src/components/UploadZone.jsx new file mode 100644 index 0000000..b73689d --- /dev/null +++ b/frontend/src/components/UploadZone.jsx @@ -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 ( + <> + + { + addFiles(e.target.files) + e.target.value = '' + }} + /> + {dragging &&
Drop to upload
} + + 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 ( +
+ + {active && } + {!collapsed && ( +
+ {queue.length > 0 && ( + <> +
+ {fmtBytes(loadedBytes)} of {fmtBytes(totalBytes)} + {active && speed > 0 && ( + <> + {' · '} + {fmtBytes(speed)}/s · ~{fmtEta((totalBytes - loadedBytes) / speed)} left + + )} +
+
    + {queue.map((item) => ( +
  • + {item.file.name} + {item.status === 'error' ? ( + {item.error} + ) : item.status === 'skipped' ? ( + already uploaded + ) : item.status === 'checking' ? ( + checking… + ) : ( + + )} +
  • + ))} +
+ + )} + {processing.length > 0 && ( + <> +
Processing
+
    + {processing.map((p) => ( +
  • + {p.filename} + {p.status === 'error' ? ( + + {p.error || 'failed'} + + + + ) : ( + {p.status}… + )} +
  • + ))} +
+ + )} +
+ )} +
+ ) +} diff --git a/frontend/src/components/XmpModal.jsx b/frontend/src/components/XmpModal.jsx new file mode 100644 index 0000000..537f360 --- /dev/null +++ b/frontend/src/components/XmpModal.jsx @@ -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 ( + +

+ 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. +

+ + {(shares ?? []).map((s) => ( + + ))} +
+ { + if (disabled) e.preventDefault() + }} + > + Download XMP + +
+ {error &&

{error}

} +
+ ) +} diff --git a/frontend/src/pages/AlbumPage.jsx b/frontend/src/pages/AlbumPage.jsx index 214377e..bc39df0 100644 --- a/frontend/src/pages/AlbumPage.jsx +++ b/frontend/src/pages/AlbumPage.jsx @@ -1,401 +1,38 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' -import { api, postDownload, sha256Hex, uploadFile } from '../api' +import { api, postDownload } from '../api' import Gallery from '../components/Gallery' import Lightbox from '../components/Lightbox' -import SelectionBar, { fmtBytes } from '../components/SelectionBar' +import Modal from '../components/Modal' +import SelectionBar from '../components/SelectionBar' +import SharesPanel from '../components/SharesPanel' import Stars from '../components/Stars' -import Thumbs from '../components/Thumbs' +import Thumbs, { ACCEPT_GLYPH, REJECT_GLYPH } from '../components/Thumbs' +import UploadZone from '../components/UploadZone' +import XmpModal from '../components/XmpModal' +import useCulling from '../useCulling' import useLightbox from '../useLightbox' import useSelection from '../useSelection' -function fmtEta(seconds) { - if (!isFinite(seconds) || seconds < 0) return '' - if (seconds < 60) return `${Math.ceil(seconds)}s` - if (seconds < 3600) return `${Math.ceil(seconds / 60)} min` - return `${Math.floor(seconds / 3600)}h ${Math.ceil((seconds % 3600) / 60)} min` -} - -const UPLOAD_CONCURRENCY = 3 - const FEEDBACK_FILTERS = [ { key: 'all', label: 'All' }, - { key: 'accept', label: '👍' }, - { key: 'reject', label: '👎' }, + { key: 'accept', label: ACCEPT_GLYPH }, + { key: 'reject', label: REJECT_GLYPH }, { key: 'undecided', label: 'Undecided' }, ] -// Expiry convention, in one place for the create form and the row editor: -// end of the chosen day in the photographer's local timezone — date-only -// strings would parse as UTC midnight and expire a day early. -const endOfDayIso = (day) => (day ? new Date(`${day}T23:59:59`).toISOString() : null) - -// ISO timestamp -> local yyyy-mm-dd for date inputs. -const localDate = (iso) => { - const d = new Date(iso) - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` -} - -// Staged expiry editor: commits on blur/Enter, never per keystroke — typing -// a year fires change events with bogus intermediate dates (year 0002) that -// must not hit the live link. The ✕ clears explicitly; Safari's date input -// has no native clear control. -function ExpiryDate({ value, onCommit }) { - const current = value ? localDate(value) : '' - const [draft, setDraft] = useState(current) - useEffect(() => setDraft(current), [current]) - - const commit = () => { - if (draft === current) return - // A half-typed year (e.g. 0002) can survive until blur; don't persist it. - if (draft && draft.slice(0, 4) < '2000') { - setDraft(current) - return - } - onCommit(endOfDayIso(draft)) +// Feedback regrouped per client link for the lightbox footer — the +// meaningful reading unit is "what did this person say". +const clientFeedback = (fb) => { + const map = new Map() + const entry = (id, label) => { + if (!map.has(id)) map.set(id, { label: label || 'client', tags: [] }) + return map.get(id) } - - return ( - - ) -} - -function UploadZone({ albumId, onUploaded }) { - const [queue, setQueue] = useState([]) - const [dragging, setDragging] = useState(false) - const [speed, setSpeed] = useState(0) - const inputRef = useRef(null) - const running = useRef(0) - const pending = useRef([]) - const lastRefresh = useRef(0) - const loadedRef = useRef(0) - - const totalBytes = queue.reduce((sum, item) => sum + item.file.size, 0) - const loadedBytes = queue.reduce( - (sum, item) => - sum + (item.status === 'done' ? item.file.size : (item.progress || 0) * item.file.size), - 0, - ) - loadedRef.current = loadedBytes - const active = queue.some((item) => - ['uploading', 'queued', 'checking'].includes(item.status), - ) - - // Sample throughput once a second (EMA-smoothed) while uploads run. - useEffect(() => { - if (!active) { - setSpeed(0) - return - } - let last = { loaded: loadedRef.current, time: Date.now() } - const timer = setInterval(() => { - const now = Date.now() - const instant = (loadedRef.current - last.loaded) / ((now - last.time) / 1000) - last = { loaded: loadedRef.current, time: now } - setSpeed((prev) => (prev > 0 ? prev * 0.7 + instant * 0.3 : instant)) - }, 1000) - return () => clearInterval(timer) - }, [active]) - - // Refresh the album at most every 5s during a bulk upload (the processing - // poll keeps it fresh anyway), plus once when the queue drains. - const refresh = useCallback(() => { - const drained = running.current === 0 && pending.current.length === 0 - if (drained || Date.now() - lastRefresh.current > 5000) { - lastRefresh.current = Date.now() - onUploaded() - } - }, [onUploaded]) - - const pump = useCallback(() => { - while (running.current < UPLOAD_CONCURRENCY && pending.current.length > 0) { - const item = pending.current.shift() - running.current += 1 - const update = (patch) => - setQueue((q) => q.map((x) => (x.key === item.key ? { ...x, ...patch } : x))) - const transfer = async () => { - // Hash locally first: content the album already has is skipped - // without transferring a single byte. - update({ status: 'checking' }) - try { - const hash = await sha256Hex(item.file) - await api(`/api/albums/${albumId}/photos/by-hash/${hash}`) - update({ status: 'skipped', progress: 1 }) - return - } catch { - // 404 (not there yet) or hashing unavailable — upload normally. - } - update({ status: 'uploading' }) - const url = `/api/albums/${albumId}/photos?filename=${encodeURIComponent(item.file.name)}` - await uploadFile(url, item.file, (p) => update({ progress: p })) - update({ status: 'done', progress: 1 }) - } - transfer() - .catch((e) => update({ status: 'error', error: e.message })) - .finally(() => { - running.current -= 1 - refresh() - pump() - }) - } - }, [albumId, refresh]) - - const addFiles = (files) => { - const items = [...files].map((file, i) => ({ - key: `${Date.now()}-${i}-${file.name}`, - file, - status: 'queued', - progress: 0, - })) - if (items.length === 0) return - setQueue((q) => [...q.filter((x) => x.status !== 'done'), ...items]) - pending.current.push(...items) - pump() - } - - return ( -
{ - e.preventDefault() - setDragging(true) - }} - onDragLeave={() => setDragging(false)} - onDrop={(e) => { - e.preventDefault() - setDragging(false) - addFiles(e.dataTransfer.files) - }} - onClick={() => inputRef.current?.click()} - > - { - addFiles(e.target.files) - e.target.value = '' - }} - /> -

Drop RAWs or JPGs here, or click to select

- {queue.length > 0 && ( -
e.stopPropagation()}> - - - {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 - - )} - -
- )} - {queue.length > 0 && ( -
    e.stopPropagation()}> - {queue.map((item) => ( -
  • - {item.file.name} - {item.status === 'error' ? ( - {item.error} - ) : item.status === 'skipped' ? ( - already uploaded - ) : item.status === 'checking' ? ( - checking… - ) : ( - - )} -
  • - ))} -
- )} -
- ) -} - -function SharesPanel({ albumId }) { - const [shares, setShares] = useState([]) - const [form, setForm] = useState({ label: '', password: '', allow_download: true, expires_at: '' }) - const [error, setError] = useState(null) - const [copied, setCopied] = useState(null) - - const load = useCallback( - () => api(`/api/albums/${albumId}/shares`).then(setShares).catch((e) => setError(e.message)), - [albumId], - ) - useEffect(() => { - load() - }, [load]) - - const create = async (e) => { - e.preventDefault() - try { - await api(`/api/albums/${albumId}/shares`, { - method: 'POST', - body: { - label: form.label, - password: form.password || null, - allow_download: form.allow_download, - expires_at: endOfDayIso(form.expires_at), - }, - }) - setForm({ label: '', password: '', allow_download: true, expires_at: '' }) - setError(null) - load() - } catch (e) { - setError(e.message) - } - } - - const copy = async (share) => { - await navigator.clipboard.writeText(share.url) - setCopied(share.id) - setTimeout(() => setCopied(null), 1500) - } - - const update = async (shareId, patch) => { - try { - const updated = await api(`/api/shares/${shareId}`, { method: 'PATCH', body: patch }) - setShares((list) => list.map((s) => (s.id === shareId ? updated : s))) - setError(null) - } catch (e) { - setError(e.message) - // Re-sync the controlled inputs with what the server actually has. - load() - } - } - - return ( -
-

Client links

- {shares.length === 0 &&

No links yet.

} - {shares.map((s) => ( -
-
- - {s.label || 'unnamed link'} - {s.locked && — locked (too many wrong passwords)} - - - {s.has_password ? '🔒 password' : 'no password'} - {' · '} - {s.rating_count} ratings, {s.tag_count} tags, 👍 {s.accept_count} 👎{' '} - {s.reject_count} - -
-
- - update(s.id, { expires_at: iso })} /> - {s.locked && ( - - )} - - -
-
- ))} -
- setForm({ ...form, label: e.target.value })} - /> - setForm({ ...form, password: e.target.value })} - /> - - - -
- {error &&

{error}

} -
- ) + 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() { @@ -412,60 +49,184 @@ export default function AlbumPage() { load() }, [load]) - // Poll while any photo is still being processed by the workers. - const hasPending = detail?.photos.some((p) => p.status === 'uploaded' || p.status === 'processing') + // While workers are busy, poll the lightweight pending endpoint and only + // refetch the full album when something actually changed. The signature + // lives in a ref so votes/patches don't restart (and starve) the interval. + const notReady = (detail?.photos ?? []).filter((p) => p.status !== 'ready') + const hasPending = notReady.some((p) => p.status === 'uploaded' || p.status === 'processing') + const signature = (list) => list.map((p) => `${p.id}:${p.status}`).sort().join() + const pendingSig = useRef('') + pendingSig.current = signature(notReady) useEffect(() => { if (!hasPending) return - const t = setInterval(load, 4000) + const t = setInterval(async () => { + try { + const status = await api(`/api/albums/${id}/pending`) + if (signature(status.pending) !== pendingSig.current) load() + } catch { + // transient; next tick retries + } + }, 4000) return () => clearInterval(t) - }, [hasPending, load]) + }, [hasPending, id, load]) - const ready = (detail?.photos ?? []).filter((p) => p.status === 'ready') + const ready = useMemo( + () => (detail?.photos ?? []).filter((p) => p.status === 'ready'), + [detail], + ) const feedback = detail?.feedback ?? {} + const aggregates = detail?.aggregates ?? {} - const avgRating = (photoId) => { - const ratings = feedback[photoId]?.ratings || [] - if (ratings.length === 0) return null - return ratings.reduce((sum, r) => sum + r.rating, 0) / ratings.length - } + // Whose feedback the filters and overlays look at: null = everyone + // (clients + own, server-aggregated), 'own', or a share id. + const [client, setClient] = useState(null) + + // 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]) - // Two combinable filter dimensions over client feedback: verdict chips - // plus a minimum-average-stars threshold (0 = off). const [filter, setFilter] = useState('all') const [minStars, setMinStars] = useState(0) - const matchesVerdict = (p, key) => { + const [linksOpen, setLinksOpen] = useState(false) + const [xmpOpen, setXmpOpen] = useState(false) + + const matchesVerdict = (s, key) => { if (key === 'all') return true - const verdicts = feedback[p.id]?.verdicts || [] - if (key === 'accept') return verdicts.some((v) => v.verdict === 'accept') - if (key === 'reject') return verdicts.some((v) => v.verdict === 'reject') - return verdicts.length === 0 + if (key === 'accept') return s.accepts > 0 + if (key === 'reject') return s.rejects > 0 + return s.accepts + s.rejects === 0 } - const shown = ready.filter( - (p) => matchesVerdict(p, filter) && (minStars === 0 || (avgRating(p.id) ?? 0) >= minStars), + 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 verdicts = feedback[p.id]?.verdicts || [] - if (verdicts.some((v) => v.verdict === 'accept')) counts.accept += 1 - if (verdicts.some((v) => v.verdict === 'reject')) counts.reject += 1 - if (verdicts.length === 0) counts.undecided += 1 + const s = scoped.get(p.id) + if (s.accepts > 0) counts.accept += 1 + if (s.rejects > 0) counts.reject += 1 + if (s.accepts + s.rejects === 0) counts.undecided += 1 } return counts + }, [ready, scoped]) + + const clientTotals = useMemo(() => { + const totals = new Map() + const entry = (id, label) => { + if (!totals.has(id)) + totals.set(id, { label: label || 'client', accepts: 0, rejects: 0, ratings: 0, tags: 0 }) + return totals.get(id) + } + for (const fb of Object.values(feedback)) { + for (const r of fb.ratings) entry(r.share_id, r.share_label).ratings += 1 + for (const v of fb.verdicts) + entry(v.share_id, v.share_label)[v.verdict === 'accept' ? 'accepts' : 'rejects'] += 1 + for (const t of fb.tags) entry(t.share_id, t.share_label).tags += 1 + } + return [...totals.entries()] }, [detail]) - // Selection spans all ready photos; the bar and bulk actions cover only - // the current view, like on the share page. + const ownTotals = useMemo(() => { + const totals = { accepts: 0, rejects: 0, ratings: 0, tags: 0 } + for (const p of ready) { + if (p.owner_verdict === 'accept') totals.accepts += 1 + if (p.owner_verdict === 'reject') totals.rejects += 1 + if (p.owner_rating) totals.ratings += 1 + } + return totals + }, [ready]) + + // The active scope's pill stays visible even at zero counts, so clearing + // your last vote can't strand the filter without a control. + const strip = [ + ['own', { label: 'you', ...ownTotals }], + ...clientTotals, + ].filter(([key, t]) => key === client || t.accepts + t.rejects + t.ratings + t.tags > 0) + + // Reset only when the scoped share is gone entirely (deleted link). + useEffect(() => { + if (client && client !== 'own' && !clientTotals.some(([id]) => id === client)) setClient(null) + }, [client, clientTotals]) + const { selected, toggle, selectAll, clear } = useSelection(ready) const shownSelected = shown.filter((p) => selected.has(p.id)) const sumBytes = (list) => list.reduce((sum, p) => sum + p.size_bytes, 0) const lightbox = useLightbox(shown) + const patchPhoto = (photoId, patch) => + setDetail((d) => ({ + ...d, + photos: d.photos.map((p) => (p.id === photoId ? { ...p, ...patch } : p)), + })) + + // Per-photo sequence: only the latest request's aggregate may land, so + // out-of-order responses can't overwrite fresher state. + const aggSeq = useRef(new Map()) + const saveOwner = async (photo, patch, endpoint, body) => { + patchPhoto(photo.id, patch) + const seq = (aggSeq.current.get(photo.id) ?? 0) + 1 + aggSeq.current.set(photo.id, seq) + try { + const res = await api(`/api/photos/${photo.id}/${endpoint}`, { method: 'PUT', body }) + if (aggSeq.current.get(photo.id) !== seq) return + setDetail((d) => ({ + ...d, + aggregates: { ...d.aggregates, [photo.id]: res.aggregate }, + })) + } catch { + if (aggSeq.current.get(photo.id) === seq) load() + } + } + const setOwnerRating = (photo, rating) => + saveOwner(photo, { owner_rating: rating || null }, 'rating', { rating }) + const setOwnerVerdict = (photo, verdict) => + saveOwner(photo, { owner_verdict: verdict }, 'verdict', { verdict }) + + const culling = useCulling({ + visible: lightbox.view, + lightbox, + setVerdict: setOwnerVerdict, + setRating: setOwnerRating, + toggle, + }) + if (error) return

{error}

if (!detail) return

Loading…

const { album, photos } = detail - const notReady = photos.filter((p) => p.status !== 'ready') const rename = async () => { const name = prompt('Album name', album.name) @@ -511,6 +272,19 @@ export default function AlbumPage() { {album.name}
+ api(`/api/photos/${p.id}/reprocess`, { method: 'POST' }).then(load)} + onDelete={removePhoto} + /> + + @@ -520,35 +294,35 @@ export default function AlbumPage() {
- - - {notReady.length > 0 && ( -
-

Processing

- -
+ {strip.length > 0 && ( +
+ {strip.map(([key, t]) => ( + + ))} +
)} {ready.length > 0 && ( @@ -564,7 +338,7 @@ export default function AlbumPage() { ))} 0 ? ' active' : ''}`} - title="Minimum average rating — click a star to filter, click it again to clear" + title="Minimum top rating in the selected scope — click a star to filter, click it again to clear" > setMinStars(n)} small /> {minStars > 0 && ≥ {minStars}} @@ -577,25 +351,25 @@ export default function AlbumPage() { onOpen={lightbox.openAt} selected={selected} onToggleSelect={(photoId, shift) => toggle(photoId, shift, shown)} + keyboard={lightbox.index < 0 && !linksOpen && !xmpOpen} + externalIndex={lightbox.index} + actions={culling.keyActions} overlay={(p) => { - const avg = avgRating(p.id) - const tagCount = feedback[p.id]?.tags.length || 0 - const verdicts = feedback[p.id]?.verdicts || [] - const accepts = verdicts.filter((v) => v.verdict === 'accept').length - const rejects = verdicts.length - accepts - if (avg === null && tagCount === 0 && accepts === 0 && rejects === 0) return null + const s = scoped.get(p.id) + if (!s || (s.top === null && s.tags === 0 && s.accepts === 0 && s.rejects === 0)) + return null return (
- {accepts > 0 && 👍 {accepts}} - {rejects > 0 && 👎 {rejects}} - {avg !== null && ★ {avg.toFixed(1)}} - {tagCount > 0 && # {tagCount}} + {s.accepts > 0 && 👍 {s.accepts}} + {s.rejects > 0 && 👎 {s.rejects}} + {s.top !== null && {'★'.repeat(s.top)}} + {s.tags > 0 && # {s.tags}}
) }} /> {ready.length === 0 && notReady.length === 0 && ( -

No photos yet — drop some above.

+

No photos yet — use Upload or drop files anywhere on the page.

)} {ready.length > 0 && shown.length === 0 && (

No photos match this filter.

@@ -619,38 +393,20 @@ export default function AlbumPage() { {lightbox.index >= 0 && ( 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 (
-
- {fb.ratings.length === 0 && fb.verdicts.length === 0 && fb.tags.length === 0 && ( - No client feedback yet - )} - {fb.verdicts.map((v, i) => ( - - {v.share_label || 'client'}: - - ))} - {fb.ratings.map((r, i) => ( - - {r.share_label || 'client'}: - - ))} - {fb.tags.map((t, i) => ( - - {t.tag} ({t.share_label || 'client'}) - - ))} -
-
+
+ + setOwnerVerdict(p, v)} /> + setOwnerRating(p, r)} /> +
+
+ {groups.length === 0 && No client feedback yet} + {groups.map(([shareId, c]) => ( + + {c.label} + {c.verdict && ( + + {c.verdict === 'accept' ? ACCEPT_GLYPH : REJECT_GLYPH} + + )} + {c.rating && } + {c.tags.map((tag) => ( + + {tag} + + ))} + + ))} +
) }} /> )} - + {linksOpen && ( + setLinksOpen(false)}> + + + )} + {xmpOpen && setXmpOpen(false)} />} + {culling.flash && ( +
+ {culling.flash.text} +
+ )} ) } diff --git a/frontend/src/pages/SharePage.jsx b/frontend/src/pages/SharePage.jsx index 6a443aa..ab4ef07 100644 --- a/frontend/src/pages/SharePage.jsx +++ b/frontend/src/pages/SharePage.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useParams } from 'react-router-dom' import { api, postDownload } from '../api' import Gallery from '../components/Gallery' @@ -6,14 +6,15 @@ import Lightbox from '../components/Lightbox' import SelectionBar from '../components/SelectionBar' import Stars from '../components/Stars' import TagEditor from '../components/TagEditor' -import Thumbs from '../components/Thumbs' +import Thumbs, { ACCEPT_GLYPH, REJECT_GLYPH } from '../components/Thumbs' +import useCulling from '../useCulling' import useLightbox from '../useLightbox' import useSelection from '../useSelection' const FILTERS = [ { key: 'all', label: 'All' }, - { key: 'accept', label: '👍' }, - { key: 'reject', label: '👎' }, + { key: 'accept', label: ACCEPT_GLYPH }, + { key: 'reject', label: REJECT_GLYPH }, { key: 'undecided', label: 'Undecided' }, ] @@ -43,12 +44,6 @@ export default function SharePage() { const visibleSelected = visible.filter((p) => selected.has(p.id)) const sumBytes = (list) => list.reduce((sum, p) => sum + p.size_bytes, 0) const lightbox = useLightbox(visible) - // Voting the last photo ends the run: fade the modal out over the gallery - // instead of cutting hard. - const [lightboxFading, setLightboxFading] = useState(false) - useEffect(() => { - if (lightbox.index < 0) setLightboxFading(false) - }, [lightbox.index]) const load = useCallback( () => api(`/api/share/${token}`).then(setView).catch((e) => setError(e.message)), @@ -92,61 +87,14 @@ export default function SharePage() { saveFeedback(photo, { my_verdict: verdict }, 'verdict', { verdict }) const setTags = (photo, tags) => saveFeedback(photo, { my_tags: tags }, 'tags', { tags }) - // Keyboard votes navigate away from the photo they change, so a transient - // toast names what just happened to it — without it the jump reads as - // "did that register?". - const [flash, setFlash] = useState(null) - const flashSeq = useRef(0) - const flashTimer = useRef() - const showFlash = (text) => { - flashSeq.current += 1 - setFlash({ text, key: flashSeq.current }) - clearTimeout(flashTimer.current) - flashTimer.current = setTimeout(() => setFlash(null), 1400) - } - useEffect(() => () => clearTimeout(flashTimer.current), []) - - // Culling flow: vote, then advance to the photo that was next in the - // current view. Under a filter that hides the voted photo, the advance - // target stays visible, so the run continues seamlessly. - const voteAndAdvance = (photo, verdict) => { - const next = visible[lightbox.index + 1] - setVerdict(photo, verdict) - showFlash( - verdict === 'accept' - ? `👍 ${photo.filename}` - : verdict === 'reject' - ? `👎 ${photo.filename}` - : `↺ ${photo.filename} cleared`, - ) - if (next) lightbox.show(next.id) - // Last photo voted: the run is done — fade back to the gallery. - else setLightboxFading(true) - } - - const keyActions = [ - { keys: ['p'], help: ['P', 'accept and go to next'], run: (p) => voteAndAdvance(p, 'accept') }, - { keys: ['x'], help: ['X', 'reject and go to next'], run: (p) => voteAndAdvance(p, 'reject') }, - { - keys: ['u'], - help: ['U', 'clear accept / reject'], - // When clearing hides the photo from the current filter, advance like - // a vote so the lightbox doesn't just close. - run: (p) => - matchesFilter({ my_verdict: null }, filter) - ? setVerdict(p, null) - : voteAndAdvance(p, null), - }, - { - keys: ['1', '2', '3', '4', '5'], - help: ['1–5', 'star rating'], - run: (p, key) => setRating(p, Number(key)), - }, - { keys: ['0'], help: ['0', 'clear star rating'], run: (p) => setRating(p, 0) }, - ...(view?.allow_download - ? [{ keys: ['s'], help: ['S', 'select for download'], run: (p) => toggle(p.id) }] - : []), - ] + const culling = useCulling({ + visible: lightbox.view, + lightbox, + setVerdict, + setRating, + toggle, + canSelect: !!view?.allow_download, + }) if (error) return
{error}
if (!view) return
Loading…
@@ -206,11 +154,14 @@ export default function SharePage() { onToggleSelect={ view.allow_download ? (id, shift) => toggle(id, shift, visible) : undefined } + keyboard={lightbox.index < 0} + externalIndex={lightbox.index} + actions={culling.keyActions} overlay={(p) => p.my_verdict || p.my_rating || p.my_tags.length > 0 ? (
{p.my_verdict && {p.my_verdict === 'accept' ? '👍' : '👎'}} - {p.my_rating && ★ {p.my_rating}} + {p.my_rating && {'★'.repeat(p.my_rating)}} {p.my_tags.length > 0 && # {p.my_tags.length}}
) : null @@ -245,20 +196,11 @@ export default function SharePage() { )} {lightbox.index >= 0 && ( { - setLightboxFading(false) - lightbox.close() - }} - gestures={{ - up: (p) => voteAndAdvance(p, 'accept'), - down: (p) => voteAndAdvance(p, 'reject'), - }} + {...culling.lightboxProps} footer={(p) => (
setVerdict(p, v)} /> @@ -283,9 +225,9 @@ export default function SharePage() { )} /> )} - {flash && ( -
- {flash.text} + {culling.flash && ( +
+ {culling.flash.text}
)} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6929a26..c2a6618 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -10,9 +10,16 @@ --muted: #9a978f; --accent: #d9a441; --danger: #e5645a; + --accept: #7ac97a; --radius: 8px; } +html { + /* Reserve the scrollbar gutter — content width must not shift when a + filter change toggles the scrollbar. */ + scrollbar-gutter: stable; +} + body { margin: 0; background: var(--bg); @@ -105,6 +112,11 @@ input { input:focus { outline: 1px solid var(--accent); } +input[type='checkbox'] { + accent-color: var(--accent); + width: 16px; + height: 16px; +} .btn { display: inline-block; background: var(--panel-2); @@ -158,10 +170,9 @@ input:focus { border-radius: var(--radius); overflow: hidden; text-decoration: none; - transition: transform 0.1s; } -.album-card:hover { - transform: translateY(-2px); +.album-card:hover .album-cover img { + filter: brightness(1.08); } .album-cover { aspect-ratio: 3 / 2; @@ -188,41 +199,41 @@ input:focus { gap: 0.5rem; } -/* justified gallery */ +/* photo grid (Lightroom-style fixed cells) */ .gallery { - display: flex; - flex-direction: column; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 6px; margin: 1rem 0; } -.g-row { - display: flex; - gap: 6px; -} .g-item { position: relative; - flex: none; + aspect-ratio: 1 / 1; border-radius: 4px; overflow: hidden; cursor: pointer; background: var(--panel); + content-visibility: auto; + contain-intrinsic-size: 300px; } .g-item img { width: 100%; height: 100%; - object-fit: cover; + padding: 8px; + object-fit: contain; display: block; } .g-overlay { position: absolute; - bottom: 0; - left: 0; - right: 0; - display: flex; + bottom: 6px; + left: 6px; + display: inline-flex; + align-items: center; gap: 0.6rem; - padding: 0.35rem 0.55rem; + padding: 0.2rem 0.55rem; font-size: 0.8rem; - background: linear-gradient(transparent, rgba(0, 0, 0, 0.75)); + background: rgba(0, 0, 0, 0.55); + border-radius: 6px; color: #ffd97a; } @@ -257,6 +268,10 @@ input:focus { outline: 3px solid var(--accent); outline-offset: -3px; } +.g-item.focused { + outline: 2px solid var(--text); + outline-offset: -2px; +} .select-bar { position: fixed; bottom: 1.25rem; @@ -284,41 +299,8 @@ input:focus { color: var(--muted); cursor: pointer; } -.select-toggle input { - accent-color: var(--accent); - width: 16px; - height: 16px; -} -/* upload zone */ -.upload-zone { - border: 2px dashed var(--panel-2); - border-radius: var(--radius); - padding: 1.25rem; - text-align: center; - color: var(--muted); - cursor: pointer; - margin-bottom: 1rem; -} -.upload-zone.dragging { - border-color: var(--accent); - color: var(--accent); -} -.upload-zone p { - margin: 0; -} -.upload-summary { - display: flex; - flex-direction: column; - gap: 0.3rem; - margin-top: 1rem; - cursor: default; - font-size: 0.9rem; -} -.upload-total { - width: 100%; - accent-color: var(--accent); -} +/* upload */ .upload-list { list-style: none; margin: 1rem 0 0; @@ -349,6 +331,121 @@ progress { accent-color: var(--accent); } +/* fullscreen drag target */ +.drop-overlay { + position: fixed; + inset: 10px; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + background: rgba(16, 18, 22, 0.82); + border: 3px dashed var(--accent); + border-radius: 12px; + color: var(--accent); + font-size: 1.4rem; + pointer-events: none; +} + +/* activity overlay: uploads + processing, bottom right */ +.activity { + position: fixed; + right: 1rem; + bottom: calc(1rem + env(safe-area-inset-bottom)); + width: min(340px, calc(100vw - 2rem)); + background: var(--panel); + border: 1px solid var(--panel-2); + border-radius: var(--radius); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45); + z-index: 60; + font-size: 0.85rem; +} +.activity-head { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + background: none; + border: none; + color: var(--text); + padding: 0.55rem 0.8rem; + font-size: 0.85rem; + cursor: pointer; +} +.activity-clear { + color: var(--muted); + padding: 0 0.2rem; +} +.activity-total { + display: block; + width: calc(100% - 1.6rem); + margin: 0 0.8rem 0.5rem; +} +.activity-body { + border-top: 1px solid var(--panel-2); + padding: 0.5rem 0.8rem 0.7rem; + max-height: 40vh; + overflow-y: auto; +} +.activity-body .upload-list { + margin: 0.25rem 0 0; + max-height: none; +} +.activity-body progress { + width: 110px; +} +.activity-section { + margin-top: 0.4rem; + font-size: 0.8rem; +} +.activity-section:first-child { + margin-top: 0; +} + +/* modal */ +.modal { + position: fixed; + inset: 0; + z-index: 90; + background: rgba(8, 9, 11, 0.7); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 8vh 1rem 1rem; +} +.modal-card { + background: var(--panel); + border: 1px solid var(--panel-2); + border-radius: 12px; + width: min(760px, 100%); + max-height: 80vh; + overflow-y: auto; + padding: 1rem 1.25rem 1.25rem; +} +.modal-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; +} +.modal-head h2 { + margin: 0; +} +.modal-foot { + margin-top: 1rem; +} +.xmp-source { + display: flex; + gap: 0.5rem; + padding: 0.3rem 0; + color: var(--text); +} +.btn-disabled { + opacity: 0.5; + cursor: default; +} + /* panels */ .panel { background: var(--panel); @@ -387,14 +484,72 @@ progress { .share-info .muted { font-size: 0.82rem; } -.share-form { +.share-stats { + display: inline-flex; + gap: 0.3rem; +} +.share-feedback { + margin-top: 0.35rem; +} +.feedback-strip { display: flex; flex-wrap: wrap; gap: 0.5rem; + margin: 0 0 0.75rem; +} +.client-fb-btn { + cursor: pointer; + font-size: inherit; + color: var(--text); +} +.client-fb-btn.active { + border-color: var(--accent); + background: var(--panel-2); +} +.chip-accept { + background: color-mix(in srgb, var(--accept) 18%, var(--panel-2)); +} +.chip-reject { + background: color-mix(in srgb, var(--danger) 18%, var(--panel-2)); +} +.share-url { + background: none; + border: none; + padding: 0; + margin-top: 0.15rem; + color: var(--muted); + font-size: 0.82rem; + cursor: pointer; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} +.share-url:hover { + color: var(--accent); +} +.share-form { + display: grid; + grid-template-columns: max-content minmax(0, 320px); + gap: 0.6rem 1rem; align-items: center; margin-top: 1rem; + border-top: 1px solid var(--panel-2); + padding-top: 0.9rem; } -.share-form label { +.share-form-title { + grid-column: 1 / -1; + font-size: 0.95rem; + font-weight: 600; + margin: 0; +} +.share-form .field { + display: contents; + font-size: 0.85rem; +} +.share-form-full { + grid-column: 1 / -1; font-size: 0.85rem; color: var(--muted); } @@ -574,14 +729,49 @@ progress { .lb-footer { padding: 0.7rem 1rem 1rem; } -.client-footer, -.admin-footer { +.client-footer { display: flex; align-items: center; justify-content: center; gap: 1.25rem; flex-wrap: wrap; } +/* Two rows: things you do (own vote + photo actions), things you read + (per-client feedback pills). */ +.admin-footer { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; +} +.admin-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 1.25rem; + flex-wrap: wrap; +} +.client-fb { + display: inline-flex; + align-items: center; + gap: 0.4rem; + background: var(--panel); + border: 1px solid var(--panel-2); + border-radius: 999px; + padding: 0.15rem 0.7rem; +} +.client-fb-verdict { + font-size: 1.1rem; + line-height: 1; +} +.client-fb-accept { + border-color: color-mix(in srgb, var(--accept) 60%, transparent); + background: color-mix(in srgb, var(--accept) 16%, var(--panel)); +} +.client-fb-reject { + border-color: color-mix(in srgb, var(--danger) 60%, transparent); + background: color-mix(in srgb, var(--danger) 16%, var(--panel)); +} .feedback { display: flex; align-items: center; @@ -589,10 +779,10 @@ progress { flex-wrap: wrap; font-size: 0.85rem; } -.feedback-item { +.own-feedback { display: inline-flex; align-items: center; - gap: 0.3rem; + gap: 0.5rem; } /* stars */ @@ -809,6 +999,9 @@ kbd { } @media (max-width: 700px) { + .gallery { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + } .lb-slide { padding: 0 0.5rem; } @@ -851,10 +1044,13 @@ kbd { row-gap: 0.5rem; } .share-form { - flex-direction: column; - align-items: stretch; + grid-template-columns: 1fr; } - .share-form > input { + .share-form .field { + display: block; + } + .share-form .field > input { width: 100%; + margin-top: 0.2rem; } } diff --git a/frontend/src/useCulling.js b/frontend/src/useCulling.js new file mode 100644 index 0000000..7dfd730 --- /dev/null +++ b/frontend/src/useCulling.js @@ -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: ['1–5', 'star rating'], + run: (p, key) => setRating(p, Number(key)), + }, + { keys: ['0'], help: ['0', 'clear star rating'], run: (p) => setRating(p, 0) }, + ...(canSelect + ? [{ keys: ['s'], help: ['S', 'select for download'], run: (p) => toggle(p.id) }] + : []), + ] + + return { + flash, + keyActions, + lightboxProps: { + actions: keyActions, + gestures: { + up: (p) => voteAndAdvance(p, 'accept'), + down: (p) => voteAndAdvance(p, 'reject'), + }, + closing: fading, + onClosed: () => { + setFading(false) + lightbox.close() + }, + }, + } +} diff --git a/frontend/src/useEscape.js b/frontend/src/useEscape.js new file mode 100644 index 0000000..2341171 --- /dev/null +++ b/frontend/src/useEscape.js @@ -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]) +} diff --git a/frontend/src/useLightbox.js b/frontend/src/useLightbox.js index 2b511c9..aae15d5 100644 --- a/frontend/src/useLightbox.js +++ b/frontend/src/useLightbox.js @@ -1,23 +1,40 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' -// Lightbox state tracked by photo id, not index — the list can reorder -// (polling refetch) or shrink (filter change, delete) underneath an open -// lightbox. When the open photo leaves the list, close for good — otherwise -// the lightbox would pop back open when the photo returns to the list. +// Lightbox state tracked by photo id, not index — the list can reorder or +// shrink underneath an open lightbox. While open, the visible list is FROZEN +// to the ids present at open time (mapped to live photo objects, so votes +// still update the open photo): voting a photo out of the active filter must +// not yank it from under the viewer. Filters re-apply on close. If the open +// photo disappears entirely (deleted), the lightbox closes for good. export default function useLightbox(photos) { const [openId, setOpenId] = useState(null) - const index = openId ? photos.findIndex((p) => p.id === openId) : -1 + const frozen = useRef(null) + + const byId = useMemo(() => new Map(photos.map((p) => [p.id, p])), [photos]) + const view = + openId && frozen.current ? frozen.current.map((id) => byId.get(id)).filter(Boolean) : photos + const index = openId ? view.findIndex((p) => p.id === openId) : -1 useEffect(() => { - if (openId && index < 0) setOpenId(null) + if (openId && index < 0) { + frozen.current = null + setOpenId(null) + } }, [openId, index]) + const open = (id) => { + if (!frozen.current) frozen.current = photos.map((p) => p.id) + setOpenId(id) + } + return { index, - // Bounds-safe: callers may hold a stale index (the list can shrink - // between render and dispatch). - openAt: (i) => photos[i] && setOpenId(photos[i].id), - show: (id) => setOpenId(id), - close: () => setOpenId(null), + view, + openAt: (i) => view[i] && open(view[i].id), + show: (id) => open(id), + close: () => { + frozen.current = null + setOpenId(null) + }, } } diff --git a/migrations/0005_owner_feedback.sql b/migrations/0005_owner_feedback.sql new file mode 100644 index 0000000..96b77ea --- /dev/null +++ b/migrations/0005_owner_feedback.sql @@ -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')); diff --git a/src/models.rs b/src/models.rs index 999c8d8..5eb1c0b 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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, + 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, 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, pub taken_at: Option>, pub processed_at: Option>, + pub owner_rating: Option, + pub owner_verdict: Option, #[serde(skip_serializing)] pub sha256: Option, #[serde(skip_serializing)] diff --git a/src/routes/albums.rs b/src/routes/albums.rs index 0261226..6de5ed3 100644 --- a/src/routes/albums.rs +++ b/src/routes/albums.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::error::{ApiError, ApiResult}; -use crate::models::{Album, Photo, PhotoStatus}; +use crate::models::{Album, FeedbackAggregate, Photo, PhotoStatus}; use crate::state::AppState; #[derive(Serialize, sqlx::FromRow)] @@ -69,18 +69,21 @@ pub async fn create( #[derive(Serialize)] pub struct ShareRating { + pub share_id: Uuid, pub share_label: String, pub rating: i32, } #[derive(Serialize)] pub struct ShareTag { + pub share_id: Uuid, pub share_label: String, pub tag: String, } #[derive(Serialize)] pub struct ShareVerdict { + pub share_id: Uuid, pub share_label: String, pub verdict: String, } @@ -92,24 +95,42 @@ pub struct PhotoFeedback { pub tags: Vec, } -async fn feedback_rows( +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( db: &sqlx::PgPool, sql: &str, album_id: Uuid, -) -> Result, sqlx::Error> + shares: Option<&[Uuid]>, +) -> Result, 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( feedback: &mut HashMap, - 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, pub feedback: HashMap, + pub aggregates: HashMap, } 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::( - &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::( - &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::( - &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::(&state.db, RATING_ROWS, album_id, None), + feedback_rows::(&state.db, VERDICT_ROWS, album_id, None), + feedback_rows::(&state.db, TAG_ROWS, album_id, None), )?; + let mut aggregates: HashMap = 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 = 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, +} + +pub async fn pending( + State(state): State, + Path(album_id): Path, +) -> ApiResult> { + let pending: Vec = 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, diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 294e0a4..90bc7bb 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -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 { 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}", diff --git a/src/routes/photos.rs b/src/routes/photos.rs index d056874..836f019 100644 --- a/src/routes/photos.rs +++ b/src/routes/photos.rs @@ -11,7 +11,7 @@ use uuid::Uuid; use crate::error::{ApiError, ApiResult}; use crate::jobs; -use crate::models::{JobKind, Photo, PhotoStatus}; +use crate::models::{FeedbackAggregate, JobKind, Photo, PhotoStatus, Verdict}; use crate::s3; use crate::state::AppState; @@ -252,6 +252,86 @@ pub async fn delete_many( Ok(Json(serde_json::json!({ "ok": true, "deleted": deleted }))) } +async fn feedback_aggregate( + db: &sqlx::PgPool, + photo_id: Uuid, +) -> Result { + let (top, accepts, rejects, owner_rating, owner_verdict): ( + Option, + i64, + i64, + Option, + Option, + ) = 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, + Path(photo_id): Path, + Json(body): Json, +) -> ApiResult> { + 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, +} + +pub async fn set_owner_verdict( + State(state): State, + Path(photo_id): Path, + Json(body): Json, +) -> ApiResult> { + let updated = sqlx::query("update photos set owner_verdict = $2 where id = $1") + .bind(photo_id) + .bind(body.verdict.map(Verdict::as_str)) + .execute(&state.db) + .await?; + if updated.rows_affected() == 0 { + return Err(ApiError::not_found()); + } + let aggregate = feedback_aggregate(&state.db, photo_id).await?; + Ok(Json(serde_json::json!({ "ok": true, "aggregate": aggregate }))) +} + pub async fn reprocess( State(state): State, Path(photo_id): Path, diff --git a/src/routes/xmp.rs b/src/routes/xmp.rs new file mode 100644 index 0000000..3b2b703 --- /dev/null +++ b/src/routes/xmp.rs @@ -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, + /// Include the photographer's own feedback; defaults to true only when + /// no share filter is given. + own: Option, +} + +#[derive(Default)] +struct Sidecar { + agg: FeedbackAggregate, + tags: BTreeSet, +} + +/// 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, + Path(album_id): Path, + Query(query): Query, +) -> ApiResult { + 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> = 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::, _>>() + .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, Option)> = 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::(&state.db, super::albums::RATING_ROWS, album_id, shares), + super::albums::feedback_rows::(&state.db, super::albums::VERDICT_ROWS, album_id, shares), + super::albums::feedback_rows::(&state.db, super::albums::TAG_ROWS, album_id, shares), + )?; + + let mut feedback: HashMap = 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 = 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)> = sidecars + .into_iter() + .map(|(name, f)| { + ( + name, + xmp_document( + f.agg.top_rating.map(|r| r.clamp(1, 5)), + f.agg.label(), + &f.tags, + ) + .into_bytes(), + ) + }) + .collect(); + + let zip = build_stored_zip(&entries); + let safe_name: String = album_name + .chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' }) + .collect(); + Ok(( + [ + (header::CONTENT_TYPE, "application/zip".to_string()), + ( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{safe_name}-xmp.zip\""), + ), + ], + zip, + ) + .into_response()) +} + +fn escape_xml(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn xmp_document(rating: Option, label: Option<&str>, tags: &BTreeSet) -> 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!("{}", escape_xml(t))) + .collect(); + format!("{items}") + }; + format!( + "\n\ + \n \ + \n \ + {subject}\n \ + \n\ + \n\ + " + ) +} + +/// 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)]) -> Vec { + let (dos_time, dos_date) = super::zip::dos_datetime(Utc::now()); + let mut out: Vec = Vec::new(); + let mut central: Vec = Vec::new(); + for (name, data) in entries { + let offset = out.len() as u32; + let crc = crc32fast::hash(data); + let name_bytes = name.as_bytes(); + let size = data.len() as u32; + + out.extend_from_slice(&0x04034b50u32.to_le_bytes()); + out.extend_from_slice(&20u16.to_le_bytes()); + out.extend_from_slice(&0x0800u16.to_le_bytes()); // UTF-8 names + out.extend_from_slice(&0u16.to_le_bytes()); // stored + out.extend_from_slice(&dos_time.to_le_bytes()); + out.extend_from_slice(&dos_date.to_le_bytes()); + out.extend_from_slice(&crc.to_le_bytes()); + out.extend_from_slice(&size.to_le_bytes()); + out.extend_from_slice(&size.to_le_bytes()); + out.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(name_bytes); + out.extend_from_slice(data); + + central.extend_from_slice(&0x02014b50u32.to_le_bytes()); + central.extend_from_slice(&20u16.to_le_bytes()); + central.extend_from_slice(&20u16.to_le_bytes()); + central.extend_from_slice(&0x0800u16.to_le_bytes()); + central.extend_from_slice(&0u16.to_le_bytes()); + central.extend_from_slice(&dos_time.to_le_bytes()); + central.extend_from_slice(&dos_date.to_le_bytes()); + central.extend_from_slice(&crc.to_le_bytes()); + central.extend_from_slice(&size.to_le_bytes()); + central.extend_from_slice(&size.to_le_bytes()); + central.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes()); + central.extend_from_slice(&0u16.to_le_bytes()); + central.extend_from_slice(&0u16.to_le_bytes()); + central.extend_from_slice(&0u16.to_le_bytes()); + central.extend_from_slice(&0u16.to_le_bytes()); + central.extend_from_slice(&0u32.to_le_bytes()); + central.extend_from_slice(&offset.to_le_bytes()); + central.extend_from_slice(name_bytes); + } + let cd_offset = out.len() as u32; + out.extend_from_slice(¢ral); + let count = entries.len() as u16; + out.extend_from_slice(&0x06054b50u32.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&count.to_le_bytes()); + out.extend_from_slice(&count.to_le_bytes()); + out.extend_from_slice(&(central.len() as u32).to_le_bytes()); + out.extend_from_slice(&cd_offset.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out +} diff --git a/src/routes/zip.rs b/src/routes/zip.rs index 9088404..1d84408 100644 --- a/src/routes/zip.rs +++ b/src/routes/zip.rs @@ -122,7 +122,7 @@ fn unique_entry_name(used: &mut HashSet, filename: &str) -> String { /// MS-DOS timestamp (2-second resolution, no timezone; years 1980+ only — /// callers clamp earlier dates). -fn dos_datetime(t: DateTime) -> (u16, u16) { +pub(super) fn dos_datetime(t: DateTime) -> (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)