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
+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>
);