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
236 lines
8.2 KiB
React
236 lines
8.2 KiB
React
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { useParams } from 'react-router-dom'
|
|
import { api, postDownload } from '../api'
|
|
import Gallery from '../components/Gallery'
|
|
import Lightbox from '../components/Lightbox'
|
|
import SelectionBar from '../components/SelectionBar'
|
|
import Stars from '../components/Stars'
|
|
import TagEditor from '../components/TagEditor'
|
|
import Thumbs, { ACCEPT_GLYPH, REJECT_GLYPH } from '../components/Thumbs'
|
|
import useCulling from '../useCulling'
|
|
import useLightbox from '../useLightbox'
|
|
import useSelection from '../useSelection'
|
|
|
|
const FILTERS = [
|
|
{ key: 'all', label: 'All' },
|
|
{ key: 'accept', label: ACCEPT_GLYPH },
|
|
{ key: 'reject', label: REJECT_GLYPH },
|
|
{ key: 'undecided', label: 'Undecided' },
|
|
]
|
|
|
|
const matchesFilter = (photo, key) =>
|
|
key === 'all' || (key === 'undecided' ? !photo.my_verdict : photo.my_verdict === key)
|
|
|
|
export default function SharePage() {
|
|
const { token } = useParams()
|
|
const [view, setView] = useState(null)
|
|
const [error, setError] = useState(null)
|
|
const [password, setPassword] = useState('')
|
|
const [unlockError, setUnlockError] = useState(null)
|
|
const [filter, setFilter] = useState('all')
|
|
|
|
const photos = view?.photos ?? []
|
|
const visible = useMemo(() => photos.filter((p) => matchesFilter(p, filter)), [photos, filter])
|
|
const filterCounts = useMemo(() => {
|
|
const counts = { all: photos.length, accept: 0, reject: 0, undecided: 0 }
|
|
for (const p of photos) counts[p.my_verdict ?? 'undecided'] += 1
|
|
return counts
|
|
}, [photos])
|
|
|
|
// Selection spans the whole album (so switching filters keeps it), while
|
|
// the selection bar describes only the current view: its counts, bytes and
|
|
// downloads cover the visible photos, and "select all" adds them.
|
|
const { selected, toggle, selectAll, clear } = useSelection(photos)
|
|
const visibleSelected = visible.filter((p) => selected.has(p.id))
|
|
const sumBytes = (list) => list.reduce((sum, p) => sum + p.size_bytes, 0)
|
|
const lightbox = useLightbox(visible)
|
|
|
|
const load = useCallback(
|
|
() => api(`/api/share/${token}`).then(setView).catch((e) => setError(e.message)),
|
|
[token],
|
|
)
|
|
useEffect(() => {
|
|
load()
|
|
}, [load])
|
|
|
|
const unlock = async (e) => {
|
|
e.preventDefault()
|
|
try {
|
|
await api(`/api/share/${token}/unlock`, { method: 'POST', body: { password } })
|
|
setUnlockError(null)
|
|
load()
|
|
} catch (err) {
|
|
setUnlockError(err.status === 401 ? 'Wrong password' : err.message)
|
|
}
|
|
}
|
|
|
|
const patchPhoto = (photoId, patch) => {
|
|
setView((v) => ({
|
|
...v,
|
|
photos: v.photos.map((p) => (p.id === photoId ? { ...p, ...patch } : p)),
|
|
}))
|
|
}
|
|
|
|
// Optimistic write: patch local state, PUT, reload from the server on
|
|
// failure to undo the patch.
|
|
const saveFeedback = async (photo, patch, endpoint, body) => {
|
|
patchPhoto(photo.id, patch)
|
|
try {
|
|
await api(`/api/share/${token}/photos/${photo.id}/${endpoint}`, { method: 'PUT', body })
|
|
} catch {
|
|
load()
|
|
}
|
|
}
|
|
const setRating = (photo, rating) =>
|
|
saveFeedback(photo, { my_rating: rating || null }, 'rating', { rating })
|
|
const setVerdict = (photo, verdict) =>
|
|
saveFeedback(photo, { my_verdict: verdict }, 'verdict', { verdict })
|
|
const setTags = (photo, tags) => saveFeedback(photo, { my_tags: tags }, 'tags', { tags })
|
|
|
|
const culling = useCulling({
|
|
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>
|
|
|
|
if (view.locked) {
|
|
return (
|
|
<div className="center-page">
|
|
<form className="login-card" onSubmit={unlock}>
|
|
<h1>{view.album_name}</h1>
|
|
<p className="muted">This gallery is password protected.</p>
|
|
<input
|
|
type="password"
|
|
autoFocus
|
|
placeholder="Password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
/>
|
|
<button className="btn btn-primary" type="submit">
|
|
Open gallery
|
|
</button>
|
|
{unlockError && <p className="error">{unlockError}</p>}
|
|
</form>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<header className="share-head">
|
|
<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'} · 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">
|
|
{FILTERS.map((f) => (
|
|
<button
|
|
key={f.key}
|
|
className={`filter-chip${filter === f.key ? ' active' : ''}`}
|
|
onClick={() => setFilter(f.key)}
|
|
>
|
|
{f.label} {filterCounts[f.key]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</header>
|
|
<main className="page">
|
|
<Gallery
|
|
photos={visible}
|
|
onOpen={lightbox.openAt}
|
|
selected={view.allow_download ? selected : 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>{'★'.repeat(p.my_rating)}</span>}
|
|
{p.my_tags.length > 0 && <span># {p.my_tags.length}</span>}
|
|
</div>
|
|
) : null
|
|
}
|
|
/>
|
|
{photos.length === 0 && (
|
|
<p className="center-page muted">Nothing here yet — check back soon.</p>
|
|
)}
|
|
{photos.length > 0 && visible.length === 0 && (
|
|
<p className="center-page muted">No photos match this filter.</p>
|
|
)}
|
|
</main>
|
|
{view.allow_download && (
|
|
<SelectionBar
|
|
count={visibleSelected.length}
|
|
total={visible.length}
|
|
selectedBytes={sumBytes(visibleSelected)}
|
|
totalBytes={sumBytes(visible)}
|
|
onSelectAll={() => selectAll(visible)}
|
|
onClear={clear}
|
|
onDownload={() =>
|
|
postDownload(`/api/share/${token}/zip`, visibleSelected.map((p) => p.id).join(','))
|
|
}
|
|
onDownloadAll={() =>
|
|
// Under a filter, "download all" means all photos shown.
|
|
postDownload(
|
|
`/api/share/${token}/zip`,
|
|
filter === 'all' ? '' : visible.map((p) => p.id).join(','),
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
{lightbox.index >= 0 && (
|
|
<Lightbox
|
|
photos={lightbox.view}
|
|
index={lightbox.index}
|
|
onClose={lightbox.close}
|
|
onNav={lightbox.openAt}
|
|
{...culling.lightboxProps}
|
|
footer={(p) => (
|
|
<div className="client-footer">
|
|
<Thumbs value={p.my_verdict} onChange={(v) => setVerdict(p, v)} />
|
|
<Stars value={p.my_rating || 0} onChange={(r) => setRating(p, r)} />
|
|
<TagEditor tags={p.my_tags} onChange={(tags) => setTags(p, tags)} />
|
|
{view.allow_download && (
|
|
<label className="select-toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={selected.has(p.id)}
|
|
onChange={() => toggle(p.id)}
|
|
/>
|
|
select
|
|
</label>
|
|
)}
|
|
{view.allow_download && (
|
|
<a className="btn" href={`/api/photos/${p.id}/original`}>
|
|
Download original
|
|
</a>
|
|
)}
|
|
</div>
|
|
)}
|
|
/>
|
|
)}
|
|
{culling.flash && (
|
|
<div key={culling.flash.key} className="action-flash">
|
|
{culling.flash.text}
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|