import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { api, type AdminPhoto, type AdminUser, type PhotoSlot, type Rating } from './api'; import { useI18n } from './i18n/I18nProvider'; import { Profile } from './ui/Profile'; import { QrCode } from './ui/QrCode'; import { Stats } from './ui/Stats'; 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 — two columns of albums: the uploads on the left, one album // per account; the landing on the right, one album per // section. Every frame carries its own labels, section boxes, // QR code and delete button under its preview // Stats — the visitor counter: views, clicks and their breakdowns // Backup — the data dir out as one .tar.gz, and the route that puts a // .tar.gz back (the API replaces its data and restarts) // 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' | 'stats' | 'backup'; // Where a photo can be put. The boxes are independent — a photo may sit in all // three sections at once, and each section draws one random photo per visit out // of its own set. Unticking every box takes the photo off the landing without // deleting the uploader's row, which is the same switch the old picker carried. const DESTINATIONS: { id: PhotoSlot; key: MsgKey }[] = [ { id: 'strip', key: 'adm.pickStrip' }, { id: 'tester', key: 'adm.pickTester' }, { id: 'creator', key: 'adm.pickCreator' }, { id: 'qr', key: 'adm.pickQr' }, ]; // Column 2 browses these as albums. QR is not one of them: its code belongs to // the frame, so it is drawn under every preview instead of in a shelf of its own. const SECTIONS = DESTINATIONS.filter((d) => d.id !== 'qr'); // 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' }, { id: 'stats', key: 'adm.tabStats' }, { id: 'backup', key: 'adm.tabBackup' }, ]; export function Admin() { const { t } = useI18n(); const [state, setState] = useState('loading'); const [photos, setPhotos] = useState([]); const [users, setUsers] = useState([]); const [tab, setTab] = useState('pictures'); // The two album browsers: which account's uploads are open on the left, which // landing section is open on the right, and the filters both columns obey. const [album, setAlbum] = useState('all'); const [section, setSection] = useState('strip'); // Each column previews its own frame; both start on the newest of their album. const [selUploads, setSelUploads] = useState(null); const [selSections, setSelSections] = useState(null); const [nameQuery, setNameQuery] = useState(''); const [sort, setSort] = useState<'newest' | 'recipe' | 'rating'>('newest'); const [minStars, setMinStars] = useState(0); const [ratings, setRatings] = useState>({}); const [busy, setBusy] = useState(false); const [note, setNote] = useState(null); const [picked, setPicked] = useState([]); const filePick = useRef(null); const restorePick = useRef(null); const load = useCallback(async () => { const me = await api.me().catch(() => null); if (!me?.user) { setState('guest'); return; } try { const [p, u, r] = await Promise.all([api.adminListPhotos(), api.adminListUsers(), api.ratings()]); setPhotos(p.photos); setUsers(u.users); setRatings(r.ratings); 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, r] = await Promise.all([api.adminListPhotos(), api.adminListUsers(), api.ratings()]); setPhotos(p.photos); setUsers(u.users); setRatings(r.ratings); }; const upload = (file: File) => run(async () => { await api.uploadPhoto(file); await refreshPhotos(); }); const remove = (id: number) => run(async () => { await api.adminDeletePhoto(id); await refreshPhotos(); }); // One checkbox tick: the whole new set goes to the API in one call. The box // flips under the curator's finger first — waiting for the round trip would // snap it back for a frame and read as a lost click — and a failed write puts // the old set back. const setSlots = (id: number, next: PhotoSlot[]) => run(async () => { const before = photos.find((p) => p.id === id)?.slots ?? []; const show = (slots: PhotoSlot[]) => setPhotos((prev) => prev.map((p) => (p.id === id ? { ...p, slots } : p))); show(next); try { await api.adminSetPhotoSlots(id, next); } catch (err) { show(before); throw err; } }); 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 })); }); }; // The one action here that replaces everything. The API swaps its data on // disk and exits; the container's restart policy brings it back. So the page // waits for the API to answer again instead of showing a listing that is no // longer what the server holds — and the wait is bounded, because a restore // that never comes back is a thing the operator needs told. const restore = (file: File) => { if (!window.confirm(t('adm.restoreConfirm', { file: file.name }))) return; return run(async () => { await api.adminRestore(file); setNote(t('adm.restoreRunning')); for (let i = 0; i < 90; i += 1) { await new Promise((resolve) => setTimeout(resolve, 2000)); try { await api.me(); await refreshPhotos(); setNote(t('adm.restoreDone')); return; } catch { // Still down for its restart; keep waiting. } } setNote(t('adm.restoreSlow')); }); }; // From the users table: open that account's album in the left column. const showOwner = (userId: number) => { setTab('pictures'); setAlbum(userId); }; // 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(); }); }; // A frame's score, from the same public tally the landing draws. Whitespace // in the name box is not a filter, so `query` is trimmed once for both uses. const score = (p: AdminPhoto) => ratings[`photo:${p.id}`]?.avg ?? 0; const query = nameQuery.trim().toLowerCase(); // Albums: one per account that owns a photo, alphabetical so the column reads // as a list of names. The name box narrows the column, not the strip. const albums = useMemo(() => { const map = new Map(); for (const p of photos) { const a = map.get(p.userId) ?? { userId: p.userId, email: p.email, n: 0 }; a.n += 1; map.set(p.userId, a); } return [...map.values()].sort((a, b) => a.email.localeCompare(b.email)); }, [photos]); const shownAlbums = query ? albums.filter((a) => a.email.toLowerCase().includes(query)) : albums; // What an album draws: the frames the name box kept, minus anything under the // rating bar, in the chosen order. Both columns run it, so one frame sits // under the same rules on either side. const arrange = useCallback( (base: AdminPhoto[]) => { const named = base.filter((p) => !query || p.email.toLowerCase().includes(query)); const sorted = [...(minStars > 0 ? named.filter((p) => score(p) >= minStars) : named)]; if (sort === 'recipe') sorted.sort((a, b) => (a.recipeName ?? '').localeCompare(b.recipeName ?? '')); else if (sort === 'rating') sorted.sort((a, b) => score(b) - score(a)); else sorted.sort((a, b) => b.id - a.id); return sorted; // `score` reads `ratings`, which is in the deps. }, [query, minStars, sort, ratings], ); const albumPhotos = useMemo( () => arrange(album === 'all' ? photos : photos.filter((p) => p.userId === album)), [arrange, album, photos], ); const sectionPhotos = useMemo( () => arrange(photos.filter((p) => p.slots.includes(section))), [arrange, photos, section], ); // A column: the album shelf down the left, the frame that is up in the middle // — its picture, then everything a curator can do to it (labels, the four // section boxes, the look's own QR code, the delete button) under the picture // — and the open album's thumbnail strip across the bottom. const column = ( title: string, key: string, list: AdminPhoto[], sel: number | null, setSel: (id: number) => void, shelf: ReactNode, ) => { const up = list.find((p) => p.id === sel) ?? list[0] ?? null; return (

{title} {list.length}

{shelf}
{up ? ( <>
#{up.id} {up.tag ? {up.tag} : null} {up.title ? {up.title} : null} {up.meta ? {up.meta} : null} {up.recipeName ? ( {t('adm.recipe')}: {up.recipeName} ) : null} {t('adm.rating')}: {score(up).toFixed(1)} ({ratings[`photo:${up.id}`]?.n ?? 0}) {t('adm.owner')}: {up.email} {t('adm.uploaded')}: {new Date(up.createdAt).toLocaleString()} {t('adm.size')}: {Math.round(up.bytes / 1024)} KB · {up.mime}
{DESTINATIONS.map((d) => ( ))}
{/* The QR section's real artefact is the code, not the photo, so it is drawn here under the frame — on white, because a scanner needs the quiet zone and the dark theme has none. */}
{up.hasPreset ? ( ) : ( {t('adm.noPreset')} )}
) : (

{t('adm.bigEmpty')}

)}
{/* The strip: one thumbnail per frame the album holds, in the order the filters and the sort chose. */}
{list.map((p) => ( ))} {list.length === 0 ?

{t('adm.rowEmpty')}

: null}
); }; 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')}

{photos.length === 0 ?

{t('adm.empty')}

: null}
{/* Two columns of the same shape: the uploads on the left, one album per account; the landing on the right, one album per section. Each column is an album shelf, the frame that is up with its own controls, and a thumbnail strip. */} {column( t('adm.uploads'), 'uploads', albumPhotos, selUploads, setSelUploads, <> setNameQuery(e.target.value)} />
{shownAlbums.map((a) => ( ))} , )} {column( t('adm.sections'), 'sections', sectionPhotos, selSections, setSelSections, <> {SECTIONS.map((d) => ( ))} , )}
) : 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} {tab === 'stats' ? : null} {tab === 'backup' ? ( <>

{t('adm.backupHint')}

{/* A plain link, so the browser does the download and the session cookie authorises it — nothing here buffers a multi-hundred-megabyte archive in memory. */} {t('adm.backupSave')} { const file = e.target.files?.[0]; e.target.value = ''; if (file) void restore(file); }} />

{t('adm.restoreHint')}

) : null}
) : null}
); }