web: star ratings on the reel, and PICTURES becomes an album browser

The landing strip now carries a score: each look and each contributed
frame shows an average, five stars the visitor can press, and how many
votes it has. Votes are keyed photo:<id> or look:<TAG> and one visitor
has one vote per key, so pressing a second star moves a score instead of
stacking one. The API is public and rate-limited; look: scores survive a
cleared pool, photo: scores are pruned with their photo.

PICTURES was four destination rows; it is now an album per uploader with
a search box, a recipe/rating/newest sort, a minimum-star filter, a big
preview and a filmstrip of thumbnails. Deleting and slot picking still
live in the big box.
This commit is contained in:
2026-09-18 19:17:38 +07:00
parent 4057566a14
commit 07fbadcdc5
10 changed files with 558 additions and 150 deletions
+24
View File
@@ -36,6 +36,8 @@ import {
photoFile,
photoPath,
photoPreset,
rateLook,
ratingsFor,
sessionUser,
setPhotoSlots,
setPhotoConsent,
@@ -346,6 +348,28 @@ app.post('/api/events', async (req, reply) => {
return reply.status(204).send();
});
// The film strip's ratings. Public and unauthenticated for the same reason the
// counter is: any visitor may score a frame once, and the vote is held against
// the salted-address hash rather than an account. The subject is a photo id or
// a built-in look's tag, so both kinds of frame are rated through one route.
const allowRate = limiter(120, 60_000);
const RATING_KEY = /^[A-Za-z0-9:_-]{1,64}$/;
app.get('/api/ratings', async (req) => ({ ratings: ratingsFor(visitorOf(clientIp(req) || 'unknown')) }));
app.post('/api/ratings', async (req, reply) => {
const b = bodyOf(req);
const ip = clientIp(req);
const key = typeof b?.key === 'string' ? b.key.trim() : '';
const stars = Math.round(Number(b?.stars));
if (!RATING_KEY.test(key) || !Number.isFinite(stars) || stars < 1 || stars > 5)
return reply.status(400).send({ error: 'invalid rating' });
if (!allowRate(ip || 'unknown')) return tooMany(reply);
const visitor = visitorOf(ip || 'unknown');
rateLook(key, visitor, stars);
return reply.status(200).send({ key, rating: ratingsFor(visitor)[key] });
});
app.post('/api/auth/signup', async (req, reply) => {
const b = bodyOf(req);
if (!b) return reply.status(400).send({ error: 'invalid body' });