- verdicts table (per link, like ratings), PUT verdict endpoint, typed Verdict enum - share page: thumbs up/down, verdict filter with counts, view-scoped selection bar - lightbox: per-page shortcut table (P/X/U, 1-5/0, S), ? help overlay, action toast when a keyboard vote auto-advances - shared useLightbox hook; single keydown subscription reading live state via ref - album view: per-link thumbs and vote counts; share list shows accept/reject totals - feedback queries deduped and run concurrently; verdict counts in one scan
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { api, postDownload } from '../api'
|
||||
import Gallery from '../components/Gallery'
|
||||
@@ -6,18 +6,43 @@ 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 useLightbox from '../useLightbox'
|
||||
import useSelection from '../useSelection'
|
||||
|
||||
const FILTERS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'accept', label: '👍' },
|
||||
{ key: 'reject', label: '👎' },
|
||||
{ key: 'undecided', label: 'Undecided' },
|
||||
]
|
||||
|
||||
const matchesFilter = (photo, key) =>
|
||||
key === 'all' || (key === 'undecided' ? !photo.my_verdict : photo.my_verdict === key)
|
||||
|
||||
export default function SharePage() {
|
||||
const { token } = useParams()
|
||||
const [view, setView] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [password, setPassword] = useState('')
|
||||
const [unlockError, setUnlockError] = useState(null)
|
||||
const [lightbox, setLightbox] = useState(-1)
|
||||
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(
|
||||
view?.photos ?? [],
|
||||
)
|
||||
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)),
|
||||
@@ -45,29 +70,75 @@ export default function SharePage() {
|
||||
}))
|
||||
}
|
||||
|
||||
const setRating = async (photo, rating) => {
|
||||
patchPhoto(photo.id, { my_rating: rating || null })
|
||||
// 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}/rating`, {
|
||||
method: 'PUT',
|
||||
body: { rating },
|
||||
})
|
||||
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 })
|
||||
|
||||
// Keyboard votes navigate away from the photo they change, so a transient
|
||||
// toast names what just happened to it — without it the jump reads as
|
||||
// "did that register?".
|
||||
const [flash, setFlash] = useState(null)
|
||||
const flashSeq = useRef(0)
|
||||
const flashTimer = useRef()
|
||||
const showFlash = (text) => {
|
||||
flashSeq.current += 1
|
||||
setFlash({ text, key: flashSeq.current })
|
||||
clearTimeout(flashTimer.current)
|
||||
flashTimer.current = setTimeout(() => setFlash(null), 1400)
|
||||
}
|
||||
useEffect(() => () => clearTimeout(flashTimer.current), [])
|
||||
|
||||
// Culling flow: vote, then advance to the photo that was next in the
|
||||
// current view. Under a filter that hides the voted photo, the advance
|
||||
// target stays visible, so the run continues seamlessly.
|
||||
const voteAndAdvance = (photo, verdict) => {
|
||||
const next = visible[lightbox.index + 1]
|
||||
setVerdict(photo, verdict)
|
||||
showFlash(
|
||||
verdict === 'accept'
|
||||
? `👍 ${photo.filename}`
|
||||
: verdict === 'reject'
|
||||
? `👎 ${photo.filename}`
|
||||
: `↺ ${photo.filename} cleared`,
|
||||
)
|
||||
if (next) lightbox.show(next.id)
|
||||
}
|
||||
|
||||
const setTags = async (photo, tags) => {
|
||||
patchPhoto(photo.id, { my_tags: tags })
|
||||
try {
|
||||
await api(`/api/share/${token}/photos/${photo.id}/tags`, {
|
||||
method: 'PUT',
|
||||
body: { tags },
|
||||
})
|
||||
} catch {
|
||||
load()
|
||||
}
|
||||
}
|
||||
const keyActions = [
|
||||
{ keys: ['p'], help: ['P', 'accept and go to next'], run: (p) => voteAndAdvance(p, 'accept') },
|
||||
{ keys: ['x'], help: ['X', 'reject and go to next'], run: (p) => voteAndAdvance(p, 'reject') },
|
||||
{
|
||||
keys: ['u'],
|
||||
help: ['U', 'clear accept / reject'],
|
||||
// When clearing hides the photo from the current filter, advance like
|
||||
// a vote so the lightbox doesn't just close.
|
||||
run: (p) =>
|
||||
matchesFilter({ my_verdict: null }, filter)
|
||||
? setVerdict(p, null)
|
||||
: voteAndAdvance(p, null),
|
||||
},
|
||||
{
|
||||
keys: ['1', '2', '3', '4', '5'],
|
||||
help: ['1–5', 'star rating'],
|
||||
run: (p, key) => setRating(p, Number(key)),
|
||||
},
|
||||
{ keys: ['0'], help: ['0', 'clear star rating'], run: (p) => setRating(p, 0) },
|
||||
...(view?.allow_download
|
||||
? [{ keys: ['s'], help: ['S', 'select for download'], run: (p) => toggle(p.id) }]
|
||||
: []),
|
||||
]
|
||||
|
||||
if (error) return <div className="center-page">{error}</div>
|
||||
if (!view) return <div className="center-page">Loading…</div>
|
||||
@@ -100,49 +171,76 @@ export default function SharePage() {
|
||||
<h1>{view.album_name}</h1>
|
||||
{view.album_description && <p className="muted">{view.album_description}</p>}
|
||||
<p className="muted">
|
||||
{view.photos.length} photo{view.photos.length === 1 ? '' : 's'} · click a photo to view,
|
||||
rate and tag
|
||||
{photos.length} photo{photos.length === 1 ? '' : 's'} · click a photo to view, rate and
|
||||
tag · press <kbd>?</kbd> in the viewer for 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={view.photos}
|
||||
onOpen={setLightbox}
|
||||
photos={visible}
|
||||
onOpen={lightbox.openAt}
|
||||
selected={view.allow_download ? selected : undefined}
|
||||
onToggleSelect={view.allow_download ? toggle : undefined}
|
||||
overlay={(p) =>
|
||||
p.my_rating || p.my_tags.length > 0 ? (
|
||||
p.my_verdict || p.my_rating || p.my_tags.length > 0 ? (
|
||||
<div className="g-overlay">
|
||||
{p.my_verdict && <span>{p.my_verdict === 'accept' ? '👍' : '👎'}</span>}
|
||||
{p.my_rating && <span>★ {p.my_rating}</span>}
|
||||
{p.my_tags.length > 0 && <span># {p.my_tags.length}</span>}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{view.photos.length === 0 && (
|
||||
{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={selected.size}
|
||||
total={view.photos.length}
|
||||
selectedBytes={selectedBytes}
|
||||
totalBytes={totalBytes}
|
||||
onSelectAll={selectAll}
|
||||
count={visibleSelected.length}
|
||||
total={visible.length}
|
||||
selectedBytes={sumBytes(visibleSelected)}
|
||||
totalBytes={sumBytes(visible)}
|
||||
onSelectAll={() => selectAll(visible)}
|
||||
onClear={clear}
|
||||
onDownload={() => postDownload(`/api/share/${token}/zip`, [...selected].join(','))}
|
||||
onDownloadAll={() => postDownload(`/api/share/${token}/zip`)}
|
||||
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 >= 0 && (
|
||||
{lightbox.index >= 0 && (
|
||||
<Lightbox
|
||||
photos={view.photos}
|
||||
index={lightbox}
|
||||
onClose={() => setLightbox(-1)}
|
||||
onNav={setLightbox}
|
||||
photos={visible}
|
||||
index={lightbox.index}
|
||||
onClose={lightbox.close}
|
||||
onNav={lightbox.openAt}
|
||||
actions={keyActions}
|
||||
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 && (
|
||||
@@ -164,6 +262,11 @@ export default function SharePage() {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{flash && (
|
||||
<div key={flash.key} className="action-flash">
|
||||
{flash.text}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user