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:
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 }) }),
|
||||
};
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user