86a93f46be
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.
438 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|