|
|
|
@@ -1,4 +1,4 @@
|
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
|
|
|
|
import { api, postDownload, sha256Hex, uploadFile } from '../api'
|
|
|
|
|
import Gallery from '../components/Gallery'
|
|
|
|
@@ -18,6 +18,13 @@ function fmtEta(seconds) {
|
|
|
|
|
|
|
|
|
|
const UPLOAD_CONCURRENCY = 3
|
|
|
|
|
|
|
|
|
|
const FEEDBACK_FILTERS = [
|
|
|
|
|
{ key: 'all', label: 'All' },
|
|
|
|
|
{ key: 'accept', label: '👍' },
|
|
|
|
|
{ key: 'reject', label: '👎' },
|
|
|
|
|
{ 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.
|
|
|
|
@@ -414,14 +421,7 @@ export default function AlbumPage() {
|
|
|
|
|
}, [hasPending, load])
|
|
|
|
|
|
|
|
|
|
const ready = (detail?.photos ?? []).filter((p) => p.status === 'ready')
|
|
|
|
|
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(ready)
|
|
|
|
|
const lightbox = useLightbox(ready)
|
|
|
|
|
|
|
|
|
|
if (error) return <p className="error">{error}</p>
|
|
|
|
|
if (!detail) return <p className="muted">Loading…</p>
|
|
|
|
|
|
|
|
|
|
const { album, photos, feedback } = detail
|
|
|
|
|
const notReady = photos.filter((p) => p.status !== 'ready')
|
|
|
|
|
const feedback = detail?.feedback ?? {}
|
|
|
|
|
|
|
|
|
|
const avgRating = (photoId) => {
|
|
|
|
|
const ratings = feedback[photoId]?.ratings || []
|
|
|
|
@@ -429,6 +429,44 @@ export default function AlbumPage() {
|
|
|
|
|
return ratings.reduce((sum, r) => sum + r.rating, 0) / ratings.length
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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) => {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
const shown = ready.filter(
|
|
|
|
|
(p) => matchesVerdict(p, filter) && (minStars === 0 || (avgRating(p.id) ?? 0) >= 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
|
|
|
|
|
}
|
|
|
|
|
return counts
|
|
|
|
|
}, [detail])
|
|
|
|
|
|
|
|
|
|
// Selection spans all ready photos; the bar and bulk actions cover only
|
|
|
|
|
// the current view, like on the share page.
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
if (error) return <p className="error">{error}</p>
|
|
|
|
|
if (!detail) return <p className="muted">Loading…</p>
|
|
|
|
|
|
|
|
|
|
const { album, photos } = detail
|
|
|
|
|
const notReady = photos.filter((p) => p.status !== 'ready')
|
|
|
|
|
|
|
|
|
|
const rename = async () => {
|
|
|
|
|
const name = prompt('Album name', album.name)
|
|
|
|
|
if (name && name.trim()) {
|
|
|
|
@@ -451,10 +489,11 @@ export default function AlbumPage() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const removeSelected = async () => {
|
|
|
|
|
if (!confirm(`Delete ${selected.size} selected photo${selected.size === 1 ? '' : 's'}? This cannot be undone.`))
|
|
|
|
|
const ids = shownSelected.map((p) => p.id)
|
|
|
|
|
if (!confirm(`Delete ${ids.length} selected photo${ids.length === 1 ? '' : 's'}? This cannot be undone.`))
|
|
|
|
|
return
|
|
|
|
|
try {
|
|
|
|
|
await api('/api/photos/delete', { method: 'POST', body: { ids: [...selected] } })
|
|
|
|
|
await api('/api/photos/delete', { method: 'POST', body: { ids } })
|
|
|
|
|
clear()
|
|
|
|
|
} catch (e) {
|
|
|
|
|
alert(`Delete failed: ${e.message}`)
|
|
|
|
@@ -512,11 +551,32 @@ export default function AlbumPage() {
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{ready.length > 0 && (
|
|
|
|
|
<div className="filter-bar filter-bar-admin">
|
|
|
|
|
{FEEDBACK_FILTERS.map((f) => (
|
|
|
|
|
<button
|
|
|
|
|
key={f.key}
|
|
|
|
|
className={`filter-chip${filter === f.key ? ' active' : ''}`}
|
|
|
|
|
onClick={() => setFilter(f.key)}
|
|
|
|
|
>
|
|
|
|
|
{f.label} {filterCounts[f.key]}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
<span
|
|
|
|
|
className={`filter-stars${minStars > 0 ? ' active' : ''}`}
|
|
|
|
|
title="Minimum average rating — click a star to filter, click it again to clear"
|
|
|
|
|
>
|
|
|
|
|
<Stars value={minStars} onChange={(n) => setMinStars(n)} small />
|
|
|
|
|
{minStars > 0 && <span className="muted">≥ {minStars}</span>}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<Gallery
|
|
|
|
|
photos={ready}
|
|
|
|
|
photos={shown}
|
|
|
|
|
onOpen={lightbox.openAt}
|
|
|
|
|
selected={selected}
|
|
|
|
|
onToggleSelect={toggle}
|
|
|
|
|
onToggleSelect={(photoId, shift) => toggle(photoId, shift, shown)}
|
|
|
|
|
overlay={(p) => {
|
|
|
|
|
const avg = avgRating(p.id)
|
|
|
|
|
const tagCount = feedback[p.id]?.tags.length || 0
|
|
|
|
@@ -537,22 +597,29 @@ export default function AlbumPage() {
|
|
|
|
|
{ready.length === 0 && notReady.length === 0 && (
|
|
|
|
|
<p className="muted">No photos yet — drop some above.</p>
|
|
|
|
|
)}
|
|
|
|
|
{ready.length > 0 && shown.length === 0 && (
|
|
|
|
|
<p className="muted">No photos match this filter.</p>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<SelectionBar
|
|
|
|
|
count={selected.size}
|
|
|
|
|
total={ready.length}
|
|
|
|
|
selectedBytes={selectedBytes}
|
|
|
|
|
totalBytes={totalBytes}
|
|
|
|
|
onSelectAll={() => selectAll(ready)}
|
|
|
|
|
count={shownSelected.length}
|
|
|
|
|
total={shown.length}
|
|
|
|
|
selectedBytes={sumBytes(shownSelected)}
|
|
|
|
|
totalBytes={sumBytes(shown)}
|
|
|
|
|
onSelectAll={() => selectAll(shown)}
|
|
|
|
|
onClear={clear}
|
|
|
|
|
onDownload={() => postDownload(`/api/albums/${id}/zip`, [...selected].join(','))}
|
|
|
|
|
onDownloadAll={() => postDownload(`/api/albums/${id}/zip`)}
|
|
|
|
|
onDownload={() =>
|
|
|
|
|
postDownload(`/api/albums/${id}/zip`, shownSelected.map((p) => p.id).join(','))
|
|
|
|
|
}
|
|
|
|
|
onDownloadAll={() =>
|
|
|
|
|
postDownload(`/api/albums/${id}/zip`, filter === 'all' ? '' : shown.map((p) => p.id).join(','))
|
|
|
|
|
}
|
|
|
|
|
onDelete={removeSelected}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{lightbox.index >= 0 && (
|
|
|
|
|
<Lightbox
|
|
|
|
|
photos={ready}
|
|
|
|
|
photos={shown}
|
|
|
|
|
index={lightbox.index}
|
|
|
|
|
onClose={lightbox.close}
|
|
|
|
|
onNav={lightbox.openAt}
|
|
|
|
|