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:
+162
-50
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user