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,
|
os TEXT,
|
||||||
device 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_sessions_user ON sessions(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipes_user ON recipes(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);
|
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,
|
// 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.
|
// and the looks it carried before: newest first, at most PHOTO_HISTORY_MAX.
|
||||||
export type MyPhoto = Photo & { recipe: unknown | null; history: unknown[] };
|
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 = {
|
export type PhotoMeta = {
|
||||||
tag?: string | null;
|
tag?: string | null;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
@@ -412,12 +427,22 @@ export function listPhotosWithOwner(): AdminPhoto[] {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT ${PHOTO_COLUMNS},
|
`SELECT ${PHOTO_COLUMNS},
|
||||||
photos.user_id AS userId, photos.mime AS mime, photos.bytes AS bytes,
|
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
|
FROM photos JOIN users ON users.id = photos.user_id
|
||||||
ORDER BY photos.id DESC`,
|
ORDER BY photos.id DESC`,
|
||||||
)
|
)
|
||||||
.all() as (PhotoRow & { userId: number; email: string; mime: string; bytes: number })[]
|
.all() as (PhotoRow & { userId: number; email: string; mime: string; bytes: number; recipe: string | null })[]
|
||||||
).map((row) => ({ ...toPhoto(row), userId: row.userId, email: row.email, mime: row.mime, bytes: row.bytes }));
|
).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
|
// 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,
|
(r) => r.file,
|
||||||
);
|
);
|
||||||
if (db.prepare('DELETE FROM users WHERE id = ?').run(id).changes === 0) return undefined;
|
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 photos WHERE user_id = ?').run(id);
|
||||||
db.prepare('DELETE FROM recipes 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);
|
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;
|
const row = db.prepare('SELECT file FROM photos WHERE id = ?').get(id) as { file: string } | undefined;
|
||||||
if (!row) return undefined;
|
if (!row) return undefined;
|
||||||
db.prepare('DELETE FROM photos WHERE id = ?').run(id);
|
db.prepare('DELETE FROM photos WHERE id = ?').run(id);
|
||||||
|
db.prepare('DELETE FROM ratings WHERE key = ?').run(`photo:${id}`);
|
||||||
return row.file;
|
return row.file;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,6 +671,7 @@ export function deletePhotoOf(userId: number, id: number): string | undefined {
|
|||||||
| undefined;
|
| undefined;
|
||||||
if (!row) return undefined;
|
if (!row) return undefined;
|
||||||
db.prepare('DELETE FROM photos WHERE id = ? AND user_id = ?').run(id, userId);
|
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;
|
return row.file;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,9 +684,41 @@ export function setPhotoSlots(id: number, slots: readonly PhotoSlot[]): boolean
|
|||||||
export function deleteAllPhotos(): string[] {
|
export function deleteAllPhotos(): string[] {
|
||||||
const files = (db.prepare('SELECT file FROM photos').all() as { file: string }[]).map((r) => r.file);
|
const files = (db.prepare('SELECT file FROM photos').all() as { file: string }[]).map((r) => r.file);
|
||||||
db.prepare('DELETE FROM photos').run();
|
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;
|
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 -------------------------------------------------------------
|
// --- analytics -------------------------------------------------------------
|
||||||
// One row per page view or feature click. Nothing that identifies a visitor is
|
// 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)
|
// stored: the address is turned into a salted hash (enough to count uniques)
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import {
|
|||||||
photoFile,
|
photoFile,
|
||||||
photoPath,
|
photoPath,
|
||||||
photoPreset,
|
photoPreset,
|
||||||
|
rateLook,
|
||||||
|
ratingsFor,
|
||||||
sessionUser,
|
sessionUser,
|
||||||
setPhotoSlots,
|
setPhotoSlots,
|
||||||
setPhotoConsent,
|
setPhotoConsent,
|
||||||
@@ -346,6 +348,28 @@ app.post('/api/events', async (req, reply) => {
|
|||||||
return reply.status(204).send();
|
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) => {
|
app.post('/api/auth/signup', async (req, reply) => {
|
||||||
const b = bodyOf(req);
|
const b = bodyOf(req);
|
||||||
if (!b) return reply.status(400).send({ error: 'invalid body' });
|
if (!b) return reply.status(400).send({ error: 'invalid body' });
|
||||||
|
|||||||
@@ -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 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);
|
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 ---------------------------------
|
// ---- pre-existing guarantees still hold ---------------------------------
|
||||||
const foreignRecipe = await user.req('/recipes/1', { method: 'DELETE' });
|
const foreignRecipe = await user.req('/recipes/1', { method: 'DELETE' });
|
||||||
check("another account's recipe is not deletable", foreignRecipe.status === 404, `got ${foreignRecipe.status}`);
|
check("another account's recipe is not deletable", foreignRecipe.status === 404, `got ${foreignRecipe.status}`);
|
||||||
|
|||||||
+229
-110
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { api, type AdminPhoto, type AdminUser, type PhotoSlot } from './api';
|
import { api, type AdminPhoto, type AdminUser, type PhotoSlot, type Rating } from './api';
|
||||||
import { useI18n } from './i18n/I18nProvider';
|
import { useI18n } from './i18n/I18nProvider';
|
||||||
import { Profile } from './ui/Profile';
|
import { Profile } from './ui/Profile';
|
||||||
import { QrCode } from './ui/QrCode';
|
import { QrCode } from './ui/QrCode';
|
||||||
@@ -10,7 +10,9 @@ import type { MsgKey } from './i18n/vi';
|
|||||||
// the pane for the chosen section.
|
// the pane for the chosen section.
|
||||||
// Profile — the signed-in account's own picture/email/password
|
// Profile — the signed-in account's own picture/email/password
|
||||||
// User account — every account: its name, email, picture and contribution count
|
// User account — every account: its name, email, picture and contribution count
|
||||||
// Pictures — every photo in the system, with the slot that shows it
|
// Pictures — one album per user: click an album for its strip of
|
||||||
|
// thumbnails, click a thumbnail for the full frame and the
|
||||||
|
// landing sections it is allowed into
|
||||||
// Stats — the visitor counter: views, clicks and their breakdowns
|
// Stats — the visitor counter: views, clicks and their breakdowns
|
||||||
// Close — leaves the frame and goes back to the landing page
|
// Close — leaves the frame and goes back to the landing page
|
||||||
// The frame holds no privilege of its own — the API answers 403 unless the
|
// The frame holds no privilege of its own — the API answers 403 unless the
|
||||||
@@ -45,7 +47,15 @@ export function Admin() {
|
|||||||
const [photos, setPhotos] = useState<AdminPhoto[]>([]);
|
const [photos, setPhotos] = useState<AdminPhoto[]>([]);
|
||||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
const [tab, setTab] = useState<Tab>('pictures');
|
const [tab, setTab] = useState<Tab>('pictures');
|
||||||
const [owner, setOwner] = useState<string | null>(null);
|
// The album browser: which user's album is open ('all' is the opening view),
|
||||||
|
// which frame is up in the big box, and the three filters over the album and
|
||||||
|
// its strip.
|
||||||
|
const [album, setAlbum] = useState<number | 'all'>('all');
|
||||||
|
const [sel, setSel] = useState<number | null>(null);
|
||||||
|
const [nameQuery, setNameQuery] = useState('');
|
||||||
|
const [sort, setSort] = useState<'newest' | 'recipe' | 'rating'>('newest');
|
||||||
|
const [minStars, setMinStars] = useState(0);
|
||||||
|
const [ratings, setRatings] = useState<Record<string, Rating>>({});
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [note, setNote] = useState<string | null>(null);
|
const [note, setNote] = useState<string | null>(null);
|
||||||
const [picked, setPicked] = useState<number[]>([]);
|
const [picked, setPicked] = useState<number[]>([]);
|
||||||
@@ -58,9 +68,10 @@ export function Admin() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const [p, u] = await Promise.all([api.adminListPhotos(), api.adminListUsers()]);
|
const [p, u, r] = await Promise.all([api.adminListPhotos(), api.adminListUsers(), api.ratings()]);
|
||||||
setPhotos(p.photos);
|
setPhotos(p.photos);
|
||||||
setUsers(u.users);
|
setUsers(u.users);
|
||||||
|
setRatings(r.ratings);
|
||||||
setState('ready');
|
setState('ready');
|
||||||
} catch {
|
} catch {
|
||||||
setState('forbidden');
|
setState('forbidden');
|
||||||
@@ -86,9 +97,10 @@ export function Admin() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const refreshPhotos = async () => {
|
const refreshPhotos = async () => {
|
||||||
const [p, u] = await Promise.all([api.adminListPhotos(), api.adminListUsers()]);
|
const [p, u, r] = await Promise.all([api.adminListPhotos(), api.adminListUsers(), api.ratings()]);
|
||||||
setPhotos(p.photos);
|
setPhotos(p.photos);
|
||||||
setUsers(u.users);
|
setUsers(u.users);
|
||||||
|
setRatings(r.ratings);
|
||||||
};
|
};
|
||||||
|
|
||||||
const upload = (file: File) =>
|
const upload = (file: File) =>
|
||||||
@@ -129,9 +141,11 @@ export function Admin() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const showOwner = (email: string) => {
|
// From the users table: open that account's album and put its newest frame up
|
||||||
setOwner(email);
|
// in the big box, rather than filtering a flat pool of every upload.
|
||||||
|
const showOwner = (userId: number) => {
|
||||||
setTab('pictures');
|
setTab('pictures');
|
||||||
|
openAlbum(userId);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Block/unblock and remove/restore share one route; the list is reloaded
|
// Block/unblock and remove/restore share one route; the list is reloaded
|
||||||
@@ -168,7 +182,51 @@ export function Admin() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const shown = owner ? photos.filter((p) => p.email === owner) : photos;
|
// A frame's score, from the same public tally the landing draws. Whitespace
|
||||||
|
// in the name box is not a filter, so `query` is trimmed once for both uses.
|
||||||
|
const score = (p: AdminPhoto) => ratings[`photo:${p.id}`]?.avg ?? 0;
|
||||||
|
const query = nameQuery.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Albums: one per account that owns a photo, alphabetical so the column reads
|
||||||
|
// as a list of names. The name box narrows the column, not the strip.
|
||||||
|
const albums = useMemo(() => {
|
||||||
|
const map = new Map<number, { userId: number; email: string; n: number }>();
|
||||||
|
for (const p of photos) {
|
||||||
|
const a = map.get(p.userId) ?? { userId: p.userId, email: p.email, n: 0 };
|
||||||
|
a.n += 1;
|
||||||
|
map.set(p.userId, a);
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) => a.email.localeCompare(b.email));
|
||||||
|
}, [photos]);
|
||||||
|
const shownAlbums = query ? albums.filter((a) => a.email.toLowerCase().includes(query)) : albums;
|
||||||
|
|
||||||
|
// The open album's strip: the name filter still narrows the list, the rating
|
||||||
|
// filter drops frames below the bar, and the sort orders what is left.
|
||||||
|
const strip = useMemo(() => {
|
||||||
|
const base = (album === 'all' ? photos : photos.filter((p) => p.userId === album)).filter(
|
||||||
|
(p) => !query || p.email.toLowerCase().includes(query),
|
||||||
|
);
|
||||||
|
const list = minStars > 0 ? base.filter((p) => score(p) >= minStars) : base;
|
||||||
|
const sorted = [...list];
|
||||||
|
if (sort === 'recipe') sorted.sort((a, b) => (a.recipeName ?? '').localeCompare(b.recipeName ?? ''));
|
||||||
|
else if (sort === 'rating') sorted.sort((a, b) => score(b) - score(a));
|
||||||
|
else sorted.sort((a, b) => b.id - a.id);
|
||||||
|
return sorted;
|
||||||
|
// `score` reads `ratings`, which is in the deps.
|
||||||
|
}, [photos, album, minStars, sort, query, ratings]);
|
||||||
|
|
||||||
|
// The frame in the big box. `sel` names it, but a stale id (the photo was just
|
||||||
|
// deleted, or a filter moved on) falls back to the top of the strip so the box
|
||||||
|
// is never pointlessly empty.
|
||||||
|
const current = photos.find((p) => p.id === sel) ?? strip[0] ?? null;
|
||||||
|
|
||||||
|
// Opening an album puts its newest frame up, so the big box fills on the same
|
||||||
|
// click that fills the strip.
|
||||||
|
const openAlbum = (id: number | 'all') => {
|
||||||
|
setAlbum(id);
|
||||||
|
const first = photos.filter((p) => id === 'all' || p.userId === id).sort((a, b) => b.id - a.id)[0];
|
||||||
|
setSel(first ? first.id : null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="adm">
|
<div className="adm">
|
||||||
@@ -248,114 +306,175 @@ export function Admin() {
|
|||||||
<>
|
<>
|
||||||
<p className="hint adm-sub">{t('adm.subtitle')}</p>
|
<p className="hint adm-sub">{t('adm.subtitle')}</p>
|
||||||
<p className="hint adm-sub">{t('adm.slotHint')}</p>
|
<p className="hint adm-sub">{t('adm.slotHint')}</p>
|
||||||
{owner ? (
|
{photos.length === 0 ? <p className="hint adm-note">{t('adm.empty')}</p> : null}
|
||||||
<p className="hint adm-sub adm-filter">
|
|
||||||
{owner}
|
|
||||||
<button type="button" className="btn" data-key="adm-filter-all" onClick={() => setOwner(null)}>
|
|
||||||
{t('adm.showAll')}
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{shown.length === 0 ? <p className="hint adm-note">{t('adm.empty')}</p> : null}
|
|
||||||
<div className="adm-cols">
|
<div className="adm-cols">
|
||||||
|
{/* One album per account, plus ALL. The list scrolls on its
|
||||||
|
own, so the strip beside it stays put however many
|
||||||
|
accounts there are. */}
|
||||||
<div className="adm-col">
|
<div className="adm-col">
|
||||||
<h3 className="adm-row-title">{t('adm.pool')}</h3>
|
<h3 className="adm-row-title">{t('adm.albums')}</h3>
|
||||||
<div className="adm-grid">
|
<input
|
||||||
{shown.map((p) => (
|
type="search"
|
||||||
<article className="adm-card" key={p.id} data-key={`adm-photo-${p.id}`}>
|
className="adm-input"
|
||||||
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
|
data-key="adm-filter-name"
|
||||||
<div className="adm-meta">
|
placeholder={t('adm.filterName')}
|
||||||
<b>#{p.id}</b>
|
value={nameQuery}
|
||||||
{/* The labels the uploader's studio stored, when it
|
onChange={(e) => setNameQuery(e.target.value)}
|
||||||
sent any — the same ones the reel draws. */}
|
/>
|
||||||
{p.tag ? <span>{p.tag}</span> : null}
|
<div className="adm-filters">
|
||||||
{p.title ? <span>{p.title}</span> : null}
|
<select
|
||||||
{p.meta ? <span>{p.meta}</span> : null}
|
className="adm-input"
|
||||||
<span>{t('adm.owner')}: {p.email}</span>
|
data-key="adm-sort"
|
||||||
<span>{t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()}</span>
|
aria-label={t('adm.sort')}
|
||||||
<span>{t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime}</span>
|
value={sort}
|
||||||
</div>
|
onChange={(e) => setSort(e.target.value as 'newest' | 'recipe' | 'rating')}
|
||||||
{/* One checkbox per destination, all three on one
|
>
|
||||||
line: tick as many as the photo should appear in,
|
<option value="newest">{t('adm.sortNewest')}</option>
|
||||||
untick the last one to take it off the landing. */}
|
<option value="recipe">{t('adm.sortRecipe')}</option>
|
||||||
<div className="adm-choices">
|
<option value="rating">{t('adm.sortRating')}</option>
|
||||||
{DESTINATIONS.map((d) => (
|
</select>
|
||||||
<label className="adm-choice" key={d.id}>
|
<select
|
||||||
<input
|
className="adm-input"
|
||||||
type="checkbox"
|
data-key="adm-filter-rating"
|
||||||
data-key={`adm-pick-${d.id}-${p.id}`}
|
aria-label={t('adm.rating')}
|
||||||
checked={p.slots.includes(d.id)}
|
value={String(minStars)}
|
||||||
// A photo with no stored look has no file to
|
onChange={(e) => setMinStars(Number(e.target.value))}
|
||||||
// hand out, so the QR box would only ever draw
|
>
|
||||||
// an empty card.
|
<option value="0">{t('adm.ratingAll')}</option>
|
||||||
disabled={busy || (d.id === 'qr' && !p.hasPreset)}
|
{[5, 4, 3, 2, 1].map((n) => (
|
||||||
title={d.id === 'qr' && !p.hasPreset ? t('adm.noPreset') : undefined}
|
<option key={n} value={String(n)}>
|
||||||
onChange={() =>
|
{t('adm.ratingAtLeast', { n })}
|
||||||
void setSlots(
|
</option>
|
||||||
p.id,
|
))}
|
||||||
p.slots.includes(d.id)
|
</select>
|
||||||
? p.slots.filter((x) => x !== d.id)
|
</div>
|
||||||
: [...p.slots, d.id],
|
<div className="adm-album-list" data-key="adm-albums">
|
||||||
)
|
<button
|
||||||
}
|
type="button"
|
||||||
/>
|
className={`adm-album${album === 'all' ? ' on' : ''}`}
|
||||||
{t(d.key)}
|
data-key="adm-album-all"
|
||||||
</label>
|
onClick={() => openAlbum('all')}
|
||||||
))}
|
>
|
||||||
</div>
|
<span className="adm-album-name">{t('adm.albumAll')}</span>
|
||||||
<button
|
<span className="adm-album-count">{photos.length}</span>
|
||||||
type="button"
|
</button>
|
||||||
className="btn adm-danger"
|
{shownAlbums.map((a) => (
|
||||||
data-key={`adm-del-${p.id}`}
|
<button
|
||||||
disabled={busy}
|
key={a.userId}
|
||||||
onClick={() => void remove(p.id)}
|
type="button"
|
||||||
>
|
className={`adm-album${album === a.userId ? ' on' : ''}`}
|
||||||
{t('adm.delete')}
|
data-key={`adm-album-${a.userId}`}
|
||||||
</button>
|
onClick={() => openAlbum(a.userId)}
|
||||||
</article>
|
>
|
||||||
|
<span className="adm-album-name">{a.email}</span>
|
||||||
|
<span className="adm-album-count">{a.n}</span>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="adm-col">
|
<div className="adm-col">
|
||||||
{DESTINATIONS.map((d) => {
|
{/* The frame that is up: the full picture, its labels and
|
||||||
const inRow = shown.filter((p) => p.slots.includes(d.id));
|
the sections it may appear in — the same checkboxes the
|
||||||
return (
|
flat pool carried, now on the chosen frame. */}
|
||||||
<section className="adm-row" key={d.id} data-key={`adm-row-${d.id}`}>
|
<section className="adm-big" data-key="adm-big">
|
||||||
<h3 className="adm-row-title">
|
{current ? (
|
||||||
{t(d.key)}
|
<>
|
||||||
<span className="adm-row-count">{inRow.length}</span>
|
<img className="adm-big-img" data-key="adm-big-img" src={api.photoUrl(current.id)} alt="" />
|
||||||
</h3>
|
<div className="adm-big-side">
|
||||||
{inRow.length === 0 ? (
|
<div className="adm-meta">
|
||||||
<p className="hint adm-row-empty">{t('adm.rowEmpty')}</p>
|
<b>#{current.id}</b>
|
||||||
) : (
|
{current.tag ? <span>{current.tag}</span> : null}
|
||||||
<div className="adm-grid">
|
{current.title ? <span>{current.title}</span> : null}
|
||||||
{inRow.map((p) => (
|
{current.meta ? <span>{current.meta}</span> : null}
|
||||||
<figure className="adm-thumb" key={p.id} data-key={`adm-thumb-${p.id}`}>
|
{current.recipeName ? (
|
||||||
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
|
<span>
|
||||||
{/* The QR section is the one whose artefact is
|
{t('adm.recipe')}: {current.recipeName}
|
||||||
not the photo: it is the link that hands
|
</span>
|
||||||
the look back to the app. A photo uploaded
|
) : null}
|
||||||
without a look has none to hand out. */}
|
<span>
|
||||||
{d.id === 'qr' ? (
|
{t('adm.rating')}: {score(current).toFixed(1)} (
|
||||||
<div className="adm-qr">
|
{ratings[`photo:${current.id}`]?.n ?? 0})
|
||||||
{p.hasPreset ? (
|
</span>
|
||||||
<QrCode
|
<span>
|
||||||
url={new URL(api.presetUrl(p.id), window.location.origin).href}
|
{t('adm.owner')}: {current.email}
|
||||||
size={120}
|
</span>
|
||||||
/>
|
<span>
|
||||||
) : (
|
{t('adm.uploaded')}: {new Date(current.createdAt).toLocaleString()}
|
||||||
<span>{t('adm.noPreset')}</span>
|
</span>
|
||||||
)}
|
<span>
|
||||||
</div>
|
{t('adm.size')}: {Math.round(current.bytes / 1024)} KB · {current.mime}
|
||||||
) : null}
|
</span>
|
||||||
<figcaption>#{p.id}</figcaption>
|
</div>
|
||||||
</figure>
|
<div className="adm-choices">
|
||||||
|
{DESTINATIONS.map((d) => (
|
||||||
|
<label className="adm-choice" key={d.id}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-key={`adm-pick-${d.id}-${current.id}`}
|
||||||
|
checked={current.slots.includes(d.id)}
|
||||||
|
// A photo with no stored look has no file to
|
||||||
|
// hand out, so the QR box would only ever
|
||||||
|
// draw an empty card.
|
||||||
|
disabled={busy || (d.id === 'qr' && !current.hasPreset)}
|
||||||
|
title={d.id === 'qr' && !current.hasPreset ? t('adm.noPreset') : undefined}
|
||||||
|
onChange={() =>
|
||||||
|
void setSlots(
|
||||||
|
current.id,
|
||||||
|
current.slots.includes(d.id)
|
||||||
|
? current.slots.filter((x) => x !== d.id)
|
||||||
|
: [...current.slots, d.id],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{t(d.key)}
|
||||||
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{/* The QR section's real artefact is the code, not
|
||||||
</section>
|
the photo, so it is drawn here when the frame is
|
||||||
);
|
in that section and has a look to hand out. */}
|
||||||
})}
|
{current.hasPreset && current.slots.includes('qr') ? (
|
||||||
|
<div className="adm-qr adm-qr-inline" data-key={`adm-qr-${current.id}`}>
|
||||||
|
<QrCode
|
||||||
|
url={new URL(api.presetUrl(current.id), window.location.origin).href}
|
||||||
|
size={112}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn adm-danger"
|
||||||
|
data-key={`adm-del-${current.id}`}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void remove(current.id)}
|
||||||
|
>
|
||||||
|
{t('adm.delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="hint adm-row-empty">{t('adm.bigEmpty')}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* The album's strip: one thumbnail per frame, in the order
|
||||||
|
the filters and the sort chose. */}
|
||||||
|
<div className="adm-filmstrip" data-key="adm-filmstrip">
|
||||||
|
{strip.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
className={`adm-thumb${current?.id === p.id ? ' on' : ''}`}
|
||||||
|
data-key={`adm-thumb-${p.id}`}
|
||||||
|
onClick={() => setSel(p.id)}
|
||||||
|
>
|
||||||
|
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
|
||||||
|
<span className="adm-thumb-cap">#{p.id}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{strip.length === 0 ? <p className="hint adm-row-empty">{t('adm.empty')}</p> : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -458,7 +577,7 @@ export function Admin() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn"
|
||||||
data-key={`adm-user-photos-${u.id}`}
|
data-key={`adm-user-photos-${u.id}`}
|
||||||
onClick={() => showOwner(u.email)}
|
onClick={() => showOwner(u.id)}
|
||||||
>
|
>
|
||||||
{t('adm.viewPhotos')}
|
{t('adm.viewPhotos')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { api, type Photo, type PhotoSlot, type User } from './api';
|
import { api, type Photo, type PhotoSlot, type Rating, type User } from './api';
|
||||||
import { useI18n } from './i18n/I18nProvider';
|
import { useI18n } from './i18n/I18nProvider';
|
||||||
import { ACCENTS, FONT_SETS, useTheme, type FontSetId } from './theme/ThemeProvider';
|
import { ACCENTS, FONT_SETS, useTheme, type FontSetId } from './theme/ThemeProvider';
|
||||||
import { AuthModal } from './ui/AuthModal';
|
import { AuthModal } from './ui/AuthModal';
|
||||||
@@ -72,12 +72,17 @@ interface Look {
|
|||||||
meta: string;
|
meta: string;
|
||||||
photo: string;
|
photo: string;
|
||||||
filter: string;
|
filter: string;
|
||||||
|
// The subject this frame is rated under: `look:<TAG>` for a built-in look,
|
||||||
|
// `photo:<id>` for a contribution. Filled in where the reel is assembled.
|
||||||
|
rateKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 35mm reel: eight looks over six sample negatives, each with its own CSS
|
// 35mm reel: eight looks over six sample negatives, each with its own CSS
|
||||||
// emulation. ponytail: CSS filters, not the CanvasKit pipeline — a landing page
|
// emulation. ponytail: CSS filters, not the CanvasKit pipeline — a landing page
|
||||||
// does not need a 12MP render; swap in real graded stills when we have them.
|
// does not need a 12MP render; swap in real graded stills when we have them.
|
||||||
const REEL: Look[] = [
|
// The built-in looks carry no `rateKey` of their own — the reel stamps it from
|
||||||
|
// the tag when it is assembled, so the literal cannot drift from the key form.
|
||||||
|
const REEL: Omit<Look, 'rateKey'>[] = [
|
||||||
{ tag: '#KODAK_PORTRA_400', title: { en: 'Golden Hour Portrait', vi: 'Chân dung giờ vàng' }, meta: 'ISO 400 · GRAIN 35 · WARMTH +18', photo: SAMPLE(1015), filter: 'sepia(.16) saturate(1.18) contrast(1.04) brightness(1.04)' },
|
{ tag: '#KODAK_PORTRA_400', title: { en: 'Golden Hour Portrait', vi: 'Chân dung giờ vàng' }, meta: 'ISO 400 · GRAIN 35 · WARMTH +18', photo: SAMPLE(1015), filter: 'sepia(.16) saturate(1.18) contrast(1.04) brightness(1.04)' },
|
||||||
{ tag: '#FUJI_CLASSIC_CHROME', title: { en: 'Quiet Street, 7am', vi: 'Phố vắng, 7 giờ sáng' }, meta: 'ISO 200 · GRAIN 20 · WARMTH -6', photo: SAMPLE(1025), filter: 'saturate(.78) contrast(1.14) hue-rotate(-6deg)' },
|
{ tag: '#FUJI_CLASSIC_CHROME', title: { en: 'Quiet Street, 7am', vi: 'Phố vắng, 7 giờ sáng' }, meta: 'ISO 200 · GRAIN 20 · WARMTH -6', photo: SAMPLE(1025), filter: 'saturate(.78) contrast(1.14) hue-rotate(-6deg)' },
|
||||||
{ tag: '#CYBER_NEON_88', title: { en: 'Neon Crossing', vi: 'Ngã tư neon' }, meta: 'ISO 1600 · GRAIN 45 · WARMTH +4', photo: SAMPLE(1035), filter: 'saturate(1.7) contrast(1.25) hue-rotate(268deg)' },
|
{ tag: '#CYBER_NEON_88', title: { en: 'Neon Crossing', vi: 'Ngã tư neon' }, meta: 'ISO 1600 · GRAIN 45 · WARMTH +4', photo: SAMPLE(1035), filter: 'saturate(1.7) contrast(1.25) hue-rotate(268deg)' },
|
||||||
@@ -163,6 +168,7 @@ export function Landing() {
|
|||||||
const [toast, setToast] = useState<string | null>(null);
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||||
|
const [ratings, setRatings] = useState<Record<string, Rating>>({});
|
||||||
// Sign up / log in happens in a dialog on this page, not by a trip to the
|
// Sign up / log in happens in a dialog on this page, not by a trip to the
|
||||||
// studio: the visitor keeps their scroll position and lands back on the reel.
|
// studio: the visitor keeps their scroll position and lands back on the reel.
|
||||||
const [auth, setAuth] = useState<'login' | 'signup' | null>(() => {
|
const [auth, setAuth] = useState<'login' | 'signup' | null>(() => {
|
||||||
@@ -189,6 +195,7 @@ export function Landing() {
|
|||||||
// everybody sees the result on the next load. A guest is sent to sign up.
|
// everybody sees the result on the next load. A guest is sent to sign up.
|
||||||
const reload = useCallback(() => {
|
const reload = useCallback(() => {
|
||||||
api.listPhotos().then((r) => setPhotos(r.photos)).catch(() => undefined);
|
api.listPhotos().then((r) => setPhotos(r.photos)).catch(() => undefined);
|
||||||
|
api.ratings().then((r) => setRatings(r.ratings)).catch(() => undefined);
|
||||||
return api.me().then((r) => {
|
return api.me().then((r) => {
|
||||||
setUser(r.user);
|
setUser(r.user);
|
||||||
return r.user;
|
return r.user;
|
||||||
@@ -212,11 +219,25 @@ export function Landing() {
|
|||||||
}
|
}
|
||||||
}, [showToast, t]);
|
}, [showToast, t]);
|
||||||
|
|
||||||
|
// One vote. The visitor may change their mind: the API upserts under the same
|
||||||
|
// address hash, so a second click replaces the first rather than stacking. A
|
||||||
|
// vote that never lands is silently dropped — it is not worth a toast.
|
||||||
|
const rate = useCallback(async (key: string, stars: number) => {
|
||||||
|
try {
|
||||||
|
const r = await api.rate(key, stars);
|
||||||
|
setRatings((prev) => ({ ...prev, [r.key]: r.rating }));
|
||||||
|
} catch {
|
||||||
|
// Offline or rate-limited: the row keeps the score it already had.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Contributed stills join the reel after the six built-in looks. Every frame
|
// Contributed stills join the reel after the six built-in looks. Every frame
|
||||||
// is repeated once, so the marquee's -50% translation still loops seamlessly.
|
// is repeated once, so the marquee's -50% translation still loops seamlessly.
|
||||||
const reel = useMemo<Look[]>(
|
const reel = useMemo<Look[]>(
|
||||||
() => [
|
() => [
|
||||||
...REEL,
|
// A built-in look is rated under its own tag, so the eight samples carry
|
||||||
|
// scores just like a contributed frame does.
|
||||||
|
...REEL.map((l) => ({ ...l, rateKey: `look:${l.tag.replace(/^#/, '')}` })),
|
||||||
// The labels the uploader's studio stored — the tagline, the title and
|
// The labels the uploader's studio stored — the tagline, the title and
|
||||||
// the ISO/grain line. A contribution from before those existed keeps the
|
// the ISO/grain line. A contribution from before those existed keeps the
|
||||||
// plain community defaults.
|
// plain community defaults.
|
||||||
@@ -226,6 +247,7 @@ export function Landing() {
|
|||||||
meta: p.meta ?? `#${p.id}`,
|
meta: p.meta ?? `#${p.id}`,
|
||||||
photo: api.photoUrl(p.id),
|
photo: api.photoUrl(p.id),
|
||||||
filter: 'none',
|
filter: 'none',
|
||||||
|
rateKey: `photo:${p.id}`,
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
[photos],
|
[photos],
|
||||||
@@ -571,25 +593,57 @@ export function Landing() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="lp-track">
|
<div className="lp-track">
|
||||||
{[...reel, ...reel].map((look, i) => (
|
{[...reel, ...reel].map((look, i) => {
|
||||||
<article className="lp-frame" key={`${look.tag}-${i}`}>
|
const score = ratings[look.rateKey] ?? { avg: 0, n: 0, mine: 0 };
|
||||||
<div className="lp-perf" aria-hidden="true">
|
return (
|
||||||
<i /><i /><i /><i /><i /><i />
|
<article className="lp-frame" key={`${look.tag}-${i}`}>
|
||||||
</div>
|
<div className="lp-perf" aria-hidden="true">
|
||||||
<div className="lp-shot">
|
<i /><i /><i /><i /><i /><i />
|
||||||
<Shot src={look.photo} alt={c(look.title)} filter={look.filter} />
|
</div>
|
||||||
<span className="lp-grain" aria-hidden="true" />
|
<div className="lp-shot">
|
||||||
<span className="lp-tagline">{look.tag}</span>
|
<Shot src={look.photo} alt={c(look.title)} filter={look.filter} />
|
||||||
</div>
|
<span className="lp-grain" aria-hidden="true" />
|
||||||
<div className="lp-frame-meta">
|
<span className="lp-tagline">{look.tag}</span>
|
||||||
<b>{c(look.title)}</b>
|
</div>
|
||||||
<span>{look.meta}</span>
|
<div className="lp-frame-meta">
|
||||||
</div>
|
<b>{c(look.title)}</b>
|
||||||
<div className="lp-perf" aria-hidden="true">
|
<span>{look.meta}</span>
|
||||||
<i /><i /><i /><i /><i /><i />
|
{/* The tally first, then the row that changes it: a visitor
|
||||||
</div>
|
reads the score, then casts one of their own. Clicking
|
||||||
</article>
|
another star replaces the vote instead of adding one. */}
|
||||||
))}
|
<div className="lp-rate" data-key={`lp-rate-${look.rateKey}`}>
|
||||||
|
<b className="lp-rate-index" data-key={`lp-rate-index-${look.rateKey}`}>
|
||||||
|
{score.avg.toFixed(1)}
|
||||||
|
</b>
|
||||||
|
<span
|
||||||
|
className="lp-stars"
|
||||||
|
role="group"
|
||||||
|
aria-label={c({ en: 'Rate this frame', vi: 'Đánh giá khung này' })}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={n}
|
||||||
|
className={`lp-star${score.mine >= n ? ' on' : ''}`}
|
||||||
|
data-key={`lp-star-${look.rateKey}-${n}`}
|
||||||
|
aria-label={`${n}/5`}
|
||||||
|
onClick={() => void rate(look.rateKey, n)}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="lp-rate-count" data-key={`lp-rate-count-${look.rateKey}`}>
|
||||||
|
({score.n})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lp-perf" aria-hidden="true">
|
||||||
|
<i /><i /><i /><i /><i /><i />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,18 @@ export interface AdminPhoto extends Photo {
|
|||||||
email: string;
|
email: string;
|
||||||
mime: string;
|
mime: string;
|
||||||
bytes: number;
|
bytes: number;
|
||||||
|
// The name of the look the photo was saved with, or null — the admin's A→Z
|
||||||
|
// ordering key for the album strip.
|
||||||
|
recipeName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One film-strip frame's score. `avg` is the mean of every vote (0 when nobody
|
||||||
|
// has voted), `n` how many there were, and `mine` this visitor's own vote (0
|
||||||
|
// when they have not rated it) — what the landing's star row is drawn from.
|
||||||
|
export interface Rating {
|
||||||
|
avg: number;
|
||||||
|
n: number;
|
||||||
|
mine: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One account as /api/admin/users reports it. `avatar` is the ready-made URL
|
// One account as /api/admin/users reports it. `avatar` is the ready-made URL
|
||||||
@@ -198,6 +210,11 @@ export const api = {
|
|||||||
// labels ride the query string, since the body is the image itself.
|
// labels ride the query string, since the body is the image itself.
|
||||||
listPhotos: () => call<{ photos: Photo[] }>('/photos'),
|
listPhotos: () => call<{ photos: Photo[] }>('/photos'),
|
||||||
listMyPhotos: () => call<{ photos: Photo[] }>('/photos/mine'),
|
listMyPhotos: () => call<{ photos: Photo[] }>('/photos/mine'),
|
||||||
|
// Film-strip ratings, keyed by subject (`photo:<id>` or `look:<TAG>`). Public:
|
||||||
|
// any visitor may read the tallies and cast one vote per frame.
|
||||||
|
ratings: () => call<{ ratings: Record<string, Rating> }>('/ratings'),
|
||||||
|
rate: (key: string, stars: number) =>
|
||||||
|
call<{ key: string; rating: Rating }>('/ratings', { method: 'POST', body: JSON.stringify({ key, stars }) }),
|
||||||
uploadPhoto: async (file: File, labels?: PhotoLabels, opts?: PhotoMetaOpts) => {
|
uploadPhoto: async (file: File, labels?: PhotoLabels, opts?: PhotoMetaOpts) => {
|
||||||
const res = await uploadBytes(`/api/photos${photoQuery(labels, opts)}`, file, MAX_PHOTO_DIM, MAX_PHOTO_UPLOAD);
|
const res = await uploadBytes(`/api/photos${photoQuery(labels, opts)}`, file, MAX_PHOTO_DIM, MAX_PHOTO_UPLOAD);
|
||||||
const body = await readJson(res);
|
const body = await readJson(res);
|
||||||
|
|||||||
@@ -166,7 +166,18 @@ export const en: Dict = {
|
|||||||
'adm.title': 'Strip moderation',
|
'adm.title': 'Strip moderation',
|
||||||
'adm.subtitle': 'Photos contributed by users. Delete one to pull it off the landing page.',
|
'adm.subtitle': 'Photos contributed by users. Delete one to pull it off the landing page.',
|
||||||
'adm.upload': 'ADD A PHOTO',
|
'adm.upload': 'ADD A PHOTO',
|
||||||
'adm.pool': 'Uploaded photos',
|
'adm.albums': 'User albums',
|
||||||
|
'adm.albumAll': 'All photos',
|
||||||
|
'adm.filterName': 'Filter by user name',
|
||||||
|
'adm.sort': 'Sort',
|
||||||
|
'adm.sortNewest': 'Newest first',
|
||||||
|
'adm.sortRecipe': 'Recipe A→Z',
|
||||||
|
'adm.sortRating': 'Highest rated',
|
||||||
|
'adm.rating': 'Rating',
|
||||||
|
'adm.ratingAll': 'Any rating',
|
||||||
|
'adm.ratingAtLeast': '{n}★ and up',
|
||||||
|
'adm.recipe': 'Recipe',
|
||||||
|
'adm.bigEmpty': 'Pick a thumbnail to view.',
|
||||||
'adm.pickStrip': 'Film strip',
|
'adm.pickStrip': 'Film strip',
|
||||||
'adm.pickTester': 'Live preset tester',
|
'adm.pickTester': 'Live preset tester',
|
||||||
'adm.pickCreator': 'Custom recipe creator',
|
'adm.pickCreator': 'Custom recipe creator',
|
||||||
@@ -201,7 +212,6 @@ export const en: Dict = {
|
|||||||
'adm.roleMember': 'Member',
|
'adm.roleMember': 'Member',
|
||||||
'adm.viewPhotos': 'VIEW PHOTOS',
|
'adm.viewPhotos': 'VIEW PHOTOS',
|
||||||
'adm.noUsers': 'No accounts yet.',
|
'adm.noUsers': 'No accounts yet.',
|
||||||
'adm.showAll': 'ALL',
|
|
||||||
'adm.profileHint': 'Picture, email and password of the signed-in account. Changing the email or password needs the current password.',
|
'adm.profileHint': 'Picture, email and password of the signed-in account. Changing the email or password needs the current password.',
|
||||||
'adm.changeAvatar': 'CHANGE PICTURE',
|
'adm.changeAvatar': 'CHANGE PICTURE',
|
||||||
'adm.currentPassword': 'Current password',
|
'adm.currentPassword': 'Current password',
|
||||||
|
|||||||
@@ -170,7 +170,18 @@ export const vi = {
|
|||||||
'adm.title': 'Quản trị dải phim',
|
'adm.title': 'Quản trị dải phim',
|
||||||
'adm.subtitle': 'Ảnh do người dùng đóng góp. Xoá một ảnh để gỡ nó khỏi trang chủ.',
|
'adm.subtitle': 'Ảnh do người dùng đóng góp. Xoá một ảnh để gỡ nó khỏi trang chủ.',
|
||||||
'adm.upload': 'THÊM ẢNH',
|
'adm.upload': 'THÊM ẢNH',
|
||||||
'adm.pool': 'Ảnh đã tải lên',
|
'adm.albums': 'Album người dùng',
|
||||||
|
'adm.albumAll': 'Tất cả ảnh',
|
||||||
|
'adm.filterName': 'Lọc theo tên người dùng',
|
||||||
|
'adm.sort': 'Sắp xếp',
|
||||||
|
'adm.sortNewest': 'Mới nhất trước',
|
||||||
|
'adm.sortRecipe': 'Công thức A→Z',
|
||||||
|
'adm.sortRating': 'Xếp hạng cao nhất',
|
||||||
|
'adm.rating': 'Xếp hạng',
|
||||||
|
'adm.ratingAll': 'Mọi mức xếp hạng',
|
||||||
|
'adm.ratingAtLeast': 'Từ {n} sao',
|
||||||
|
'adm.recipe': 'Công thức',
|
||||||
|
'adm.bigEmpty': 'Chọn một ảnh để xem.',
|
||||||
'adm.pickStrip': 'Dải phim',
|
'adm.pickStrip': 'Dải phim',
|
||||||
'adm.pickTester': 'Live preset tester',
|
'adm.pickTester': 'Live preset tester',
|
||||||
'adm.pickCreator': 'Custom recipe creator',
|
'adm.pickCreator': 'Custom recipe creator',
|
||||||
@@ -205,7 +216,6 @@ export const vi = {
|
|||||||
'adm.roleMember': 'Thành viên',
|
'adm.roleMember': 'Thành viên',
|
||||||
'adm.viewPhotos': 'XEM ẢNH',
|
'adm.viewPhotos': 'XEM ẢNH',
|
||||||
'adm.noUsers': 'Chưa có tài khoản nào.',
|
'adm.noUsers': 'Chưa có tài khoản nào.',
|
||||||
'adm.showAll': 'TẤT CẢ',
|
|
||||||
'adm.profileHint': 'Ảnh đại diện, email và mật khẩu của tài khoản đang đăng nhập. Đổi email hoặc mật khẩu cần mật khẩu hiện tại.',
|
'adm.profileHint': 'Ảnh đại diện, email và mật khẩu của tài khoản đang đăng nhập. Đổi email hoặc mật khẩu cần mật khẩu hiện tại.',
|
||||||
'adm.changeAvatar': 'ĐỔI ẢNH ĐẠI DIỆN',
|
'adm.changeAvatar': 'ĐỔI ẢNH ĐẠI DIỆN',
|
||||||
'adm.currentPassword': 'Mật khẩu hiện tại',
|
'adm.currentPassword': 'Mật khẩu hiện tại',
|
||||||
|
|||||||
@@ -475,14 +475,78 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
|||||||
.adm-brand { display: flex; align-items: center; gap: 6px; font-weight: 650; letter-spacing: -0.01em; color: var(--text); text-decoration: none; }
|
.adm-brand { display: flex; align-items: center; gap: 6px; font-weight: 650; letter-spacing: -0.01em; color: var(--text); text-decoration: none; }
|
||||||
.adm-brand em { font-style: normal; font-weight: 400; color: var(--accent); }
|
.adm-brand em { font-style: normal; font-weight: 400; color: var(--accent); }
|
||||||
.adm-brand img { border-radius: 8px; }
|
.adm-brand img { border-radius: 8px; }
|
||||||
/* Curating pictures: the pool of uploads on the left, the three landing
|
/* Curating pictures: the albums on the left, the chosen frame and its strip of
|
||||||
sections on the right. One picker button per destination under each photo. */
|
thumbnails on the right. The album column is the narrow one — it is a list of
|
||||||
.adm-cols { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; }
|
names — and scrolls on its own so the strip beside it never moves. */
|
||||||
|
.adm-cols { display: grid; grid-template-columns: minmax(200px, 280px) minmax(0, 1fr); gap: 16px; align-items: start; }
|
||||||
@media (max-width: 1100px) { .adm-cols { grid-template-columns: minmax(0, 1fr); } }
|
@media (max-width: 1100px) { .adm-cols { grid-template-columns: minmax(0, 1fr); } }
|
||||||
.adm-col { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
.adm-col { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
||||||
/* Wide enough for the three chips to sit on one line: the picker is the point
|
/* The filters over the album list: one name box, two pickers. */
|
||||||
of the card, so the grid gives it the width it needs. */
|
.adm-input {
|
||||||
.adm-cols .adm-grid { grid-template-columns: repeat(auto-fill, minmax(450px, 1fr)); }
|
width: 100%;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-sunken);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
.adm-filters { display: flex; gap: 6px; }
|
||||||
|
.adm-filters .adm-input { min-width: 0; }
|
||||||
|
.adm-album-list { display: flex; flex-direction: column; gap: 4px; max-height: 62vh; overflow-y: auto; padding-right: 2px; }
|
||||||
|
.adm-album {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text-dim);
|
||||||
|
background: var(--bg-sunken);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 7px 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.adm-album:hover { border-color: var(--accent-line); }
|
||||||
|
.adm-album.on { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
|
||||||
|
.adm-album-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.adm-album-count { font-family: var(--mono); font-size: 11px; }
|
||||||
|
/* The frame that is up: the picture on the left, everything about it on the
|
||||||
|
right — including the four landing sections it may appear in. */
|
||||||
|
.adm-big {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(220px, 300px);
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.adm-big-img { width: 100%; aspect-ratio: 4 / 3; object-fit: contain; border-radius: var(--radius-sm); background: var(--bg-sunken); }
|
||||||
|
.adm-big-side { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
||||||
|
.adm-qr-inline { border-radius: var(--radius-sm); }
|
||||||
|
/* The album's strip: thumbnails in one row, scrolled sideways when they
|
||||||
|
overflow the pane rather than wrapping into a wall of pictures. */
|
||||||
|
.adm-filmstrip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
|
||||||
|
.adm-thumb {
|
||||||
|
flex: 0 0 132px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-sunken);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.adm-thumb:hover { border-color: var(--accent-line); }
|
||||||
|
.adm-thumb.on { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||||
|
.adm-thumb img { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; }
|
||||||
|
.adm-thumb-cap { display: block; padding: 3px 6px; font-family: var(--mono); font-size: 10.5px; }
|
||||||
/* The three destinations sit on one line under the photo, each an independent
|
/* The three destinations sit on one line under the photo, each an independent
|
||||||
checkbox — tick all three and the photo shows in all three sections. */
|
checkbox — tick all three and the photo shows in all three sections. */
|
||||||
.adm-choices { display: flex; flex-wrap: wrap; gap: 5px; }
|
.adm-choices { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||||
@@ -518,9 +582,6 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
|||||||
}
|
}
|
||||||
.adm-row-count { font-family: var(--mono); color: var(--text); }
|
.adm-row-count { font-family: var(--mono); color: var(--text); }
|
||||||
.adm-row-empty { margin: 0; }
|
.adm-row-empty { margin: 0; }
|
||||||
.adm-thumb { margin: 0; border: 1px solid var(--border-soft); border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-sunken); }
|
|
||||||
.adm-thumb img { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; }
|
|
||||||
.adm-thumb figcaption { padding: 3px 6px; font-family: var(--mono); font-size: 10.5px; color: var(--text-dim); }
|
|
||||||
/* The QR section's real artefact is the code, not the photo. Drawn on white:
|
/* The QR section's real artefact is the code, not the photo. Drawn on white:
|
||||||
a scanner needs the quiet zone, and the dark theme has none. */
|
a scanner needs the quiet zone, and the dark theme has none. */
|
||||||
.adm-qr { display: flex; align-items: center; justify-content: center; min-height: 88px; padding: 6px; background: #fff; }
|
.adm-qr { display: flex; align-items: center; justify-content: center; min-height: 88px; padding: 6px; background: #fff; }
|
||||||
@@ -610,7 +671,6 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.adm-face img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
.adm-face img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||||
.adm-filter { display: flex; align-items: center; gap: 8px; }
|
|
||||||
/* Moderation state next to the account name, and the row's own actions: the
|
/* Moderation state next to the account name, and the row's own actions: the
|
||||||
three controls sit side by side and the pane scrolls when it runs out. */
|
three controls sit side by side and the pane scrolls when it runs out. */
|
||||||
.adm-badge-warn { margin-left: 6px; color: var(--danger); font-size: 11px; }
|
.adm-badge-warn { margin-left: 6px; color: var(--danger); font-size: 11px; }
|
||||||
|
|||||||
@@ -308,6 +308,24 @@ html { scroll-behavior: smooth; scroll-padding-top: 116px; }
|
|||||||
.lp-frame-meta { display: flex; flex-direction: column; gap: 3px; }
|
.lp-frame-meta { display: flex; flex-direction: column; gap: 3px; }
|
||||||
.lp-frame-meta b { font-size: 13px; font-weight: 600; }
|
.lp-frame-meta b { font-size: 13px; font-weight: 600; }
|
||||||
.lp-frame-meta span { font-family: var(--lp-mono); font-size: 9.5px; color: var(--lp-muted); letter-spacing: 0.06em; }
|
.lp-frame-meta span { font-family: var(--lp-mono); font-size: 9.5px; color: var(--lp-muted); letter-spacing: 0.06em; }
|
||||||
|
/* The score line: the tally reads first, the five clickable stars after it, the
|
||||||
|
number of votes last. One vote per visitor — a second click replaces it. */
|
||||||
|
.lp-rate { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
|
||||||
|
.lp-rate-index { font-family: var(--lp-mono); font-size: 12px; font-weight: 600; color: var(--lp-amber); }
|
||||||
|
.lp-stars { display: inline-flex; gap: 1px; }
|
||||||
|
.lp-star {
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--lp-line);
|
||||||
|
transition: color 0.12s ease, transform 0.12s ease;
|
||||||
|
}
|
||||||
|
.lp-star:hover { color: var(--lp-amber); transform: scale(1.18); }
|
||||||
|
.lp-star.on { color: var(--lp-amber); }
|
||||||
|
.lp-rate-count { font-family: var(--lp-mono); font-size: 9.5px; color: var(--lp-muted); }
|
||||||
|
|
||||||
/* --- 4. preset tester --------------------------------------------------- */
|
/* --- 4. preset tester --------------------------------------------------- */
|
||||||
.lp-tester { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 26px; align-items: start; }
|
.lp-tester { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 26px; align-items: start; }
|
||||||
|
|||||||
Reference in New Issue
Block a user