1 Commits
Author SHA1 Message Date
nils b5512aa7e5 Album view: filter by client feedback; mobile share-panel layout
ci / docker (push) Successful in 8m10s
- filter bar over verdicts and average star rating (All / thumbs /
  undecided / 3+ / 4+ / 5) with live counts; gallery, lightbox,
  selection, ZIP download and bulk delete all operate on the filtered
  view
- share rows and the create form stack vertically on small screens
2026-07-17 17:37:23 +02:00
3 changed files with 110 additions and 24 deletions
+3 -3
View File
@@ -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: `server`, `worker`, and the built frontend). To build your own instead:
```sh ```sh
docker build -t 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.0 docker push registry.example.com/you/photos:0.4.2
``` ```
Install the chart, pointing it at your existing Postgres and S3: 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 ```sh
helm install photos deploy/chart \ helm install photos deploy/chart \
--set image.repository=git.draic.info/nils/photos \ --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 publicUrl=https://photos.example.com \
--set ingress.host=photos.example.com \ --set ingress.host=photos.example.com \
--set config.oidcIssuer=https://auth.example.com \ --set config.oidcIssuer=https://auth.example.com \
+85 -21
View File
@@ -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 { Link, useNavigate, useParams } from 'react-router-dom'
import { api, postDownload, sha256Hex, uploadFile } from '../api' import { api, postDownload, sha256Hex, uploadFile } from '../api'
import Gallery from '../components/Gallery' import Gallery from '../components/Gallery'
@@ -18,6 +18,16 @@ function fmtEta(seconds) {
const UPLOAD_CONCURRENCY = 3 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: // 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 // end of the chosen day in the photographer's local timezone — date-only
// strings would parse as UTC midnight and expire a day early. // strings would parse as UTC midnight and expire a day early.
@@ -414,14 +424,7 @@ export default function AlbumPage() {
}, [hasPending, load]) }, [hasPending, load])
const ready = (detail?.photos ?? []).filter((p) => p.status === 'ready') const ready = (detail?.photos ?? []).filter((p) => p.status === 'ready')
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(ready) const feedback = detail?.feedback ?? {}
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 avgRating = (photoId) => { const avgRating = (photoId) => {
const ratings = feedback[photoId]?.ratings || [] const ratings = feedback[photoId]?.ratings || []
@@ -429,6 +432,45 @@ export default function AlbumPage() {
return ratings.reduce((sum, r) => sum + r.rating, 0) / ratings.length 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 <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 rename = async () => {
const name = prompt('Album name', album.name) const name = prompt('Album name', album.name)
if (name && name.trim()) { if (name && name.trim()) {
@@ -451,10 +493,11 @@ export default function AlbumPage() {
} }
const removeSelected = async () => { 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 return
try { try {
await api('/api/photos/delete', { method: 'POST', body: { ids: [...selected] } }) await api('/api/photos/delete', { method: 'POST', body: { ids } })
clear() clear()
} catch (e) { } catch (e) {
alert(`Delete failed: ${e.message}`) alert(`Delete failed: ${e.message}`)
@@ -512,11 +555,25 @@ export default function AlbumPage() {
</section> </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>
))}
</div>
)}
<Gallery <Gallery
photos={ready} photos={shown}
onOpen={lightbox.openAt} onOpen={lightbox.openAt}
selected={selected} selected={selected}
onToggleSelect={toggle} onToggleSelect={(photoId, shift) => toggle(photoId, shift, shown)}
overlay={(p) => { overlay={(p) => {
const avg = avgRating(p.id) const avg = avgRating(p.id)
const tagCount = feedback[p.id]?.tags.length || 0 const tagCount = feedback[p.id]?.tags.length || 0
@@ -537,22 +594,29 @@ export default function AlbumPage() {
{ready.length === 0 && notReady.length === 0 && ( {ready.length === 0 && notReady.length === 0 && (
<p className="muted">No photos yet drop some above.</p> <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 <SelectionBar
count={selected.size} count={shownSelected.length}
total={ready.length} total={shown.length}
selectedBytes={selectedBytes} selectedBytes={sumBytes(shownSelected)}
totalBytes={totalBytes} totalBytes={sumBytes(shown)}
onSelectAll={() => selectAll(ready)} onSelectAll={() => selectAll(shown)}
onClear={clear} onClear={clear}
onDownload={() => postDownload(`/api/albums/${id}/zip`, [...selected].join(','))} onDownload={() =>
onDownloadAll={() => postDownload(`/api/albums/${id}/zip`)} 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} onDelete={removeSelected}
/> />
{lightbox.index >= 0 && ( {lightbox.index >= 0 && (
<Lightbox <Lightbox
photos={ready} photos={shown}
index={lightbox.index} index={lightbox.index}
onClose={lightbox.close} onClose={lightbox.close}
onNav={lightbox.openAt} onNav={lightbox.openAt}
+22
View File
@@ -671,6 +671,10 @@ progress {
color: var(--text); color: var(--text);
border-color: var(--accent); border-color: var(--accent);
} }
.filter-bar-admin {
justify-content: flex-start;
margin: 0 0 0.75rem;
}
/* transient action feedback (keyboard votes that navigate away) */ /* transient action feedback (keyboard votes that navigate away) */
.action-flash { .action-flash {
@@ -819,4 +823,22 @@ kbd {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
bottom: calc(0.75rem + env(safe-area-inset-bottom)); bottom: calc(0.75rem + env(safe-area-inset-bottom));
} }
/* Share rows: info and controls stack instead of fighting for one line. */
.share-row {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
padding: 0.75rem 0;
}
.share-row .row {
flex-wrap: wrap;
row-gap: 0.5rem;
}
.share-form {
flex-direction: column;
align-items: stretch;
}
.share-form > input {
width: 100%;
}
} }