diff --git a/docker/backend/src/db.ts b/docker/backend/src/db.ts index 8edcaf8..62f0967 100644 --- a/docker/backend/src/db.ts +++ b/docker/backend/src/db.ts @@ -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:` for a contribution or `look:` 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 { + 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 = {}; + 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) diff --git a/docker/backend/src/server.ts b/docker/backend/src/server.ts index f94455c..b893d9f 100644 --- a/docker/backend/src/server.ts +++ b/docker/backend/src/server.ts @@ -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' }); diff --git a/docker/backend/test/security.mjs b/docker/backend/test/security.mjs index 03bee33..df5983a 100644 --- a/docker/backend/test/security.mjs +++ b/docker/backend/test/security.mjs @@ -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}`); diff --git a/docker/frontend/src/Admin.tsx b/docker/frontend/src/Admin.tsx index 822fa6f..505b3fe 100644 --- a/docker/frontend/src/Admin.tsx +++ b/docker/frontend/src/Admin.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { api, type AdminPhoto, type AdminUser, type PhotoSlot } from './api'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { api, type AdminPhoto, type AdminUser, type PhotoSlot, type Rating } from './api'; import { useI18n } from './i18n/I18nProvider'; import { Profile } from './ui/Profile'; import { QrCode } from './ui/QrCode'; @@ -10,7 +10,9 @@ import type { MsgKey } from './i18n/vi'; // the pane for the chosen section. // Profile — the signed-in account's own picture/email/password // 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 // 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 @@ -45,7 +47,15 @@ export function Admin() { const [photos, setPhotos] = useState([]); const [users, setUsers] = useState([]); const [tab, setTab] = useState('pictures'); - const [owner, setOwner] = useState(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('all'); + const [sel, setSel] = useState(null); + const [nameQuery, setNameQuery] = useState(''); + const [sort, setSort] = useState<'newest' | 'recipe' | 'rating'>('newest'); + const [minStars, setMinStars] = useState(0); + const [ratings, setRatings] = useState>({}); const [busy, setBusy] = useState(false); const [note, setNote] = useState(null); const [picked, setPicked] = useState([]); @@ -58,9 +68,10 @@ export function Admin() { return; } 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); setUsers(u.users); + setRatings(r.ratings); setState('ready'); } catch { setState('forbidden'); @@ -86,9 +97,10 @@ export function Admin() { }; 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); setUsers(u.users); + setRatings(r.ratings); }; const upload = (file: File) => @@ -129,9 +141,11 @@ export function Admin() { }); }; - const showOwner = (email: string) => { - setOwner(email); + // From the users table: open that account's album and put its newest frame up + // in the big box, rather than filtering a flat pool of every upload. + const showOwner = (userId: number) => { setTab('pictures'); + openAlbum(userId); }; // 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(); + 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 (
@@ -248,114 +306,175 @@ export function Admin() { <>

{t('adm.subtitle')}

{t('adm.slotHint')}

- {owner ? ( -

- {owner} - -

- ) : null} - {shown.length === 0 ?

{t('adm.empty')}

: null} + {photos.length === 0 ?

{t('adm.empty')}

: null}
+ {/* One album per account, plus ALL. The list scrolls on its + own, so the strip beside it stays put however many + accounts there are. */}
-

{t('adm.pool')}

-
- {shown.map((p) => ( -
- -
- #{p.id} - {/* The labels the uploader's studio stored, when it - sent any — the same ones the reel draws. */} - {p.tag ? {p.tag} : null} - {p.title ? {p.title} : null} - {p.meta ? {p.meta} : null} - {t('adm.owner')}: {p.email} - {t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()} - {t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime} -
- {/* One checkbox per destination, all three on one - line: tick as many as the photo should appear in, - untick the last one to take it off the landing. */} -
- {DESTINATIONS.map((d) => ( - - ))} -
- -
+

{t('adm.albums')}

+ setNameQuery(e.target.value)} + /> +
+ + +
+
+ + {shownAlbums.map((a) => ( + ))}
+
- {DESTINATIONS.map((d) => { - const inRow = shown.filter((p) => p.slots.includes(d.id)); - return ( -
-

- {t(d.key)} - {inRow.length} -

- {inRow.length === 0 ? ( -

{t('adm.rowEmpty')}

- ) : ( -
- {inRow.map((p) => ( -
- - {/* The QR section is the one whose artefact is - not the photo: it is the link that hands - the look back to the app. A photo uploaded - without a look has none to hand out. */} - {d.id === 'qr' ? ( -
- {p.hasPreset ? ( - - ) : ( - {t('adm.noPreset')} - )} -
- ) : null} -
#{p.id}
-
+ {/* The frame that is up: the full picture, its labels and + the sections it may appear in — the same checkboxes the + flat pool carried, now on the chosen frame. */} +
+ {current ? ( + <> + +
+
+ #{current.id} + {current.tag ? {current.tag} : null} + {current.title ? {current.title} : null} + {current.meta ? {current.meta} : null} + {current.recipeName ? ( + + {t('adm.recipe')}: {current.recipeName} + + ) : null} + + {t('adm.rating')}: {score(current).toFixed(1)} ( + {ratings[`photo:${current.id}`]?.n ?? 0}) + + + {t('adm.owner')}: {current.email} + + + {t('adm.uploaded')}: {new Date(current.createdAt).toLocaleString()} + + + {t('adm.size')}: {Math.round(current.bytes / 1024)} KB · {current.mime} + +
+
+ {DESTINATIONS.map((d) => ( + ))}
- )} -
- ); - })} + {/* The QR section's real artefact is the code, not + 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') ? ( +
+ +
+ ) : null} + +
+ + ) : ( +

{t('adm.bigEmpty')}

+ )} +
+ + {/* The album's strip: one thumbnail per frame, in the order + the filters and the sort chose. */} +
+ {strip.map((p) => ( + + ))} + {strip.length === 0 ?

{t('adm.empty')}

: null} +
@@ -458,7 +577,7 @@ export function Admin() { type="button" className="btn" data-key={`adm-user-photos-${u.id}`} - onClick={() => showOwner(u.email)} + onClick={() => showOwner(u.id)} > {t('adm.viewPhotos')} diff --git a/docker/frontend/src/Landing.tsx b/docker/frontend/src/Landing.tsx index 1cb0c0a..c1945dc 100644 --- a/docker/frontend/src/Landing.tsx +++ b/docker/frontend/src/Landing.tsx @@ -1,5 +1,5 @@ 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 { ACCENTS, FONT_SETS, useTheme, type FontSetId } from './theme/ThemeProvider'; import { AuthModal } from './ui/AuthModal'; @@ -72,12 +72,17 @@ interface Look { meta: string; photo: string; filter: string; + // The subject this frame is rated under: `look:` for a built-in look, + // `photo:` 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 // 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. -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[] = [ { 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: '#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(null); const [user, setUser] = useState(null); const [photos, setPhotos] = useState([]); + const [ratings, setRatings] = useState>({}); // 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. 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. const reload = useCallback(() => { api.listPhotos().then((r) => setPhotos(r.photos)).catch(() => undefined); + api.ratings().then((r) => setRatings(r.ratings)).catch(() => undefined); return api.me().then((r) => { setUser(r.user); return r.user; @@ -212,11 +219,25 @@ export function Landing() { } }, [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 // is repeated once, so the marquee's -50% translation still loops seamlessly. const reel = useMemo( () => [ - ...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 ISO/grain line. A contribution from before those existed keeps the // plain community defaults. @@ -226,6 +247,7 @@ export function Landing() { meta: p.meta ?? `#${p.id}`, photo: api.photoUrl(p.id), filter: 'none', + rateKey: `photo:${p.id}`, })), ], [photos], @@ -571,25 +593,57 @@ export function Landing() { />
- {[...reel, ...reel].map((look, i) => ( -
- -
- -
-
- {c(look.title)} - {look.meta} -
- -
- ))} + {[...reel, ...reel].map((look, i) => { + const score = ratings[look.rateKey] ?? { avg: 0, n: 0, mine: 0 }; + return ( +
+ +
+ +
+
+ {c(look.title)} + {look.meta} + {/* The tally first, then the row that changes it: a visitor + reads the score, then casts one of their own. Clicking + another star replaces the vote instead of adding one. */} +
+ + {score.avg.toFixed(1)} + + + {[1, 2, 3, 4, 5].map((n) => ( + + ))} + + + ({score.n}) + +
+
+ +
+ ); + })}
diff --git a/docker/frontend/src/api.ts b/docker/frontend/src/api.ts index b6e2e53..8579910 100644 --- a/docker/frontend/src/api.ts +++ b/docker/frontend/src/api.ts @@ -75,6 +75,18 @@ export interface AdminPhoto extends Photo { email: string; mime: string; 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 @@ -198,6 +210,11 @@ export const api = { // labels ride the query string, since the body is the image itself. listPhotos: () => call<{ photos: Photo[] }>('/photos'), listMyPhotos: () => call<{ photos: Photo[] }>('/photos/mine'), + // Film-strip ratings, keyed by subject (`photo:` or `look:`). Public: + // any visitor may read the tallies and cast one vote per frame. + ratings: () => call<{ ratings: Record }>('/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) => { const res = await uploadBytes(`/api/photos${photoQuery(labels, opts)}`, file, MAX_PHOTO_DIM, MAX_PHOTO_UPLOAD); const body = await readJson(res); diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 467b34c..aa3101f 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -166,7 +166,18 @@ export const en: Dict = { 'adm.title': 'Strip moderation', 'adm.subtitle': 'Photos contributed by users. Delete one to pull it off the landing page.', '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.pickTester': 'Live preset tester', 'adm.pickCreator': 'Custom recipe creator', @@ -201,7 +212,6 @@ export const en: Dict = { 'adm.roleMember': 'Member', 'adm.viewPhotos': 'VIEW PHOTOS', '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.changeAvatar': 'CHANGE PICTURE', 'adm.currentPassword': 'Current password', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index f065cba..385665b 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -170,7 +170,18 @@ export const vi = { '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.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.pickTester': 'Live preset tester', 'adm.pickCreator': 'Custom recipe creator', @@ -205,7 +216,6 @@ export const vi = { 'adm.roleMember': 'Thành viên', 'adm.viewPhotos': 'XEM ẢNH', '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.changeAvatar': 'ĐỔI ẢNH ĐẠI DIỆN', 'adm.currentPassword': 'Mật khẩu hiện tại', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 6287084..16a6e03 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -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 em { font-style: normal; font-weight: 400; color: var(--accent); } .adm-brand img { border-radius: 8px; } -/* Curating pictures: the pool of uploads on the left, the three landing - sections on the right. One picker button per destination under each photo. */ -.adm-cols { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; } +/* Curating pictures: the albums on the left, the chosen frame and its strip of + thumbnails on the right. The album column is the narrow one — it is a list of + 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); } } .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 - of the card, so the grid gives it the width it needs. */ -.adm-cols .adm-grid { grid-template-columns: repeat(auto-fill, minmax(450px, 1fr)); } +/* The filters over the album list: one name box, two pickers. */ +.adm-input { + 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 checkbox — tick all three and the photo shows in all three sections. */ .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-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: 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; } @@ -610,7 +671,6 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } font-weight: 600; } .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 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; } diff --git a/docker/frontend/src/styles/landing.css b/docker/frontend/src/styles/landing.css index 7b87ef3..f5f2e04 100644 --- a/docker/frontend/src/styles/landing.css +++ b/docker/frontend/src/styles/landing.css @@ -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 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; } +/* 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 --------------------------------------------------- */ .lp-tester { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 26px; align-items: start; }