Files
RecipesCam/docker/frontend/src/Admin.tsx
T

764 lines
32 KiB
TypeScript

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<State>('loading');
const [photos, setPhotos] = useState<AdminPhoto[]>([]);
const [users, setUsers] = useState<AdminUser[]>([]);
const [tab, setTab] = useState<Tab>('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<number | 'all'>('all');
const [section, setSection] = useState<PhotoSlot>('strip');
// Each column previews its own frame; both start on the newest of their album.
const [selUploads, setSelUploads] = useState<number | null>(null);
const [selSections, setSelSections] = useState<number | null>(null);
const [nameQuery, setNameQuery] = useState('');
const [sort, setSort] = useState<'newest' | 'recipe' | 'rating'>('newest');
const [minStars, setMinStars] = useState(0);
const [ratings, setRatings] = useState<Record<string, Rating>>({});
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<string | null>(null);
const [picked, setPicked] = useState<number[]>([]);
const filePick = useRef<HTMLInputElement>(null);
const restorePick = useRef<HTMLInputElement>(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<void>) => {
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, remove/restore and the PRO grant share one route; the list
// is reloaded because any of the flags also changes what the built-in viewer
// would show.
const setUserState = (id: number, patch: { blocked?: boolean; removed?: boolean; pro?: 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<number, { userId: number; email: string; n: number }>();
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 (
<div className="adm-col" data-key={`adm-col-${key}`}>
<h3 className="adm-row-title">
{title}
<span className="adm-row-count">{list.length}</span>
</h3>
<div className="adm-split">
<div className="adm-album-list" data-key={`adm-albums-${key}`}>
{shelf}
</div>
<section className="adm-stage" data-key={`adm-stage-${key}`}>
{up ? (
<>
<img className="adm-stage-img" data-key={`adm-stage-img-${key}`} src={api.photoUrl(up.id)} alt="" />
<div className="adm-meta">
<b>#{up.id}</b>
{up.tag ? <span>{up.tag}</span> : null}
{up.title ? <span>{up.title}</span> : null}
{up.meta ? <span>{up.meta}</span> : null}
{up.recipeName ? (
<span>
{t('adm.recipe')}: {up.recipeName}
</span>
) : null}
<span>
{t('adm.rating')}: {score(up).toFixed(1)} ({ratings[`photo:${up.id}`]?.n ?? 0})
</span>
<span>
{t('adm.owner')}: {up.email}
</span>
<span>
{t('adm.uploaded')}: {new Date(up.createdAt).toLocaleString()}
</span>
<span>
{t('adm.size')}: {Math.round(up.bytes / 1024)} KB · {up.mime}
</span>
</div>
<div className="adm-choices">
{DESTINATIONS.map((d) => (
<label className="adm-choice" key={d.id}>
<input
type="checkbox"
data-key={`adm-pick-${d.id}-${up.id}`}
checked={up.slots.includes(d.id)}
// A photo with no stored look has no file to hand out, so
// the QR box would only ever draw an empty card.
disabled={busy || (d.id === 'qr' && !up.hasPreset)}
title={d.id === 'qr' && !up.hasPreset ? t('adm.noPreset') : undefined}
onChange={() =>
void setSlots(
up.id,
up.slots.includes(d.id) ? up.slots.filter((x) => x !== d.id) : [...up.slots, d.id],
)
}
/>
{t(d.key)}
</label>
))}
</div>
{/* 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. */}
<div className="adm-qr" data-key={`adm-qr-${up.id}`}>
{up.hasPreset ? (
<QrCode url={new URL(api.presetUrl(up.id), window.location.origin).href} size={104} />
) : (
<span>{t('adm.noPreset')}</span>
)}
</div>
<button
type="button"
className="btn adm-danger"
data-key={`adm-del-${up.id}`}
disabled={busy}
onClick={() => void remove(up.id)}
>
{t('adm.delete')}
</button>
</>
) : (
<p className="hint adm-row-empty">{t('adm.bigEmpty')}</p>
)}
</section>
</div>
{/* The strip: one thumbnail per frame the album holds, in the order the
filters and the sort chose. */}
<div className="adm-filmstrip" data-key={`adm-strip-${key}`}>
{list.map((p) => (
<button
key={p.id}
type="button"
className={`adm-thumb${up?.id === p.id ? ' on' : ''}`}
data-key={`adm-thumb-${key}-${p.id}`}
onClick={() => setSel(p.id)}
>
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
<span className="adm-thumb-cap">#{p.id}</span>
</button>
))}
{list.length === 0 ? <p className="hint adm-row-empty">{t('adm.rowEmpty')}</p> : null}
</div>
</div>
);
};
return (
<div className="adm">
<header className="adm-bar">
<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' ? (
<>
<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>
{note ? <p className="hint adm-note">{note}</p> : null}
{state === 'loading' ? <p className="hint adm-note">{t('auth.busy')}</p> : null}
{state === 'guest' ? (
<p className="adm-note">
<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' ? (
<div className="adm-frame" data-key="adm-frame">
<nav className="adm-side">
<span className="adm-side-title">{t('adm.settings')}</span>
{MENU.map((entry) => (
<button
key={entry.id}
type="button"
className={`adm-side-item${tab === entry.id ? ' on' : ''}`}
data-key={`adm-nav-${entry.id}`}
aria-current={tab === entry.id}
onClick={() => setTab(entry.id)}
>
{t(entry.key)}
</button>
))}
<a className="adm-side-item" data-key="adm-close" href="/">{t('adm.close')}</a>
</nav>
<div className="adm-pane">
{tab === 'pictures' ? (
<>
<p className="hint adm-sub">{t('adm.subtitle')}</p>
<p className="hint adm-sub">{t('adm.slotHint')}</p>
{photos.length === 0 ? <p className="hint adm-note">{t('adm.empty')}</p> : null}
<div className="adm-cols">
{/* 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,
<>
<input
type="search"
className="adm-input"
data-key="adm-filter-name"
placeholder={t('adm.filterName')}
value={nameQuery}
onChange={(e) => setNameQuery(e.target.value)}
/>
<div className="adm-filters">
<select
className="adm-input"
data-key="adm-sort"
aria-label={t('adm.sort')}
value={sort}
onChange={(e) => setSort(e.target.value as 'newest' | 'recipe' | 'rating')}
>
<option value="newest">{t('adm.sortNewest')}</option>
<option value="recipe">{t('adm.sortRecipe')}</option>
<option value="rating">{t('adm.sortRating')}</option>
</select>
<select
className="adm-input"
data-key="adm-filter-rating"
aria-label={t('adm.rating')}
value={String(minStars)}
onChange={(e) => setMinStars(Number(e.target.value))}
>
<option value="0">{t('adm.ratingAll')}</option>
{[5, 4, 3, 2, 1].map((n) => (
<option key={n} value={String(n)}>
{t('adm.ratingAtLeast', { n })}
</option>
))}
</select>
</div>
<button
type="button"
className={`adm-album${album === 'all' ? ' on' : ''}`}
data-key="adm-album-all"
onClick={() => setAlbum('all')}
>
<span className="adm-album-name">{t('adm.albumAll')}</span>
<span className="adm-album-count">{photos.length}</span>
</button>
{shownAlbums.map((a) => (
<button
key={a.userId}
type="button"
className={`adm-album${album === a.userId ? ' on' : ''}`}
data-key={`adm-album-${a.userId}`}
onClick={() => setAlbum(a.userId)}
>
<span className="adm-album-name">{a.email}</span>
<span className="adm-album-count">{a.n}</span>
</button>
))}
</>,
)}
{column(
t('adm.sections'),
'sections',
sectionPhotos,
selSections,
setSelSections,
<>
{SECTIONS.map((d) => (
<button
key={d.id}
type="button"
className={`adm-album${section === d.id ? ' on' : ''}`}
data-key={`adm-section-${d.id}`}
onClick={() => setSection(d.id)}
>
<span className="adm-album-name">{t(d.key)}</span>
<span className="adm-album-count">
{photos.filter((p) => p.slots.includes(d.id)).length}
</span>
</button>
))}
</>,
)}
</div>
</>
) : null}
{tab === 'users' ? (
users.length === 0 ? <p className="hint adm-note">{t('adm.noUsers')}</p> : (
<>
<div className="adm-bulk">
<button
type="button"
className="btn"
data-key="adm-users-all"
disabled={busy || selectable.length === 0}
onClick={() => setPicked(selectable)}
>
{t('adm.selectAll')}
</button>
<button
type="button"
className="btn"
data-key="adm-users-none"
disabled={busy || picked.length === 0}
onClick={() => setPicked([])}
>
{t('adm.selectNone')}
</button>
<button
type="button"
className="btn adm-danger"
data-key="adm-users-delete"
disabled={busy || picked.length === 0}
onClick={() => void deleteSelected()}
>
{t('adm.deleteSelected', { n: picked.length })}
</button>
</div>
<div className="adm-table-wrap">
<table className="adm-table">
<thead>
<tr>
<th className="adm-check">
<input
type="checkbox"
data-key="adm-user-select-all"
aria-label={t('adm.selectAll')}
checked={allPicked}
disabled={busy || selectable.length === 0}
onChange={() => setPicked(allPicked ? [] : selectable)}
/>
</th>
<th>{t('adm.userPicture')}</th>
<th>{t('adm.userName')}</th>
<th>{t('adm.userEmail')}</th>
<th>{t('adm.userRole')}</th>
<th className="adm-pro">{t('adm.userPro')}</th>
<th>{t('adm.userPhotos')}</th>
<th>{t('adm.userJoined')}</th>
<th />
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} data-key={`adm-user-${u.id}`}>
<td className="adm-check">
{u.admin ? null : (
<input
type="checkbox"
data-key={`adm-user-check-${u.id}`}
aria-label={u.email}
checked={picked.includes(u.id)}
disabled={busy}
onChange={() => togglePicked(u.id)}
/>
)}
</td>
<td>
{/* The account's own picture, straight from the
public avatar route — nothing to fetch. */}
{u.avatar ? (
<img className="adm-face" src={u.avatar} alt="" loading="lazy" />
) : (
<span className="adm-face adm-face-empty">{(u.email[0] ?? '?').toUpperCase()}</span>
)}
</td>
<td>
{u.email.split('@')[0]}
{u.blocked ? <span className="adm-badge adm-badge-warn">{t('adm.userBlocked')}</span> : null}
{u.removed ? <span className="adm-badge adm-badge-warn">{t('adm.userRemoved')}</span> : null}
</td>
<td>{u.email}</td>
<td>
<span className={`adm-badge${u.admin ? ' on' : ''}`}>
{u.admin ? t('adm.roleAdmin') : t('adm.roleMember')}
</span>
</td>
<td className="adm-pro">
{/* The PRO grant. Ticking it hands this account the
studio's PRO tier on its next request; the API
reads the row, not the session, so nothing has
to be signed in again. An admin account is PRO
by allowlist, so its box only reports that. */}
<input
type="checkbox"
data-key={`adm-user-pro-${u.id}`}
aria-label={t('adm.userPro')}
checked={u.pro}
disabled={busy || u.admin}
onChange={() => void setUserState(u.id, { pro: !u.pro })}
/>
</td>
<td>{u.photos}</td>
<td>{new Date(u.createdAt).toLocaleDateString()}</td>
<td>
<button
type="button"
className="btn"
data-key={`adm-user-photos-${u.id}`}
onClick={() => showOwner(u.id)}
>
{t('adm.viewPhotos')}
</button>
{/* 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 : (
<>
<button
type="button"
className="btn"
data-key={`adm-user-block-${u.id}`}
disabled={busy}
onClick={() => void setUserState(u.id, { blocked: !u.blocked })}
>
{u.blocked ? t('adm.userUnblock') : t('adm.userBlock')}
</button>
<button
type="button"
className="btn"
data-key={`adm-user-remove-${u.id}`}
disabled={busy}
onClick={() => void setUserState(u.id, { removed: !u.removed })}
>
{u.removed ? t('adm.userRestore') : t('adm.userRemove')}
</button>
<button
type="button"
className="btn adm-danger"
data-key={`adm-user-delete-${u.id}`}
disabled={busy}
onClick={() => void deleteUser(u)}
>
{t('adm.userDelete')}
</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)
) : null}
{tab === 'profile' ? <Profile onSaved={(msg) => setNote(msg)} /> : null}
{tab === 'stats' ? <Stats /> : null}
{tab === 'backup' ? (
<>
<p className="hint adm-sub">{t('adm.backupHint')}</p>
<div className="adm-bulk">
{/* 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. */}
<a className="btn primary" data-key="adm-backup-save" href={api.adminBackupUrl()}>
{t('adm.backupSave')}
</a>
<button
type="button"
className="btn adm-danger"
data-key="adm-restore-pick"
disabled={busy}
onClick={() => restorePick.current?.click()}
>
{busy ? t('auth.busy') : t('adm.restorePick')}
</button>
<input
ref={restorePick}
type="file"
hidden
accept=".gz,application/gzip"
data-key="adm-restore-input"
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) void restore(file);
}}
/>
</div>
<p className="hint adm-sub">{t('adm.restoreHint')}</p>
</>
) : null}
</div>
</div>
) : null}
</div>
);
}