Photo slots + admin page: place any upload in the strip or a live slot, sign up in place, brand links home

This commit is contained in:
2026-09-18 07:55:54 +07:00
parent bd2f08aaf7
commit 7b79e49c20
11 changed files with 320 additions and 77 deletions
+29 -5
View File
@@ -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();
+26 -4
View File
@@ -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) => {
+27
View File
@@ -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);
+117 -46
View File
@@ -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<State>('loading');
const [photos, setPhotos] = useState<AdminPhoto[]>([]);
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<string | null>(null);
const filePick = useRef<HTMLInputElement>(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<void>) => {
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 (
<div className="adm">
<header className="adm-bar">
<a className="btn ghost" href="/">{t('adm.back')}</a>
<a className="adm-brand" data-key="adm-brand" href="/">
<img src="/assets/RecipesCamIcon.png" alt="" width="26" height="26" />
<span>Recipes<em>Cam</em></span>
</a>
<b className="adm-title">{t('adm.title')}</b>
<span className="adm-spacer" />
<span className="hint">{t('adm.total', { n: photos.length })}</span>
{state === 'ready' && photos.length > 0 ? (
<button type="button" className="btn adm-danger" data-key="adm-clear" disabled={busy} onClick={() => void clear()}>
{t('adm.clearAll')}
</button>
{state === 'ready' ? (
<>
<button
type="button"
className="btn primary"
data-key="adm-upload"
disabled={busy}
onClick={() => filePick.current?.click()}
>
{busy ? t('auth.busy') : t('adm.upload')}
</button>
<input
ref={filePick}
type="file"
hidden
accept="image/jpeg,image/png,image/webp"
data-key="adm-upload-input"
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) void upload(file);
}}
/>
{photos.length > 0 ? (
<button type="button" className="btn adm-danger" data-key="adm-clear" disabled={busy} onClick={() => void clear()}>
{t('adm.clearAll')}
</button>
) : null}
</>
) : null}
</header>
@@ -83,40 +137,57 @@ export function Admin() {
{state === 'guest' ? (
<p className="adm-note">
<a className="btn primary" href="/app?auth=1">{t('adm.signIn')}</a>
<a className="btn primary" href="/?auth=login">{t('adm.signIn')}</a>
</p>
) : null}
{state === 'forbidden' ? <p className="err adm-note">{t('adm.notAdmin')}</p> : null}
{state === 'ready' ? (
<>
<p className="hint adm-sub">{t('adm.subtitle')}</p>
<p className="hint adm-sub">{t('adm.slotHint')}</p>
</>
) : null}
{state === 'ready' && photos.length === 0 ? <p className="hint adm-note">{t('adm.empty')}</p> : null}
{state === 'ready' && photos.length > 0 ? (
<>
<p className="hint adm-sub">{t('adm.subtitle')}</p>
<div className="adm-grid">
{photos.map((p) => (
<article className="adm-card" key={p.id} data-key={`adm-photo-${p.id}`}>
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
<div className="adm-meta">
<b>#{p.id}</b>
<span>{t('adm.owner')}: {p.email}</span>
<span>{t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()}</span>
<span>{t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime}</span>
</div>
<button
type="button"
className="btn adm-danger"
data-key={`adm-del-${p.id}`}
<div className="adm-grid">
{photos.map((p) => (
<article className="adm-card" key={p.id} data-key={`adm-photo-${p.id}`}>
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
<div className="adm-meta">
<b>#{p.id}</b>
<span>{t('adm.owner')}: {p.email}</span>
<span>{t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()}</span>
<span>{t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime}</span>
</div>
<label className="hint adm-slot">
{t('adm.slot')}
<select
data-key={`adm-slot-${p.id}`}
value={p.slot}
disabled={busy}
onClick={() => void remove(p.id)}
onChange={(e) => void setSlot(p.id, e.target.value as PhotoSlot)}
>
{t('adm.delete')}
</button>
</article>
))}
</div>
</>
{SLOTS.map((s) => (
<option key={s.id} value={s.id}>{t(s.key)}</option>
))}
</select>
</label>
<button
type="button"
className="btn adm-danger"
data-key={`adm-del-${p.id}`}
disabled={busy}
onClick={() => void remove(p.id)}
>
{t('adm.delete')}
</button>
</article>
))}
</div>
) : null}
</div>
);
+3 -2
View File
@@ -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<Recipe[]>([]);
// 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);
+55 -17
View File
@@ -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<User | null>(null);
const [photos, setPhotos] = useState<Photo[]>([]);
// 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<HTMLInputElement>(null);
const toastTimer = useRef<ReturnType<typeof setTimeout>>();
@@ -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<Look[]>(
() => [
...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 */}
<nav className="lp-nav">
<div className="lp-wrap">
<a className="lp-logo" href="#top" onClick={() => setMenu(false)}>
<a className="lp-logo" href="/" onClick={() => setMenu(false)}>
<img className="lp-logo-mark" src="/assets/RecipesCamIcon.png" alt="" width="30" height="30" />
{/* One span, not two siblings: .lp-logo is a flex row, so a bare
text node + <em> would get the 6px gap between them. */}
@@ -268,8 +284,10 @@ export function Landing() {
{lang.toUpperCase()}
</button>
</span>
{/* Register, as the old landing had: straight into the sign-up dialog. */}
<a className="lp-btn lp-sm" href="/app?auth=1">{t('nav.signup')}</a>
{/* Register: the dialog opens here, on the page the visitor is on. */}
<button type="button" className="lp-btn lp-sm" data-key="nav-signup" onClick={() => setAuth('signup')}>
{t('nav.signup')}
</button>
<button type="button" className="lp-btn lp-sm lp-primary" onClick={() => showToast(toastDownload)}>
{c({ en: 'Download RecipesCam', vi: 'Tải RecipesCam' })}
</button>
@@ -281,7 +299,14 @@ export function Landing() {
<div className="lp-sheet">
{nav.map((entry) => <a key={entry.href} href={entry.href} onClick={() => setMenu(false)}>{c(entry.label)}</a>)}
<a href="/app" onClick={() => setMenu(false)}>{c({ en: 'Open studio', vi: 'Mở studio' })}</a>
<a href="/app?auth=1" onClick={() => setMenu(false)}>{t('nav.signup')}</a>
<button
type="button"
className="lp-btn"
data-key="sheet-signup"
onClick={() => { setMenu(false); setAuth('signup'); }}
>
{t('nav.signup')}
</button>
<button
type="button"
className="lp-btn lp-primary"
@@ -337,7 +362,9 @@ export function Landing() {
</button>
) : (
/* A guest gets the same button; it opens the sign-up dialog. */
<a className="lp-btn lp-primary" data-key="contribute" href="/app?auth=1">{t('photo.contribute')}</a>
<button type="button" className="lp-btn lp-primary" data-key="contribute" onClick={() => setAuth('signup')}>
{t('photo.contribute')}
</button>
)}
<input
ref={filePick}
@@ -393,7 +420,7 @@ export function Landing() {
))}
</div>
<div className="lp-view">
<img src={SAMPLE(1043)} alt={`${preset.name} preview`} style={{ filter: preset.filter }} />
<img src={slotPhoto.tester} alt={`${preset.name} preview`} style={{ filter: preset.filter }} />
<div className="lp-hud">
<span>f/<b>1.8</b></span>
<span>SS <b>1/500s</b></span>
@@ -430,7 +457,7 @@ export function Landing() {
</div>
<div className="lp-lab">
<div className="lp-view">
<img src={SAMPLE(1015)} alt={c({ en: 'Custom recipe preview', vi: 'Xem trước công thức riêng' })} style={{ filter: labFilter }} />
<img src={slotPhoto.creator} alt={c({ en: 'Custom recipe preview', vi: 'Xem trước công thức riêng' })} style={{ filter: labFilter }} />
<div className="lp-hud"><span>{c({ en: 'CUSTOM RECIPE', vi: 'CÔNG THỨC RIÊNG' })}</span><span>TEMP <b>{temp > 0 ? `+${temp}` : temp}</b></span><span>SAT <b>{sat}%</b></span><span>CON <b>{con}%</b></span></div>
</div>
<div className="lp-knobs">
@@ -489,7 +516,7 @@ export function Landing() {
<div className="lp-qr">
<div className="lp-recipe-card">
<div className="lp-shot">
<img src={SAMPLE(1069)} alt={c({ en: 'Shared recipe card', vi: 'Thẻ công thức được chia sẻ' })} style={{ filter: PRESETS[2].filter }} />
<img src={slotPhoto.qr} alt={c({ en: 'Shared recipe card', vi: 'Thẻ công thức được chia sẻ' })} style={{ filter: PRESETS[2].filter }} />
<span className="lp-grain" aria-hidden="true" />
<span className="lp-tagline">#SUNSET_GLOW</span>
</div>
@@ -637,6 +664,17 @@ export function Landing() {
</div>
</footer>
{auth ? (
<AuthModal
initialMode={auth}
onClose={() => setAuth(null)}
onDone={() => {
setAuth(null);
reload();
}}
/>
) : null}
{toast ? <div className="lp-toast" role="status"><b>{c({ en: 'Coming soon —', vi: 'Sắp có —' })}</b> {toast}</div> : null}
</div>
);
+11
View File
@@ -5,6 +5,9 @@ import type { Recipe } from '../shared/types';
export interface User {
id: number;
email: string;
// True when the account is on the API's ADMIN_EMAILS allowlist. The server
// re-checks it on every admin route; this only drives what the UI offers.
admin?: boolean;
}
export interface SavedRecipe {
@@ -15,11 +18,17 @@ export interface SavedRecipe {
updatedAt: string;
}
// Where a curated photo may appear on the landing page. `strip` is the
// community reel; the rest are the three live slots, each of which shows one
// random photo out of its set per page load.
export type PhotoSlot = 'strip' | 'tester' | 'creator' | 'qr';
// A strip contribution as the public sees it — the API never puts an email on
// this shape.
export interface Photo {
id: number;
createdAt: string;
slot: PhotoSlot;
}
// Admin listing only: adds the owner, which /api/admin/photos is gated on.
@@ -79,4 +88,6 @@ export const api = {
adminListPhotos: () => call<{ photos: AdminPhoto[] }>('/admin/photos'),
adminDeletePhoto: (id: number) => call<void>(`/admin/photos/${id}`, { method: 'DELETE' }),
adminClearPhotos: () => call<{ removed: number }>('/admin/photos', { method: 'DELETE' }),
adminSetPhotoSlot: (id: number, slot: PhotoSlot) =>
call<{ id: number; slot: PhotoSlot }>(`/admin/photos/${id}`, { method: 'PATCH', body: JSON.stringify({ slot }) }),
};
+8
View File
@@ -112,8 +112,16 @@ export const en: Dict = {
'photo.thanks': 'Your frame is in the strip.',
'photo.failed': 'Upload failed: {msg}',
'nav.admin': 'Admin',
'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.slot': 'Shows up in',
'adm.slotStrip': 'Community film strip',
'adm.slotTester': 'Live tester (lp-tester)',
'adm.slotCreator': 'Live recipe section (lp-sec)',
'adm.slotQr': 'QR card (lp-qr)',
'adm.slotHint': 'The three live slots each draw one photo out of their set at random, so several photos in the same slot rotate between visits.',
'adm.signIn': 'Sign in with an admin account',
'adm.notAdmin': 'This account has no admin rights.',
'adm.empty': 'No contributions yet.',
+8
View File
@@ -14,6 +14,7 @@ export const vi = {
'nav.theme': 'Giao diện',
'nav.language': 'Ngôn ngữ',
'nav.back': 'Về trang chủ',
'nav.admin': 'Quản trị',
'nav.guest': 'Khách',
'act.undo': 'HOÀN TÁC',
@@ -117,6 +118,13 @@ 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.slot': 'Hiển thị ở',
'adm.slotStrip': 'Dải phim cộng đồng',
'adm.slotTester': 'Live tester (lp-tester)',
'adm.slotCreator': 'Mục công thức live (lp-sec)',
'adm.slotQr': 'Thẻ QR (lp-qr)',
'adm.slotHint': 'Ba vị trí live mỗi lần vào trang sẽ bốc ngẫu nhiên một ảnh trong nhóm, nên nhiều ảnh cùng một vị trí sẽ luân phiên nhau.',
'adm.signIn': 'Đăng nhập bằng tài khoản quản trị',
'adm.notAdmin': 'Tài khoản này không có quyền quản trị.',
'adm.empty': 'Chưa có ảnh đóng góp nào.',
+23 -2
View File
@@ -293,9 +293,30 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
.modal h2 { margin: 0; font-size: 17px; }
/* --- strip moderation (/admin) ------------------------------------------ */
.adm { height: 100%; overflow-y: auto; padding: 18px 22px 40px; }
.adm-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 16px; }
.adm { height: 100%; overflow-y: auto; padding: 0 22px 40px; }
/* Sticky: the mark and the page name stay in the top-left corner while the
photo grid scrolls under them, like the studio's header and the landing nav. */
.adm-bar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 5;
margin: 0 -22px 16px;
padding: 12px 22px;
background: var(--bg);
border-bottom: 1px solid var(--border-soft);
}
.adm-title { font-size: 15px; letter-spacing: 0.02em; }
/* The mark and the page name, top-left, always a way back to the landing page —
the same pair the studio's TopBar carries. */
.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(--text-dim); }
.adm-brand img { border-radius: 8px; }
.adm-slot { display: flex; flex-direction: column; gap: 3px; letter-spacing: 0.04em; }
.adm-slot select { width: 100%; }
.adm-spacer { flex: 1 1 auto; }
.adm-note { margin: 12px 0; }
.adm-sub { margin: 0 0 14px; }
+13 -1
View File
@@ -15,6 +15,7 @@ export function TopBar({
onExport,
onSave,
onAuth,
onSignup,
onLogout,
}: {
recipeName: string;
@@ -26,6 +27,7 @@ export function TopBar({
onExport: () => void;
onSave: () => void;
onAuth: () => void;
onSignup: () => void;
onLogout: () => void;
}) {
const { t, lang, setLang } = useI18n();
@@ -129,10 +131,20 @@ export function TopBar({
{user ? (
<>
<p className="hint">{t('auth.loggedInAs', { email: user.email })}</p>
{user.admin ? (
<a className="btn" data-key="nav-admin" href="/admin">{t('nav.admin')}</a>
) : null}
<button type="button" className="btn" onClick={onLogout}>{t('nav.logout')}</button>
</>
) : (
<button type="button" className="btn primary" onClick={onAuth}>{t('nav.login')}</button>
<>
<button type="button" className="btn primary" data-key="nav-login" onClick={onAuth}>
{t('nav.login')}
</button>
<button type="button" className="btn" data-key="nav-signup" onClick={onSignup}>
{t('nav.signup')}
</button>
</>
)}
</div>
) : null}