Files
RecipesCam/docker/frontend/src/Admin.tsx
T
3dtours 86a93f46be web: bulk-select accounts in the admin table and link the studio from the top bar
The users table grows a checkbox column with a select-all box in its head,
and three controls above it: SELECT ALL, SELECT NONE and DELETE SELECTED
naming the count. An admin account is the API's own privilege source, so it
gets no box and select-all skips it; the picks clear once the deletes land.

The landing top bar gains a RecipesCam web studio button between the account
slot and Download RecipesCam. It hides with the rest of the wide row under
1160px, where the burger sheet already offers the studio.
2026-09-18 11:11:43 +07:00

438 lines
18 KiB
TypeScript

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<State>('loading');
const [photos, setPhotos] = useState<AdminPhoto[]>([]);
const [users, setUsers] = useState<AdminUser[]>([]);
const [tab, setTab] = useState<Tab>('pictures');
const [owner, setOwner] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<string | null>(null);
const [picked, setPicked] = useState<number[]>([]);
const filePick = useRef<HTMLInputElement>(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<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] = 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 (
<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>
{owner ? (
<p className="hint adm-sub adm-filter">
{owner}
<button type="button" className="btn" data-key="adm-filter-all" onClick={() => setOwner(null)}>
{t('adm.showAll')}
</button>
</p>
) : null}
{shown.length === 0 ? <p className="hint adm-note">{t('adm.empty')}</p> : null}
<div className="adm-grid">
{shown.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>
{/* The labels the uploader's studio stored, when it
sent any — the same ones the reel draws. */}
{p.tag ? <span>{p.tag}</span> : null}
{p.title ? <span>{p.title}</span> : null}
{p.meta ? <span>{p.meta}</span> : null}
<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}
onChange={(e) => void setSlot(p.id, e.target.value as PhotoSlot)}
>
{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}
{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>{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>{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.email)}
>
{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}
</div>
</div>
) : null}
</div>
);
}