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:
+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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user