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
+36
View File
@@ -528,6 +528,42 @@ try {
check('a deleted account cannot sign in', (await login(`moderated${stamp}@test.local`, 'supersecret1')).status === 401);
check('a deleted account’s photo file is unlinked', (await fetch(targetPhotoUrl)).status === 404);
// ---- film-strip ratings -------------------------------------------------
// Public and one-per-visitor: two addresses are two voters, and a second vote
// from the same address replaces the first instead of adding to it.
const vote = (ip, key, stars) =>
fetch(`${BASE}/ratings`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': ip },
body: JSON.stringify({ key, stars }),
});
const readRatings = async (ip) =>
((await (await fetch(`${BASE}/ratings`, { headers: { 'x-forwarded-for': ip } })).json()).ratings ?? {});
check('a guest may rate a frame', (await vote('10.9.9.1', 'look:TEST_LOOK', 5)).status === 200);
await vote('10.9.9.2', 'look:TEST_LOOK', 3);
const tally = (await readRatings('10.9.9.1'))['look:TEST_LOOK'];
check('the tally averages every vote', tally?.avg === 4 && tally?.n === 2, JSON.stringify(tally));
check('a visitor reads back their own vote', tally?.mine === 5);
check('a stranger has no vote of their own', (await readRatings('10.9.9.3'))['look:TEST_LOOK']?.mine === 0);
await vote('10.9.9.1', 'look:TEST_LOOK', 1);
const changed = (await readRatings('10.9.9.1'))['look:TEST_LOOK'];
check('a second vote replaces the first', changed?.avg === 2 && changed?.n === 2 && changed?.mine === 1, JSON.stringify(changed));
check('a six-star score is refused', (await vote('10.9.9.1', 'look:TEST_LOOK', 6)).status === 400);
check('a zero-star score is refused', (await vote('10.9.9.1', 'look:TEST_LOOK', 0)).status === 400);
check('a malformed key is refused', (await vote('10.9.9.1', 'bad key!', 3)).status === 400);
check('an empty key is refused', (await vote('10.9.9.1', '', 3)).status === 400);
const throwaway = await user.upload(PNG, 'image/png');
const throwawayId = throwaway.body?.photo?.id;
check('the throwaway upload lands', Number.isInteger(throwawayId), JSON.stringify(throwaway.body));
await vote('10.9.9.4', `photo:${throwawayId}`, 5);
await user.req(`/photos/${throwawayId}`, { method: 'DELETE' });
check(
'a deleted photo takes its votes with it',
!Object.hasOwn(await readRatings('10.9.9.4'), `photo:${throwawayId}`),
);
// ---- pre-existing guarantees still hold ---------------------------------
const foreignRecipe = await user.req('/recipes/1', { method: 'DELETE' });
check("another account's recipe is not deletable", foreignRecipe.status === 404, `got ${foreignRecipe.status}`);