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)
|
||||
|
||||
Reference in New Issue
Block a user