Album filters: star threshold as interactive stars control
ci / docker (push) Successful in 17s

Replaces the three fixed star chips with the Stars component as a
min-average selector, combinable with the verdict chips
This commit is contained in:
2026-07-17 18:59:26 +02:00
parent b5512aa7e5
commit 46dd8df55c
3 changed files with 35 additions and 16 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:
```sh
docker build -t registry.example.com/you/photos:0.4.2 .
docker push registry.example.com/you/photos:0.4.2
docker build -t registry.example.com/you/photos:0.4.3 .
docker push registry.example.com/you/photos:0.4.3
```
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.2 \
--set image.tag=0.4.3 \
--set publicUrl=https://photos.example.com \
--set ingress.host=photos.example.com \
--set config.oidcIssuer=https://auth.example.com \
+16 -13
View File
@@ -23,9 +23,6 @@ const FEEDBACK_FILTERS = [
{ 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:
@@ -432,28 +429,27 @@ 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.
// Two combinable filter dimensions over client feedback: verdict chips
// plus a minimum-average-stars threshold (0 = off).
const [filter, setFilter] = useState('all')
const matchesFilter = (p, key) => {
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')
if (key === 'undecided') return verdicts.length === 0
return (avgRating(p.id) ?? 0) >= Number(key.slice(4))
return verdicts.length === 0
}
const shown = ready.filter((p) => matchesFilter(p, filter))
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, star3: 0, star4: 0, star5: 0 }
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
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])
@@ -566,6 +562,13 @@ export default function AlbumPage() {
{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>
)}
+16
View File
@@ -675,6 +675,22 @@ progress {
justify-content: flex-start;
margin: 0 0 0.75rem;
}
.filter-stars {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: var(--panel);
border: 1px solid var(--panel-2);
border-radius: 999px;
padding: 0.1rem 0.7rem;
font-size: 0.85rem;
}
.filter-stars.active {
border-color: var(--accent);
}
.filter-stars .star {
cursor: pointer;
}
/* transient action feedback (keyboard votes that navigate away) */
.action-flash {