From 7b79e49c20220eec9515cf2541c5d14eb692e9d8 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 18 Sep 2026 07:55:54 +0700 Subject: [PATCH] Photo slots + admin page: place any upload in the strip or a live slot, sign up in place, brand links home --- docker/backend/src/db.ts | 34 +++++- docker/backend/src/server.ts | 30 +++++- docker/backend/test/security.mjs | 27 +++++ docker/frontend/src/Admin.tsx | 163 +++++++++++++++++++++-------- docker/frontend/src/App.tsx | 5 +- docker/frontend/src/Landing.tsx | 72 ++++++++++--- docker/frontend/src/api.ts | 11 ++ docker/frontend/src/i18n/en.ts | 8 ++ docker/frontend/src/i18n/vi.ts | 8 ++ docker/frontend/src/styles/app.css | 25 ++++- docker/frontend/src/ui/TopBar.tsx | 14 ++- 11 files changed, 320 insertions(+), 77 deletions(-) diff --git a/docker/backend/src/db.ts b/docker/backend/src/db.ts index 98673bc..57e2ff1 100644 --- a/docker/backend/src/db.ts +++ b/docker/backend/src/db.ts @@ -56,6 +56,23 @@ CREATE INDEX IF NOT EXISTS idx_recipes_user ON recipes(user_id); CREATE INDEX IF NOT EXISTS idx_photos_user ON photos(user_id); `); +// Where a curated photo is allowed to appear on the landing page: the community +// strip, the live tester's preview, the creator lab's preview, or the QR card. +// One is picked at random out of its slot on every page load. +export const PHOTO_SLOTS = ['strip', 'tester', 'creator', 'qr'] as const; +export type PhotoSlot = (typeof PHOTO_SLOTS)[number]; +export const isPhotoSlot = (v: unknown): v is PhotoSlot => + typeof v === 'string' && (PHOTO_SLOTS as readonly string[]).includes(v); + +// The column arrived after the first strips were already on disk, so add it in +// place — `CREATE TABLE IF NOT EXISTS` would silently skip an existing table. +{ + const cols = db.prepare('PRAGMA table_info(photos)').all() as { name: string }[]; + if (!cols.some((c) => c.name === 'slot')) { + db.exec(`ALTER TABLE photos ADD COLUMN slot TEXT NOT NULL DEFAULT 'strip'`); + } +} + export type User = { id: number; email: string }; export type Recipe = { id: number; @@ -179,20 +196,21 @@ export function deleteRecipe(userId: number, id: number): boolean { // ---- contributed strip photos ------------------------------------------- // The public shape carries no owner: the landing page is anonymous, so the // uploader's email must never be reachable from an unauthenticated request. -export type Photo = { id: number; createdAt: string }; +export type Photo = { id: number; createdAt: string; slot: PhotoSlot }; export type AdminPhoto = Photo & { userId: number; email: string; mime: string; bytes: number }; export function listPhotos(): Photo[] { return db - .prepare('SELECT id, created_at AS createdAt FROM photos ORDER BY id DESC') + .prepare('SELECT id, created_at AS createdAt, slot FROM photos ORDER BY id DESC') .all() as Photo[]; } export function listPhotosWithOwner(): AdminPhoto[] { return db .prepare( - `SELECT photos.id AS id, photos.created_at AS createdAt, photos.user_id AS userId, - photos.mime AS mime, photos.bytes AS bytes, users.email AS email + `SELECT photos.id AS id, photos.created_at AS createdAt, photos.slot AS slot, + photos.user_id AS userId, photos.mime AS mime, photos.bytes AS bytes, + users.email AS email FROM photos JOIN users ON users.id = photos.user_id ORDER BY photos.id DESC`, ) @@ -208,7 +226,8 @@ export function createPhoto(userId: number, file: string, mime: string, bytes: n const info = db .prepare('INSERT INTO photos (user_id, file, mime, bytes, created_at) VALUES (?, ?, ?, ?, ?)') .run(userId, file, mime, bytes, ts); - return { id: Number(info.lastInsertRowid), createdAt: ts }; + // A fresh upload is a strip photo until the curator moves it to a live slot. + return { id: Number(info.lastInsertRowid), createdAt: ts, slot: 'strip' }; } // The stored file name is only ever used through here, and callers must still @@ -226,6 +245,11 @@ export function deletePhoto(id: number): string | undefined { return row.file; } +// Curating, not moderating: where this photo is allowed to surface. +export function setPhotoSlot(id: number, slot: PhotoSlot): boolean { + return db.prepare('UPDATE photos SET slot = ? WHERE id = ?').run(slot, id).changes > 0; +} + 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(); diff --git a/docker/backend/src/server.ts b/docker/backend/src/server.ts index 2de05c0..e6f345a 100644 --- a/docker/backend/src/server.ts +++ b/docker/backend/src/server.ts @@ -19,12 +19,14 @@ import { deleteRecipe, deleteSession, findUserByEmail, + isPhotoSlot, listPhotos, listPhotosWithOwner, listRecipes, photoFile, photoPath, sessionUser, + setPhotoSlot, updateRecipe, verifyPassword, type Recipe, @@ -51,6 +53,11 @@ const ADMIN_EMAILS = new Set( ); const isAdmin = (user: User) => ADMIN_EMAILS.has(user.email.toLowerCase()); +// The public shape of an account. `admin` is the allowlist's answer, so the +// client can decide whether to offer /admin without a second round trip — and +// the server still enforces it on every admin route below. +const publicUser = (user: User) => ({ id: user.id, email: user.email, admin: isAdmin(user) }); + const app = Fastify({ logger: true, bodyLimit: 1024 * 1024, @@ -199,7 +206,7 @@ app.post('/api/auth/signup', async (req, reply) => { const user = createUser(creds.email, creds.password); if (!user) return reply.status(409).send({ error: 'email already registered' }); setSession(req, reply, createSession(user.id)); - return reply.status(201).send({ user }); + return reply.status(201).send({ user: publicUser(user) }); }); app.post('/api/auth/login', async (req, reply) => { @@ -212,7 +219,7 @@ app.post('/api/auth/login', async (req, reply) => { const ok = verifyPassword(b.password, row?.password_hash ?? DUMMY_HASH); if (!row || !ok) return reply.status(401).send({ error: 'invalid credentials' }); setSession(req, reply, createSession(row.id)); - return reply.status(200).send({ user: { id: row.id, email: row.email } }); + return reply.status(200).send({ user: publicUser({ id: row.id, email: row.email }) }); }); app.post('/api/auth/logout', async (req, reply) => { @@ -227,7 +234,7 @@ app.get('/api/auth/me', async (req, reply) => { // nobody. A 401 here would put a console error on every anonymous visit to // the landing page, which asks the same question to decide what to offer. const user = auth(req); - return reply.status(200).send({ user: user ?? null }); + return reply.status(200).send({ user: user ? publicUser(user) : null }); }); app.get('/api/recipes', async (req, reply) => { @@ -289,7 +296,8 @@ app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply const mime = sniffImage(body); if (!mime || mime !== declared) return reply.status(415).send({ error: 'unsupported image type' }); - if (countPhotos(user.id) >= MAX_PHOTOS_PER_USER) + // The quota is a fair-use cap on members, not on the curator. + if (!isAdmin(user) && countPhotos(user.id) >= MAX_PHOTOS_PER_USER) return reply.status(429).send({ error: 'photo quota reached' }); const file = `${randomBytes(16).toString('hex')}.${EXT[mime]}`; @@ -366,6 +374,20 @@ app.delete('/api/admin/photos', async (req, reply) => { return reply.status(200).send({ removed: files.length }); }); +// Curating: which slot on the landing page this photo is allowed to appear in. +// The landing page picks one at random per slot, so several photos in one slot +// rotate between visits. +app.patch<{ Params: { id: string } }>('/api/admin/photos/:id', async (req, reply) => { + const user = admin(req); + if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' }); + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' }); + const b = bodyOf(req); + if (!b || !isPhotoSlot(b.slot)) return reply.status(400).send({ error: 'invalid slot' }); + if (!setPhotoSlot(id, b.slot)) return reply.status(404).send({ error: 'photo not found' }); + return reply.status(200).send({ id, slot: b.slot }); +}); + app .listen({ port: PORT, host: HOST }) .catch((err) => { diff --git a/docker/backend/test/security.mjs b/docker/backend/test/security.mjs index 8aec8af..3565316 100644 --- a/docker/backend/test/security.mjs +++ b/docker/backend/test/security.mjs @@ -224,8 +224,29 @@ try { const rows = adminList.body?.photos ?? []; check('an admin lists contributions', adminList.status === 200 && rows.length > 0); check('the admin listing carries the owner', rows.some((r) => /@test\.local$/.test(r.email ?? ''))); + check('a fresh upload lands in the strip slot', rows.find((r) => r.id === id)?.slot === 'strip'); check('a plain member cannot delete', (await user.req(`/admin/photos/${id}`, { method: 'DELETE' })).status === 403); + // ---- placement ---------------------------------------------------------- + const patch = (path, body) => + admin.req(path, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + check( + 'a plain member cannot place a photo', + (await user.req(`/admin/photos/${id}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ slot: 'qr' }), + })).status === 403, + ); + const placed = await patch(`/admin/photos/${id}`, { slot: 'qr' }); + check('an admin moves a photo to a live slot', placed.status === 200 && placed.body?.slot === 'qr', JSON.stringify(placed.body)); + check( + 'the slot is public, the owner is not', + ((await actor().req('/photos')).body?.photos ?? []).find((r) => r.id === id)?.slot === 'qr', + ); + check('an unknown slot is refused', (await patch(`/admin/photos/${id}`, { slot: 'nope' })).status === 400); + check('placing an unknown photo is a 404', (await patch('/admin/photos/999999', { slot: 'qr' })).status === 404); + const deleted = await admin.req(`/admin/photos/${id}`, { method: 'DELETE' }); check('an admin deletes a contribution', deleted.status === 204, `got ${deleted.status}`); check('the deleted file is gone from disk', (await fetch(`${BASE}/photos/${id}/file`)).status === 404); @@ -238,6 +259,12 @@ try { for (let i = 0; i < 13; i++) last = (await quota.upload(PNG, 'image/png')).status; check('uploads are capped per account', last === 429, `13th upload: ${last}`); + // The cap is a member fair-use rule; the curator stocks the landing page from + // one account, so it must not apply to the allowlist. + let adminLast = 0; + for (let i = 0; i < 13; i++) adminLast = (await admin.upload(PNG, 'image/png')).status; + check('the admin is exempt from the member quota', adminLast === 201, `13th admin upload: ${adminLast}`); + const cleared = await admin.req('/admin/photos', { method: 'DELETE' }); check('an admin clears the strip in one call', cleared.status === 200 && cleared.body?.removed > 0, JSON.stringify(cleared.body)); check('the strip is empty afterwards', ((await actor().req('/photos')).body?.photos ?? []).length === 0); diff --git a/docker/frontend/src/Admin.tsx b/docker/frontend/src/Admin.tsx index aa78e79..74a4549 100644 --- a/docker/frontend/src/Admin.tsx +++ b/docker/frontend/src/Admin.tsx @@ -1,20 +1,32 @@ -import { useCallback, useEffect, useState } from 'react'; -import { api, type AdminPhoto } from './api'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { api, type AdminPhoto, type PhotoSlot } from './api'; import { useI18n } from './i18n/I18nProvider'; +import type { MsgKey } from './i18n/vi'; -// Moderation screen for the contributed strip: list, delete one, clear all. -// It holds no privilege of its own — the API answers 403 unless the signed-in -// account is on the ADMIN_EMAILS allowlist, so this page is only a viewer. +// Curating screen: list every contributed photo, upload, delete, and say where +// each one may surface on the landing page. It holds no privilege of its own — +// the API answers 403 unless the signed-in account is on the ADMIN_EMAILS +// allowlist, so this page is only a viewer. // ponytail: no pagination. The upload quota caps the table at a handful of // rows per account; add a page cursor when the strip outgrows one screen. type State = 'loading' | 'guest' | 'forbidden' | 'ready'; +// The four places a photo can be drawn from. `strip` feeds the community reel; +// each live slot shows one photo of its set, picked at random per page load. +const SLOTS: { id: PhotoSlot; key: MsgKey }[] = [ + { id: 'strip', key: 'adm.slotStrip' }, + { id: 'tester', key: 'adm.slotTester' }, + { id: 'creator', key: 'adm.slotCreator' }, + { id: 'qr', key: 'adm.slotQr' }, +]; + export function Admin() { const { t } = useI18n(); const [state, setState] = useState('loading'); const [photos, setPhotos] = useState([]); const [busy, setBusy] = useState(false); const [note, setNote] = useState(null); + const filePick = useRef(null); const load = useCallback(async () => { const me = await api.me().catch(() => null); @@ -35,12 +47,13 @@ export function Admin() { void load(); }, [load]); - const remove = async (id: number) => { + // Every action reports its own failure into `note` and never throws: one bad + // upload must not take the whole screen down. + const run = async (fn: () => Promise) => { setBusy(true); setNote(null); try { - await api.adminDeletePhoto(id); - setPhotos((prev) => prev.filter((p) => p.id !== id)); + await fn(); } catch (err) { setNote(err instanceof Error ? err.message : String(err)); } finally { @@ -48,32 +61,73 @@ export function Admin() { } }; - const clear = async () => { + const upload = (file: File) => + run(async () => { + await api.uploadPhoto(file); + const r = await api.adminListPhotos(); + setPhotos(r.photos); + }); + + const remove = (id: number) => + run(async () => { + await api.adminDeletePhoto(id); + setPhotos((prev) => prev.filter((p) => p.id !== id)); + }); + + const setSlot = (id: number, slot: PhotoSlot) => + run(async () => { + await api.adminSetPhotoSlot(id, slot); + setPhotos((prev) => prev.map((p) => (p.id === id ? { ...p, slot } : p))); + }); + + const clear = () => { if (!window.confirm(t('adm.clearConfirm', { n: photos.length }))) return; - setBusy(true); - setNote(null); - try { + return run(async () => { const r = await api.adminClearPhotos(); setPhotos([]); setNote(t('adm.removed', { n: r.removed })); - } catch (err) { - setNote(err instanceof Error ? err.message : String(err)); - } finally { - setBusy(false); - } + }); }; return (
- {t('adm.back')} + + + RecipesCam + {t('adm.title')} {t('adm.total', { n: photos.length })} - {state === 'ready' && photos.length > 0 ? ( - + {state === 'ready' ? ( + <> + + { + const file = e.target.files?.[0]; + e.target.value = ''; + if (file) void upload(file); + }} + /> + {photos.length > 0 ? ( + + ) : null} + ) : null}
@@ -83,40 +137,57 @@ export function Admin() { {state === 'guest' ? (

- {t('adm.signIn')} + {t('adm.signIn')}

) : null} {state === 'forbidden' ?

{t('adm.notAdmin')}

: null} + {state === 'ready' ? ( + <> +

{t('adm.subtitle')}

+

{t('adm.slotHint')}

+ + ) : null} + {state === 'ready' && photos.length === 0 ?

{t('adm.empty')}

: null} {state === 'ready' && photos.length > 0 ? ( - <> -

{t('adm.subtitle')}

-
- {photos.map((p) => ( -
- -
- #{p.id} - {t('adm.owner')}: {p.email} - {t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()} - {t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime} -
- +
+ ))} +
) : null}
); diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 7e5735c..05d9808 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -290,8 +290,8 @@ export function Workspace() { // Recipes made in this session, guest ones included. A guest's copy lives // here and nowhere else, so reloading the page is what deletes it. const [created, setCreated] = useState([]); - // null = closed. The landing's register link is /app?auth=1, so it opens the - // dialog straight on the sign-up tab. + // null = closed. /app?auth=1 opens the studio's dialog straight on the sign-up + // tab; the landing page runs the same dialog in place instead of linking here. const [authMode, setAuthMode] = useState<'login' | 'signup' | null>(() => (new URLSearchParams(window.location.search).has('auth') ? 'signup' : null)); @@ -1113,6 +1113,7 @@ export function Workspace() { onExport={exportPhoto} onSave={saveRecipe} onAuth={() => setAuthMode('login')} + onSignup={() => setAuthMode('signup')} onLogout={async () => { await api.logout().catch(() => undefined); setUser(null); diff --git a/docker/frontend/src/Landing.tsx b/docker/frontend/src/Landing.tsx index e5bcd0f..11b3a6f 100644 --- a/docker/frontend/src/Landing.tsx +++ b/docker/frontend/src/Landing.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { api, type Photo, type User } from './api'; +import { api, type Photo, type PhotoSlot, type User } from './api'; import { useI18n } from './i18n/I18nProvider'; import { FONT_SETS, useTheme } from './theme/ThemeProvider'; +import { AuthModal } from './ui/AuthModal'; // Landing page, built to the landing blueprint: the 10-section funnel. The // marketing prose is bilingual and lives here next to the markup as `Txt` @@ -112,6 +113,12 @@ export function Landing() { const [qrOk, setQrOk] = useState(true); const [user, setUser] = useState(null); const [photos, setPhotos] = 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>(() => { + const asked = new URLSearchParams(window.location.search).get('auth'); + return asked === 'login' ? 'login' : asked ? 'signup' : null; + }); const [uploading, setUploading] = useState(false); const filePick = useRef(null); const toastTimer = useRef>(); @@ -130,15 +137,13 @@ export function Landing() { // The community half of the reel: whoever is signed in gets a picker, and // everybody sees the result on the next load. A guest is sent to sign up. - useEffect(() => { - let alive = true; - api.me().then((r) => alive && setUser(r.user)).catch(() => undefined); - api.listPhotos().then((r) => alive && setPhotos(r.photos)).catch(() => undefined); - return () => { - alive = false; - }; + const reload = useCallback(() => { + api.me().then((r) => setUser(r.user)).catch(() => undefined); + api.listPhotos().then((r) => setPhotos(r.photos)).catch(() => undefined); }, []); + useEffect(reload, [reload]); + const contribute = useCallback(async (file: File) => { setUploading(true); try { @@ -157,7 +162,7 @@ export function Landing() { const reel = useMemo( () => [ ...REEL, - ...photos.map((p) => ({ + ...photos.filter((p) => p.slot === 'strip').map((p) => ({ tag: '#COMMUNITY', title: { en: 'Community Frame', vi: 'Khung cộng đồng' }, meta: `#${p.id}`, @@ -168,6 +173,17 @@ export function Landing() { [photos], ); + // The three live slots. One random photo out of each set, drawn once per page + // load so the preview does not change under the visitor's eyes on every + // render; the built-in sample stands in while a slot has no curation. + const slotPhoto = useMemo(() => { + const pick = (slot: PhotoSlot, fallback: string) => { + const list = photos.filter((p) => p.slot === slot); + return list.length ? api.photoUrl(list[Math.floor(Math.random() * list.length)].id) : fallback; + }; + return { tester: pick('tester', SAMPLE(1043)), creator: pick('creator', SAMPLE(1015)), qr: pick('qr', SAMPLE(1069)) }; + }, [photos]); + // The theme popover closes on a click anywhere else, like the workspace's. useEffect(() => { if (!themeMenu) return; @@ -198,7 +214,7 @@ export function Landing() { {/* 1 — fixed glass navigation */}