web: account avatars, member /profile, framed admin panel

- an account can carry a picture: POST /api/auth/avatar (raw bytes,
  sniffed, replaces and unlinks the old file) and the public
  GET /api/users/:id/avatar. It rides wherever the account is named —
  the landing chip, the studio TopBar, the profile form.
- new /profile page for members, sharing one Profile form (picture,
  email, password) with the admin drawer.
- /admin is now one bordered frame whose left column is
  Profile / User account / Pictures / Close. Pictures lists every
  photo in the system with the slot that shows it; User account lists
  each account's name, email, picture and contribution count.
- account control opens a menu: Admin page + Log out for an admin,
  Profile + Log out for a member.
This commit is contained in:
2026-09-18 08:19:36 +07:00
parent 2917c034ed
commit 6bbf77860b
15 changed files with 908 additions and 79 deletions
+162 -50
View File
@@ -1,15 +1,21 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api, type AdminPhoto, type PhotoSlot } from './api';
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: list every contributed photo, upload, delete, and say where
// each one may surface on the landing page. It holds no privilege of its own —
// the API answers 403 unless the signed-in account is on the ADMIN_EMAILS
// allowlist, so this page is only a viewer.
// 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.
@@ -20,10 +26,20 @@ const SLOTS: { id: PhotoSlot; key: MsgKey }[] = [
{ 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 filePick = useRef<HTMLInputElement>(null);
@@ -35,8 +51,9 @@ export function Admin() {
return;
}
try {
const r = await api.adminListPhotos();
setPhotos(r.photos);
const [p, u] = await Promise.all([api.adminListPhotos(), api.adminListUsers()]);
setPhotos(p.photos);
setUsers(u.users);
setState('ready');
} catch {
setState('forbidden');
@@ -61,17 +78,22 @@ export function Admin() {
}
};
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);
const r = await api.adminListPhotos();
setPhotos(r.photos);
await refreshPhotos();
});
const remove = (id: number) =>
run(async () => {
await api.adminDeletePhoto(id);
setPhotos((prev) => prev.filter((p) => p.id !== id));
await refreshPhotos();
});
const setSlot = (id: number, slot: PhotoSlot) =>
@@ -84,11 +106,18 @@ export function Admin() {
if (!window.confirm(t('adm.clearConfirm', { n: photos.length }))) return;
return run(async () => {
const r = await api.adminClearPhotos();
setPhotos([]);
await refreshPhotos();
setNote(t('adm.removed', { n: r.removed }));
});
};
const showOwner = (email: string) => {
setOwner(email);
setTab('pictures');
};
const shown = owner ? photos.filter((p) => p.email === owner) : photos;
return (
<div className="adm">
<header className="adm-bar">
@@ -144,49 +173,132 @@ export function Admin() {
{state === 'forbidden' ? <p className="err adm-note">{t('adm.notAdmin')}</p> : null}
{state === 'ready' ? (
<>
<p className="hint adm-sub">{t('adm.subtitle')}</p>
<p className="hint adm-sub">{t('adm.slotHint')}</p>
</>
) : null}
{state === 'ready' && photos.length === 0 ? <p className="hint adm-note">{t('adm.empty')}</p> : null}
{state === 'ready' && photos.length > 0 ? (
<div className="adm-grid">
{photos.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>
<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>
<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="btn adm-danger"
data-key={`adm-del-${p.id}`}
disabled={busy}
onClick={() => void remove(p.id)}
className={`adm-side-item${tab === entry.id ? ' on' : ''}`}
data-key={`adm-nav-${entry.id}`}
aria-current={tab === entry.id}
onClick={() => setTab(entry.id)}
>
{t('adm.delete')}
{t(entry.key)}
</button>
</article>
))}
))}
<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>
<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-table-wrap">
<table className="adm-table">
<thead>
<tr>
<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>
{/* 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]}</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>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
) : null}
{tab === 'profile' ? <Profile onSaved={(msg) => setNote(msg)} /> : null}
</div>
</div>
) : null}
</div>