diff --git a/README.md b/README.md index f7c85e4..527a0df 100644 --- a/README.md +++ b/README.md @@ -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.0 . -docker push registry.example.com/you/photos:0.4.0 +docker build -t registry.example.com/you/photos:0.4.2 . +docker push registry.example.com/you/photos:0.4.2 ``` 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.0 \ + --set image.tag=0.4.2 \ --set publicUrl=https://photos.example.com \ --set ingress.host=photos.example.com \ --set config.oidcIssuer=https://auth.example.com \ diff --git a/frontend/src/pages/AlbumPage.jsx b/frontend/src/pages/AlbumPage.jsx index 397d5c1..9217e91 100644 --- a/frontend/src/pages/AlbumPage.jsx +++ b/frontend/src/pages/AlbumPage.jsx @@ -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,16 @@ function fmtEta(seconds) { const UPLOAD_CONCURRENCY = 3 +const FEEDBACK_FILTERS = [ + { key: 'all', label: 'All' }, + { key: 'accept', label: '👍' }, + { key: 'reject', label: '👎' }, + { key: 'undecided', label: 'Undecided' }, + { key: 'star3', label: '★ 3+' }, + { key: 'star4', label: '★ 4+' }, + { key: 'star5', label: '★ 5' }, +] + // 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 +424,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
{error}
- if (!detail) returnLoading…
- - 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 +432,45 @@ export default function AlbumPage() { return ratings.reduce((sum, r) => sum + r.rating, 0) / ratings.length } + // Filter over client feedback: verdicts, or minimum average star rating. + const [filter, setFilter] = useState('all') + const matchesFilter = (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') + if (key === 'undecided') return verdicts.length === 0 + return (avgRating(p.id) ?? 0) >= Number(key.slice(4)) + } + const shown = ready.filter((p) => matchesFilter(p, filter)) + const filterCounts = useMemo(() => { + const counts = { all: ready.length, accept: 0, reject: 0, undecided: 0, star3: 0, star4: 0, star5: 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 avg = avgRating(p.id) ?? 0 + if (avg >= 3) counts.star3 += 1 + if (avg >= 4) counts.star4 += 1 + if (avg >= 5) counts.star5 += 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{error}
+ if (!detail) returnLoading…
+ + 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 +493,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 +555,25 @@ export default function AlbumPage() { )} + {ready.length > 0 && ( +No photos yet — drop some above.
)} + {ready.length > 0 && shown.length === 0 && ( +No photos match this filter.
+ )}