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:
@@ -75,6 +75,13 @@ CREATE TABLE IF NOT EXISTS events (
|
||||
os TEXT,
|
||||
device TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ratings (
|
||||
key TEXT NOT NULL,
|
||||
visitor TEXT NOT NULL,
|
||||
stars INTEGER NOT NULL,
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (key, visitor)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_user ON recipes(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_user ON photos(user_id);
|
||||
@@ -346,7 +353,15 @@ export type Photo = {
|
||||
// The owner's own row adds the look that made it, so it can be opened again,
|
||||
// and the looks it carried before: newest first, at most PHOTO_HISTORY_MAX.
|
||||
export type MyPhoto = Photo & { recipe: unknown | null; history: unknown[] };
|
||||
export type AdminPhoto = Photo & { userId: number; email: string; mime: string; bytes: number };
|
||||
export type AdminPhoto = Photo & {
|
||||
userId: number;
|
||||
email: string;
|
||||
mime: string;
|
||||
bytes: number;
|
||||
// The name of the look the photo was saved with, when it still carries one —
|
||||
// the curator's A→Z ordering key. Null for a photo uploaded without a look.
|
||||
recipeName: string | null;
|
||||
};
|
||||
export type PhotoMeta = {
|
||||
tag?: string | null;
|
||||
title?: string | null;
|
||||
@@ -412,12 +427,22 @@ export function listPhotosWithOwner(): AdminPhoto[] {
|
||||
.prepare(
|
||||
`SELECT ${PHOTO_COLUMNS},
|
||||
photos.user_id AS userId, photos.mime AS mime, photos.bytes AS bytes,
|
||||
users.email AS email
|
||||
photos.recipe AS recipe, users.email AS email
|
||||
FROM photos JOIN users ON users.id = photos.user_id
|
||||
ORDER BY photos.id DESC`,
|
||||
)
|
||||
.all() as (PhotoRow & { userId: number; email: string; mime: string; bytes: number })[]
|
||||
).map((row) => ({ ...toPhoto(row), userId: row.userId, email: row.email, mime: row.mime, bytes: row.bytes }));
|
||||
.all() as (PhotoRow & { userId: number; email: string; mime: string; bytes: number; recipe: string | null })[]
|
||||
).map((row) => {
|
||||
const recipe = parseJson(row.recipe) as { name?: unknown } | null;
|
||||
return {
|
||||
...toPhoto(row),
|
||||
userId: row.userId,
|
||||
email: row.email,
|
||||
mime: row.mime,
|
||||
bytes: row.bytes,
|
||||
recipeName: typeof recipe?.name === 'string' ? recipe.name : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// A member's own folder, newest first. No JOIN: the owner is the caller. This
|
||||
@@ -486,6 +511,7 @@ export function deleteUser(id: number): { photos: string[]; avatar: string | nul
|
||||
(r) => r.file,
|
||||
);
|
||||
if (db.prepare('DELETE FROM users WHERE id = ?').run(id).changes === 0) return undefined;
|
||||
db.prepare(`DELETE FROM ratings WHERE key IN (SELECT 'photo:' || id FROM photos WHERE user_id = ?)`).run(id);
|
||||
db.prepare('DELETE FROM photos WHERE user_id = ?').run(id);
|
||||
db.prepare('DELETE FROM recipes WHERE user_id = ?').run(id);
|
||||
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id);
|
||||
@@ -633,6 +659,7 @@ export function deletePhoto(id: number): string | undefined {
|
||||
const row = db.prepare('SELECT file FROM photos WHERE id = ?').get(id) as { file: string } | undefined;
|
||||
if (!row) return undefined;
|
||||
db.prepare('DELETE FROM photos WHERE id = ?').run(id);
|
||||
db.prepare('DELETE FROM ratings WHERE key = ?').run(`photo:${id}`);
|
||||
return row.file;
|
||||
}
|
||||
|
||||
@@ -644,6 +671,7 @@ export function deletePhotoOf(userId: number, id: number): string | undefined {
|
||||
| undefined;
|
||||
if (!row) return undefined;
|
||||
db.prepare('DELETE FROM photos WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
db.prepare('DELETE FROM ratings WHERE key = ?').run(`photo:${id}`);
|
||||
return row.file;
|
||||
}
|
||||
|
||||
@@ -656,9 +684,41 @@ export function setPhotoSlots(id: number, slots: readonly PhotoSlot[]): boolean
|
||||
export function deleteAllPhotos(): string[] {
|
||||
const files = (db.prepare('SELECT file FROM photos').all() as { file: string }[]).map((r) => r.file);
|
||||
db.prepare('DELETE FROM photos').run();
|
||||
// The votes go with the rows; the built-in reel's own keys are left alone.
|
||||
db.prepare(`DELETE FROM ratings WHERE key LIKE 'photo:%'`).run();
|
||||
return files;
|
||||
}
|
||||
|
||||
// --- ratings ---------------------------------------------------------------
|
||||
// One row per (subject, visitor): a viewer's vote on a film-strip frame. The
|
||||
// subject is `photo:<id>` for a contribution or `look:<TAG>` for a built-in
|
||||
// look, so every frame on the strip is rated the same way. `visitor` is the
|
||||
// salted-address hash the counter already uses, which is what makes a vote
|
||||
// one-per-visitor without an account and without storing anything identifying.
|
||||
export type Rating = { avg: number; n: number; mine: number };
|
||||
|
||||
export function rateLook(key: string, visitor: string, stars: number): void {
|
||||
db.prepare(
|
||||
`INSERT INTO ratings (key, visitor, stars, at) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(key, visitor) DO UPDATE SET stars = excluded.stars, at = excluded.at`,
|
||||
).run(key, visitor, stars, new Date().toISOString());
|
||||
}
|
||||
|
||||
// Every subject's tally, keyed by subject. `mine` is this visitor's own vote, 0
|
||||
// when they have not rated it — the landing draws the star row from it.
|
||||
export function ratingsFor(visitor: string): Record<string, Rating> {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT key, AVG(stars) AS avg, COUNT(*) AS n,
|
||||
MAX(CASE WHEN visitor = ? THEN stars END) AS mine
|
||||
FROM ratings GROUP BY key`,
|
||||
)
|
||||
.all(visitor) as { key: string; avg: number; n: number; mine: number | null }[];
|
||||
const out: Record<string, Rating> = {};
|
||||
for (const r of rows) out[r.key] = { avg: Math.round(r.avg * 100) / 100, n: r.n, mine: r.mine ?? 0 };
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- analytics -------------------------------------------------------------
|
||||
// One row per page view or feature click. Nothing that identifies a visitor is
|
||||
// stored: the address is turned into a salted hash (enough to count uniques)
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user