import { useCallback, useEffect, useRef, useState } from 'react'; import { api, type AdminPhoto, type AdminUser, type PhotoSlot } from './api'; import { useI18n } from './i18n/I18nProvider'; import { Profile } from './ui/Profile'; import type { MsgKey } from './i18n/vi'; // Curating screen: one bordered frame, a left column of what can be managed and // the pane for the chosen section. // Profile — the signed-in account's own picture/email/password // User account — every account: its name, email, picture and contribution count // Pictures — every photo in the system, with the slot that shows it // Close — leaves the frame and goes back to the landing page // The frame holds no privilege of its own — the API answers 403 unless the // signed-in account is on the ADMIN_EMAILS allowlist, so this 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'; type Tab = 'profile' | 'users' | 'pictures'; // 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' }, ]; // The left column, in the order it reads. const MENU: { id: Tab; key: MsgKey }[] = [ { id: 'profile', key: 'adm.tabProfile' }, { id: 'users', key: 'adm.tabUsers' }, { id: 'pictures', key: 'adm.tabPictures' }, ]; export function Admin() { const { t } = useI18n(); const [state, setState] = useState('loading'); const [photos, setPhotos] = useState([]); const [users, setUsers] = useState([]); const [tab, setTab] = useState('pictures'); const [owner, setOwner] = useState(null); const [busy, setBusy] = useState(false); const [note, setNote] = useState(null); const [picked, setPicked] = useState([]); const filePick = useRef(null); const load = useCallback(async () => { const me = await api.me().catch(() => null); if (!me?.user) { setState('guest'); return; } try { const [p, u] = await Promise.all([api.adminListPhotos(), api.adminListUsers()]); setPhotos(p.photos); setUsers(u.users); setState('ready'); } catch { setState('forbidden'); } }, []); useEffect(() => { void load(); }, [load]); // 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 fn(); } catch (err) { setNote(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } }; const refreshPhotos = async () => { const [p, u] = await Promise.all([api.adminListPhotos(), api.adminListUsers()]); setPhotos(p.photos); setUsers(u.users); }; const upload = (file: File) => run(async () => { await api.uploadPhoto(file); await refreshPhotos(); }); const remove = (id: number) => run(async () => { await api.adminDeletePhoto(id); await refreshPhotos(); }); 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; return run(async () => { const r = await api.adminClearPhotos(); await refreshPhotos(); setNote(t('adm.removed', { n: r.removed })); }); }; const showOwner = (email: string) => { setOwner(email); setTab('pictures'); }; // Block/unblock and remove/restore share one route; the list is reloaded // because either flag also changes what the built-in viewer would show. const setUserState = (id: number, patch: { blocked?: boolean; removed?: boolean }) => run(async () => { await api.adminSetUser(id, patch); await refreshPhotos(); }); const deleteUser = (u: AdminUser) => { if (!window.confirm(t('adm.userDeleteConfirm', { email: u.email }))) return; return run(async () => { await api.adminDeleteUser(u.id); await refreshPhotos(); }); }; // The users table's bulk actions. An admin account is the API's own privilege // source, so it is never a moderation target: select-all skips it and a stray // id is filtered out before the deletes run. const selectable = users.filter((u) => !u.admin).map((u) => u.id); const allPicked = selectable.length > 0 && selectable.every((id) => picked.includes(id)); const togglePicked = (id: number) => setPicked((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); const deleteSelected = () => { const ids = picked.filter((id) => selectable.includes(id)); if (ids.length === 0) return; if (!window.confirm(t('adm.userDeleteSelectedConfirm', { n: ids.length }))) return; return run(async () => { for (const id of ids) await api.adminDeleteUser(id); setPicked([]); await refreshPhotos(); }); }; const shown = owner ? photos.filter((p) => p.email === owner) : photos; return (
RecipesCam {t('adm.title')} {t('adm.total', { n: photos.length })} {state === 'ready' ? ( <> { const file = e.target.files?.[0]; e.target.value = ''; if (file) void upload(file); }} /> {photos.length > 0 ? ( ) : null} ) : null}
{note ?

{note}

: null} {state === 'loading' ?

{t('auth.busy')}

: null} {state === 'guest' ? (

{t('adm.signIn')}

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

{t('adm.notAdmin')}

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

{t('adm.subtitle')}

{t('adm.slotHint')}

{owner ? (

{owner}

) : null} {shown.length === 0 ?

{t('adm.empty')}

: null}
{shown.map((p) => (
#{p.id} {/* The labels the uploader's studio stored, when it sent any — the same ones the reel draws. */} {p.tag ? {p.tag} : null} {p.title ? {p.title} : null} {p.meta ? {p.meta} : null} {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} {tab === 'users' ? ( users.length === 0 ?

{t('adm.noUsers')}

: ( <>
{users.map((u) => ( ))}
setPicked(allPicked ? [] : selectable)} /> {t('adm.userPicture')} {t('adm.userName')} {t('adm.userEmail')} {t('adm.userRole')} {t('adm.userPhotos')} {t('adm.userJoined')}
{u.admin ? null : ( togglePicked(u.id)} /> )} {/* The account's own picture, straight from the public avatar route — nothing to fetch. */} {u.avatar ? ( ) : ( {(u.email[0] ?? '?').toUpperCase()} )} {u.email.split('@')[0]} {u.blocked ? {t('adm.userBlocked')} : null} {u.removed ? {t('adm.userRemoved')} : null} {u.email} {u.admin ? t('adm.roleAdmin') : t('adm.roleMember')} {u.photos} {new Date(u.createdAt).toLocaleDateString()} {/* An admin account is the API's own privilege source, so it is never a moderation target and offers no buttons here. */} {u.admin ? null : ( <> )}
) ) : null} {tab === 'profile' ? setNote(msg)} /> : null}
) : null}
); }