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
+66 -4
View File
@@ -20,6 +20,11 @@ const UPLOAD_DIR = join(DATA_DIR, 'uploads');
mkdirSync(UPLOAD_DIR, { recursive: true });
export const photoPath = (file: string) => join(UPLOAD_DIR, file);
// Profile pictures, one per account, named the same way.
const AVATAR_DIR = join(DATA_DIR, 'avatars');
mkdirSync(AVATAR_DIR, { recursive: true });
export const avatarPath = (file: string) => join(AVATAR_DIR, file);
export const db = new Database(join(DATA_DIR, 'recipescam.db'));
db.pragma('journal_mode = WAL');
@@ -73,7 +78,16 @@ export const isPhotoSlot = (v: unknown): v is PhotoSlot =>
}
}
export type User = { id: number; email: string };
// The avatar column arrived after the first accounts did, same as photos.slot.
{
const cols = db.prepare('PRAGMA table_info(users)').all() as { name: string }[];
if (!cols.some((c) => c.name === 'avatar')) {
db.exec(`ALTER TABLE users ADD COLUMN avatar TEXT`);
}
}
// `avatar` is the stored file name, or null for "no picture".
export type User = { id: number; email: string; avatar: string | null };
export type Recipe = {
id: number;
name: string;
@@ -109,7 +123,7 @@ export function createUser(email: string, password: string): User | null {
const info = db
.prepare('INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?)')
.run(email, hashPassword(password), now());
return { id: Number(info.lastInsertRowid), email };
return { id: Number(info.lastInsertRowid), email, avatar: null };
} catch (err) {
if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return null;
throw err;
@@ -118,12 +132,28 @@ export function createUser(email: string, password: string): User | null {
export function findUserByEmail(email: string): (User & { password_hash: string }) | undefined {
return db
.prepare('SELECT id, email, password_hash FROM users WHERE email = ?')
.prepare('SELECT id, email, avatar, password_hash FROM users WHERE email = ?')
.get(email) as (User & { password_hash: string }) | undefined;
}
export function findUserById(id: number): User | undefined {
return db.prepare('SELECT id, email FROM users WHERE id = ?').get(id) as User | undefined;
return db.prepare('SELECT id, email, avatar FROM users WHERE id = ?').get(id) as User | undefined;
}
// Swaps the picture and hands back the file it replaced, so the caller can
// unlink it — the row is the only index of what is on disk.
export function setUserAvatar(id: number, file: string): string | null {
const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id) as { avatar: string | null } | undefined;
if (!row) return null;
db.prepare('UPDATE users SET avatar = ? WHERE id = ?').run(file, id);
return row.avatar;
}
// Avatars are public by nature — they sit next to a name — so this is not
// session-gated. It returns only the row's own file name, never a client path.
export function userAvatar(id: number): string | undefined {
const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id) as { avatar: string | null } | undefined;
return row?.avatar ?? undefined;
}
export function createSession(userId: number): string {
@@ -217,6 +247,38 @@ export function listPhotosWithOwner(): AdminPhoto[] {
.all() as AdminPhoto[];
}
// Admin listing: one row per account with how many photos it owns.
export type AdminUser = { id: number; email: string; createdAt: string; photos: number; avatar: string | null };
export function listUsersWithCounts(): AdminUser[] {
return db
.prepare(
`SELECT users.id AS id, users.email AS email, users.created_at AS createdAt,
users.avatar AS avatar, COUNT(photos.id) AS photos
FROM users LEFT JOIN photos ON photos.user_id = users.id
GROUP BY users.id
ORDER BY users.id`,
)
.all() as AdminUser[];
}
// Profile edits. The email column is UNIQUE, so a taken address comes back as
// false rather than a thrown constraint; the password uses the same hash the
// sign-up path writes.
export function updateUserEmail(id: number, email: string): boolean {
try {
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, id);
return true;
} catch (err) {
if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return false;
throw err;
}
}
export function setUserPassword(id: number, password: string): void {
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(password), id);
}
export function countPhotos(userId: number): number {
return (db.prepare('SELECT COUNT(*) AS n FROM photos WHERE user_id = ?').get(userId) as { n: number }).n;
}
+113 -2
View File
@@ -23,11 +23,17 @@ import {
listPhotos,
listPhotosWithOwner,
listRecipes,
listUsersWithCounts,
avatarPath,
photoFile,
photoPath,
sessionUser,
setPhotoSlot,
setUserAvatar,
setUserPassword,
updateRecipe,
updateUserEmail,
userAvatar,
verifyPassword,
type Recipe,
type User,
@@ -56,7 +62,15 @@ const isAdmin = (user: User) => ADMIN_EMAILS.has(user.email.toLowerCase());
// The public shape of an account. `admin` is the allowlist's answer, so the
// client can decide whether to offer /admin without a second round trip — and
// the server still enforces it on every admin route below.
const publicUser = (user: User) => ({ id: user.id, email: user.email, admin: isAdmin(user) });
// `avatar` is a URL the client can drop straight into an <img>, or null when
// the account never picked a picture. The `v` is the stored file's own name, so
// the URL changes with the picture and can be cached hard.
const publicUser = (user: User) => ({
id: user.id,
email: user.email,
admin: isAdmin(user),
avatar: user.avatar ? `/api/users/${user.id}/avatar?v=${user.avatar.split('.')[0]}` : null,
});
const app = Fastify({
logger: true,
@@ -125,6 +139,7 @@ function sniffImage(buf: Buffer): ImageMime | null {
return null;
}
const EXT: Record<ImageMime, string> = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' };
const AVATAR_MIME: Record<string, ImageMime> = { jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp' };
// Single error shape for the whole API: { error: "..." }
app.setErrorHandler((err, req, reply) => {
@@ -219,7 +234,7 @@ app.post('/api/auth/login', async (req, reply) => {
const ok = verifyPassword(b.password, row?.password_hash ?? DUMMY_HASH);
if (!row || !ok) return reply.status(401).send({ error: 'invalid credentials' });
setSession(req, reply, createSession(row.id));
return reply.status(200).send({ user: publicUser({ id: row.id, email: row.email }) });
return reply.status(200).send({ user: publicUser({ id: row.id, email: row.email, avatar: row.avatar }) });
});
app.post('/api/auth/logout', async (req, reply) => {
@@ -237,6 +252,37 @@ app.get('/api/auth/me', async (req, reply) => {
return reply.status(200).send({ user: user ? publicUser(user) : null });
});
// Profile: the signed-in account edits its own email or password. The current
// password is required either way, so a stolen cookie alone cannot lock the
// owner out — and the login limiter caps guesses at it.
app.patch('/api/auth/me', async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
if (!allowLogin(user.email)) return tooMany(reply);
const b = bodyOf(req);
if (!b) return reply.status(400).send({ error: 'invalid body' });
const row = findUserByEmail(user.email);
const current = typeof b.currentPassword === 'string' ? b.currentPassword : '';
if (!row || !verifyPassword(current, row.password_hash))
return reply.status(403).send({ error: 'invalid password' });
let email = user.email;
if (b.email !== undefined) {
const next = typeof b.email === 'string' ? b.email.trim().toLowerCase() : '';
if (!next || next.length > MAX_EMAIL || !EMAIL_RE.test(next)) return reply.status(400).send({ error: 'invalid email' });
if (next !== user.email && !updateUserEmail(user.id, next))
return reply.status(409).send({ error: 'email already registered' });
email = next;
}
if (b.password !== undefined) {
const password = typeof b.password === 'string' ? b.password : '';
if (password.length < MIN_PASSWORD || password.length > MAX_PASSWORD)
return reply.status(400).send({ error: `password must be ${MIN_PASSWORD}-${MAX_PASSWORD} characters` });
setUserPassword(user.id, password);
}
return reply.status(200).send({ user: publicUser({ id: user.id, email, avatar: user.avatar }) });
});
app.get('/api/recipes', async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
@@ -306,6 +352,49 @@ app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply
return reply.status(201).send({ photo });
});
// A profile picture is the same deal as a photo: raw bytes, sniffed, written
// under a server-generated name. The picture it replaces goes with it.
app.post('/api/auth/avatar', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
if (!allowUpload(String(user.id))) return tooMany(reply);
const body = req.body;
if (!Buffer.isBuffer(body) || body.length === 0) return reply.status(400).send({ error: 'invalid body' });
if (body.length > MAX_PHOTO_BYTES) return reply.status(413).send({ error: 'photo too large' });
const declared = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
const mime = sniffImage(body);
if (!mime || mime !== declared) return reply.status(415).send({ error: 'unsupported image type' });
const file = `${randomBytes(16).toString('hex')}.${EXT[mime]}`;
writeFileSync(avatarPath(file), body, { flag: 'wx' });
const previous = setUserAvatar(user.id, file);
if (previous) unlinkAvatar(previous);
return reply.status(200).send({ user: publicUser({ ...user, avatar: file }) });
});
// Public on purpose: an avatar sits next to a name on the landing page, so
// there is nothing here a session would protect.
app.get<{ Params: { id: string } }>('/api/users/:id/avatar', async (req, reply) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' });
const file = userAvatar(id);
const type = file ? AVATAR_MIME[file.split('.').pop() ?? ''] : undefined;
if (!file || !type) return reply.status(404).send({ error: 'not_found' });
let data: Buffer;
try {
data = readFileSync(avatarPath(file));
} catch {
return reply.status(404).send({ error: 'not_found' });
}
// The URL carries the file's own name as a version, so it can never go stale.
return reply
.header('content-type', type)
.header('cache-control', 'public, max-age=31536000, immutable')
.send(data);
});
app.get<{ Params: { id: string } }>('/api/photos/:id/file', async (req, reply) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' });
@@ -349,6 +438,28 @@ function unlink(file: string): void {
}
}
function unlinkAvatar(file: string): void {
try {
unlinkSync(avatarPath(file));
} catch {
// Already gone; the row is what matters.
}
}
// Accounts and how much each one has contributed — the "who is this" half of
// moderation. `admin` is the allowlist's answer, not a stored column.
app.get('/api/admin/users', async (req, reply) => {
const user = admin(req);
if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });
return reply.status(200).send({
users: listUsersWithCounts().map((u) => ({
...u,
avatar: u.avatar ? `/api/users/${u.id}/avatar?v=${u.avatar.split('.')[0]}` : null,
admin: ADMIN_EMAILS.has(u.email),
})),
});
});
app.get('/api/admin/photos', async (req, reply) => {
const user = admin(req);
if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });
+76
View File
@@ -71,6 +71,9 @@ function actor() {
upload(bytes, type) {
return this.req('/photos', { method: 'POST', headers: { 'content-type': type }, body: bytes });
},
avatar(bytes, type) {
return this.req('/auth/avatar', { method: 'POST', headers: { 'content-type': type }, body: bytes });
},
};
}
@@ -269,6 +272,79 @@ try {
check('an admin clears the strip in one call', cleared.status === 200 && cleared.body?.removed > 0, JSON.stringify(cleared.body));
check('the strip is empty afterwards', ((await actor().req('/photos')).body?.photos ?? []).length === 0);
// ---- profile: an account edits itself ----------------------------------
const JSON_HDR = { 'content-type': 'application/json' };
const edit = (a, body) => a.req('/auth/me', { method: 'PATCH', headers: JSON_HDR, body: JSON.stringify(body) });
const member = actor();
await member.signup(`profile${stamp}@test.local`);
const anonEdit = await edit(actor(), { password: 'another-secret-1', currentPassword: 'supersecret1' });
check('a profile edit needs a session', anonEdit.status === 401, `got ${anonEdit.status}`);
const badCurrent = await edit(member, { password: 'another-secret-1', currentPassword: 'not-the-password' });
check('a profile edit needs the current password', badCurrent.status === 403, `got ${badCurrent.status}`);
const takenEmail = await edit(member, { email: ADMIN_EMAIL, currentPassword: 'supersecret1' });
check('a profile edit refuses a taken email', takenEmail.status === 409, `got ${takenEmail.status}`);
const shortNew = await edit(member, { password: 'short', currentPassword: 'supersecret1' });
check('a profile edit refuses a short password', shortNew.status === 400, `got ${shortNew.status}`);
const newEmail = `renamed${stamp}@test.local`;
const renamed = await edit(member, { email: newEmail, currentPassword: 'supersecret1' });
check('an admin-visible profile edit changes the email', renamed.status === 200 && renamed.body?.user?.email === newEmail, JSON.stringify(renamed.body));
const login = (email, password) =>
actor().req('/auth/login', { method: 'POST', headers: JSON_HDR, body: JSON.stringify({ email, password }) });
check('the account logs in under the new email', (await login(newEmail, 'supersecret1')).status === 200);
check('the old email no longer logs in', (await login(`profile${stamp}@test.local`, 'supersecret1')).status === 401);
// The session that made the edit is the same row, so it also changes the password.
const newPassword = 'second-secret-1';
const rekeyed = await edit(member, { password: newPassword, currentPassword: 'supersecret1' });
check('an account changes its own password', rekeyed.status === 200, `got ${rekeyed.status}`);
check('the old password stops working', (await login(newEmail, 'supersecret1')).status === 401);
check('the new password works', (await login(newEmail, newPassword)).status === 200);
// ---- admin: the account list -------------------------------------------
const anonUsers = await actor().req('/admin/users');
check('the user list is not public', anonUsers.status === 401, `got ${anonUsers.status}`);
const memberUsers = await member.req('/admin/users');
check('a member cannot read the user list', memberUsers.status === 403, `got ${memberUsers.status}`);
const adminUsers = await admin.req('/admin/users');
const adminRow = (adminUsers.body?.users ?? []).find((u) => u.email === ADMIN_EMAIL);
check('an admin reads the user list', adminUsers.status === 200 && Array.isArray(adminUsers.body?.users), `got ${adminUsers.status}`);
check('the list flags the allowlisted account', adminRow?.admin === true, JSON.stringify(adminRow));
check('the list counts each account’s photos', typeof adminRow?.photos === 'number', JSON.stringify(adminRow));
// ---- avatar: the picture beside the name -------------------------------
const memberId = renamed.body?.user?.id;
const noSession = await actor().avatar(PNG, 'image/png');
check('an avatar upload needs a session', noSession.status === 401, `got ${noSession.status}`);
const badAvatar = await member.avatar(Buffer.from('<svg onload="alert(1)"/>'), 'image/png');
check('an avatar upload sniffs the bytes', badAvatar.status === 415, `got ${badAvatar.status}`);
const gaveAvatar = await member.avatar(PNG, 'image/png');
const avatarUrl = gaveAvatar.body?.user?.avatar;
check('a member uploads an avatar', gaveAvatar.status === 200 && typeof avatarUrl === 'string', JSON.stringify(gaveAvatar.body));
check('the avatar URL points at the account', new RegExp(`^/api/users/${memberId}/avatar\\?v=[0-9a-f]{32}$`).test(String(avatarUrl)), String(avatarUrl));
const servedAvatar = await fetch(`http://127.0.0.1:${PORT}${avatarUrl}`);
check('an avatar is served without a session', servedAvatar.status === 200, `got ${servedAvatar.status}`);
check('an avatar carries its image type', servedAvatar.headers.get('content-type') === 'image/png', String(servedAvatar.headers.get('content-type')));
check('an avatar is cacheable for a long time', (servedAvatar.headers.get('cache-control') ?? '').includes('immutable'), String(servedAvatar.headers.get('cache-control')));
check('the avatar bytes round-trip intact', Buffer.from(await servedAvatar.arrayBuffer()).equals(PNG));
const replaced = await member.avatar(JPEG_HEAD, 'image/jpeg');
const replacedUrl = replaced.body?.user?.avatar;
check('a second avatar replaces the first', replaced.status === 200 && replacedUrl !== avatarUrl, JSON.stringify(replaced.body));
check('the replaced avatar file is gone', !existsSync(join(DATA_DIR, 'avatars', `${String(avatarUrl).split('?v=')[1]}.png`)));
check('the new avatar resolves', (await fetch(`http://127.0.0.1:${PORT}${replacedUrl}`)).status === 200);
const ghost = await actor().req('/users/999999/avatar');
check('an unknown account has no avatar', ghost.status === 404, `got ${ghost.status}`);
// The moderation list has to show the same face beside the email.
const listedUsers = (await admin.req('/admin/users')).body?.users ?? [];
const memberRow = listedUsers.find((u) => u.id === memberId);
check('the user list carries each account’s picture', memberRow?.avatar === replacedUrl, JSON.stringify(memberRow));
// ---- pre-existing guarantees still hold ---------------------------------
const foreignRecipe = await user.req('/recipes/1', { method: 'DELETE' });
check("another account's recipe is not deletable", foreignRecipe.status === 404, `got ${foreignRecipe.status}`);
+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>
+47 -9
View File
@@ -3,6 +3,7 @@ import { api, type Photo, type PhotoSlot, type User } from './api';
import { useI18n } from './i18n/I18nProvider';
import { FONT_SETS, useTheme } from './theme/ThemeProvider';
import { AuthModal } from './ui/AuthModal';
import { Avatar } from './ui/Avatar';
// Landing page, built to the landing blueprint: the 10-section funnel. The
// marketing prose is bilingual and lives here next to the markup as `Txt`
@@ -104,7 +105,9 @@ export function Landing() {
const { mode, fonts, setMode, setFonts } = useTheme();
const [menu, setMenu] = useState(false);
const [themeMenu, setThemeMenu] = useState(false);
const [accountMenu, setAccountMenu] = useState(false);
const tools = useRef<HTMLSpanElement>(null);
const account = useRef<HTMLSpanElement>(null);
const [presetId, setPresetId] = useState(PRESETS[0].id);
const [temp, setTemp] = useState(0);
const [sat, setSat] = useState(100);
@@ -199,6 +202,23 @@ export function Landing() {
return () => document.removeEventListener('mousedown', away);
}, [themeMenu]);
// The account popover closes the same way, and signing out puts the chip back
// to its signed-out state without a reload.
useEffect(() => {
if (!accountMenu) return;
const away = (e: MouseEvent) => {
if (!account.current?.contains(e.target as Node)) setAccountMenu(false);
};
document.addEventListener('mousedown', away);
return () => document.removeEventListener('mousedown', away);
}, [accountMenu]);
const signOut = useCallback(async () => {
await api.logout().catch(() => undefined);
setUser(null);
setAccountMenu(false);
}, []);
// The simulator's output: three knobs, one CSS filter string, applied live.
const labFilter = `sepia(${(Math.abs(temp) / 260).toFixed(3)}) hue-rotate(${(-temp * 0.45).toFixed(1)}deg) saturate(${sat}%) contrast(${con}%)`;
@@ -294,14 +314,32 @@ export function Landing() {
signed in the same slot names the account, so the session is
visible on the page that took the credentials. */}
{user ? (
<a
className="lp-btn lp-sm"
data-key="nav-account"
href={user.admin ? '/admin' : '/app'}
title={user.email}
>
{user.email.split('@')[0]}
</a>
<span className="menu-wrap" ref={account}>
<button
type="button"
className="lp-btn lp-sm lp-account"
data-key="nav-account"
title={user.email}
aria-expanded={accountMenu}
onClick={() => setAccountMenu((v) => !v)}
>
<Avatar user={user} small />
{user.email.split('@')[0]}
</button>
{accountMenu ? (
<div className="popover lp-theme-pop lp-acc-pop">
<p className="hint">{user.email}</p>
{user.admin ? (
<a className="btn" data-key="nav-admin" href="/admin">{t('nav.admin')}</a>
) : (
<a className="btn" data-key="nav-profile" href="/profile">{t('nav.profile')}</a>
)}
<button type="button" className="btn" data-key="nav-logout" onClick={signOut}>
{t('nav.logout')}
</button>
</div>
) : null}
</span>
) : (
<button type="button" className="lp-btn lp-sm" data-key="nav-login" onClick={() => setAuth('login')}>
{t('nav.login')}
@@ -319,7 +357,7 @@ export function Landing() {
{nav.map((entry) => <a key={entry.href} href={entry.href} onClick={() => setMenu(false)}>{c(entry.label)}</a>)}
<a href="/app" onClick={() => setMenu(false)}>{c({ en: 'Open studio', vi: 'Mở studio' })}</a>
{user ? (
<a className="lp-btn" data-key="sheet-account" href={user.admin ? '/admin' : '/app'} onClick={() => setMenu(false)}>
<a className="lp-btn" data-key="sheet-account" href={user.admin ? '/admin' : '/profile'} onClick={() => setMenu(false)}>
{user.email.split('@')[0]}
</a>
) : (
+57
View File
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { api, type User } from './api';
import { useI18n } from './i18n/I18nProvider';
import { Profile } from './ui/Profile';
import { Avatar } from './ui/Avatar';
// The member's own page: the same form the admin drawer holds, reachable
// without the moderation screen. No router — main.tsx picks it by pathname.
export function ProfilePage() {
const { t } = useI18n();
const [user, setUser] = useState<User | null>(null);
const [loaded, setLoaded] = useState(false);
const [note, setNote] = useState<string | null>(null);
useEffect(() => {
api.me()
.then((r) => setUser(r.user))
.catch(() => undefined)
.finally(() => setLoaded(true));
}, []);
return (
<div className="adm">
<header className="adm-bar">
<a className="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('profile.title')}</b>
<span className="adm-spacer" />
<Avatar user={user} />
<a className="btn ghost" data-key="profile-home" href="/">{t('nav.back')}</a>
<button
type="button"
className="btn ghost"
data-key="profile-logout"
onClick={async () => {
await api.logout().catch(() => undefined);
window.location.assign('/');
}}
>
{t('nav.logout')}
</button>
</header>
{note ? <p className="hint adm-note">{note}</p> : null}
{user ? (
<Profile onSaved={setNote} onUser={setUser} />
) : loaded ? (
<p className="adm-note">
<a className="btn primary" href="/?auth=login">{t('nav.login')}</a>
</p>
) : null}
</div>
);
}
+50 -5
View File
@@ -8,6 +8,10 @@ export interface User {
// True when the account is on the API's ADMIN_EMAILS allowlist. The server
// re-checks it on every admin route; this only drives what the UI offers.
admin?: boolean;
// A ready-to-use picture URL (`/api/users/<id>/avatar?v=<file>`), or null.
// The version segment is the file's own name, so a replacement is never
// served from cache.
avatar?: string | null;
}
export interface SavedRecipe {
@@ -39,6 +43,17 @@ export interface AdminPhoto extends Photo {
bytes: number;
}
// One account as /api/admin/users reports it. `avatar` is the ready-made URL
// (or null), same shape as on the signed-in user.
export interface AdminUser {
id: number;
email: string;
createdAt: string;
photos: number;
admin: boolean;
avatar: string | null;
}
async function call<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, {
credentials: 'same-origin',
@@ -46,12 +61,23 @@ async function call<T>(path: string, init?: RequestInit): Promise<T> {
...init,
});
if (res.status === 204) return undefined as T;
const text = await res.text();
const body = text ? JSON.parse(text) : {};
const body = await readJson(res);
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
return body as T;
}
// A gateway error (nginx's 502/504 page) arrives as HTML, and the JSON parser's
// "Unexpected token '<'" says nothing useful — degrade to the status instead.
async function readJson(res: Response): Promise<{ error?: string } & Record<string, unknown>> {
const text = await res.text();
if (!text) return {};
try {
return JSON.parse(text) as { error?: string };
} catch {
return { error: `HTTP ${res.status}` };
}
}
export const api = {
// null user = signed out; the API answers 200 either way.
me: () => call<{ user: User | null }>('/auth/me'),
@@ -78,16 +104,35 @@ export const api = {
headers: { 'content-type': file.type },
body: file,
});
const text = await res.text();
const body = text ? JSON.parse(text) : {};
const body = await readJson(res);
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
return body as { photo: Photo };
return body as unknown as { photo: Photo };
},
photoUrl: (id: number) => `/api/photos/${id}/file`,
// Admin only: every account, with how many photos it owns.
adminListUsers: () => call<{ users: AdminUser[] }>('/admin/users'),
adminListPhotos: () => call<{ photos: AdminPhoto[] }>('/admin/photos'),
adminDeletePhoto: (id: number) => call<void>(`/admin/photos/${id}`, { method: 'DELETE' }),
adminClearPhotos: () => call<{ removed: number }>('/admin/photos', { method: 'DELETE' }),
adminSetPhotoSlot: (id: number, slot: PhotoSlot) =>
call<{ id: number; slot: PhotoSlot }>(`/admin/photos/${id}`, { method: 'PATCH', body: JSON.stringify({ slot }) }),
// Own profile. The API wants `currentPassword` on every edit, even an email-only one.
updateProfile: (body: { email?: string; password?: string; currentPassword: string }) =>
call<{ user: User }>('/auth/me', { method: 'PATCH', body: JSON.stringify(body) }),
// The profile picture, raw bytes like a photo. Replacing it deletes the old
// file, so the returned User carries a new `?v=` and nothing goes stale.
uploadAvatar: async (file: File) => {
const res = await fetch('/api/auth/avatar', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': file.type },
body: file,
});
const body = await readJson(res);
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
return body as unknown as { user: User };
},
};
+26
View File
@@ -113,6 +113,7 @@ export const en: Dict = {
'photo.failed': 'Upload failed: {msg}',
'nav.admin': 'Admin',
'nav.profile': 'Profile',
'adm.title': 'Strip moderation',
'adm.subtitle': 'Photos contributed by users. Delete one to pull it off the landing page.',
'adm.upload': 'ADD A PHOTO',
@@ -134,6 +135,31 @@ export const en: Dict = {
'adm.clearConfirm': 'Delete all {n} contributions? This cannot be undone.',
'adm.removed': 'Deleted {n} photos.',
'adm.back': 'Back to home',
'adm.settings': 'SETTINGS',
'adm.tabPictures': 'Pictures',
'adm.tabUsers': 'User account',
'adm.tabProfile': 'Profile',
'adm.close': 'Close',
'adm.userPicture': 'Picture',
'adm.userName': 'Name',
'adm.userEmail': 'Email',
'adm.userRole': 'Role',
'adm.userPhotos': 'Photos',
'adm.userJoined': 'Joined',
'adm.roleAdmin': 'Admin',
'adm.roleMember': 'Member',
'adm.viewPhotos': 'VIEW PHOTOS',
'adm.noUsers': 'No accounts yet.',
'adm.showAll': 'ALL',
'adm.profileHint': 'Picture, email and password of the signed-in account. Changing the email or password needs the current password.',
'adm.changeAvatar': 'CHANGE PICTURE',
'adm.currentPassword': 'Current password',
'adm.newEmail': 'New email',
'adm.newPassword': 'New password',
'adm.save': 'SAVE',
'adm.saved': 'Profile saved.',
'profile.title': 'My profile',
'err.noWebgl':
'The browser refused a WebGL context — this build needs WebGL to process photos (the CPU path is thousands of times slower).',
+26
View File
@@ -15,6 +15,7 @@ export const vi = {
'nav.language': 'Ngôn ngữ',
'nav.back': 'Về trang chủ',
'nav.admin': 'Quản trị',
'nav.profile': 'Hồ sơ',
'nav.guest': 'Khách',
'act.undo': 'HOÀN TÁC',
@@ -137,6 +138,31 @@ export const vi = {
'adm.clearConfirm': 'Xoá toàn bộ {n} ảnh đóng góp? Không hoàn tác được.',
'adm.removed': 'Đã xoá {n} ảnh.',
'adm.back': 'Về trang chủ',
'adm.settings': 'CÀI ĐẶT',
'adm.tabPictures': 'Ảnh',
'adm.tabUsers': 'Người dùng',
'adm.tabProfile': 'Hồ sơ',
'adm.close': 'Đóng',
'adm.userPicture': 'Ảnh',
'adm.userName': 'Tên',
'adm.userEmail': 'Email',
'adm.userRole': 'Quyền',
'adm.userPhotos': 'Số ảnh',
'adm.userJoined': 'Ngày tạo',
'adm.roleAdmin': 'Quản trị',
'adm.roleMember': 'Thành viên',
'adm.viewPhotos': 'XEM ẢNH',
'adm.noUsers': 'Chưa có tài khoản nào.',
'adm.showAll': 'TẤT CẢ',
'adm.profileHint': 'Ảnh đại diện, email và mật khẩu của tài khoản đang đăng nhập. Đổi email hoặc mật khẩu cần mật khẩu hiện tại.',
'adm.changeAvatar': 'ĐỔI ẢNH ĐẠI DIỆN',
'adm.currentPassword': 'Mật khẩu hiện tại',
'adm.newEmail': 'Email mới',
'adm.newPassword': 'Mật khẩu mới',
'adm.save': 'LƯU',
'adm.saved': 'Đã lưu hồ sơ.',
'profile.title': 'Hồ sơ của tôi',
'err.noWebgl':
'Trình duyệt không cấp được WebGL — bản dựng này cần WebGL để xử lý ảnh (chạy CPU chậm gấp hàng nghìn lần).',
+13 -4
View File
@@ -8,12 +8,21 @@ import { I18nProvider } from './i18n/I18nProvider';
import { Landing } from './Landing';
import { Workspace } from './App';
import { Admin } from './Admin';
import { ProfilePage } from './ProfilePage';
// Three routes, no router: the landing page, the workspace and the strip
// moderation screen. nginx serves index.html for all of them (SPA fallback),
// so this is just a pathname check.
// Four routes, no router: the landing page, the workspace, the strip moderation
// screen and the member's own profile. nginx serves index.html for all of them
// (SPA fallback), so this is just a pathname check.
const path = window.location.pathname;
const page = path.startsWith('/app') ? <Workspace /> : path.startsWith('/admin') ? <Admin /> : <Landing />;
const page = path.startsWith('/app') ? (
<Workspace />
) : path.startsWith('/admin') ? (
<Admin />
) : path.startsWith('/profile') ? (
<ProfilePage />
) : (
<Landing />
);
const root = document.getElementById('root');
if (!root) throw new Error('#root missing');
+90
View File
@@ -337,9 +337,99 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
.adm-danger { color: var(--danger); border-color: var(--border); }
.adm-danger:hover:not(:disabled) { border-color: var(--danger); }
/* Settings drawer: the tabs that organise the page, plus the three panels. */
/* The management frame: a bordered box whose left column is the menu and whose
right side is the pane for the chosen section. */
.adm-frame {
display: flex;
align-items: stretch;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-elev);
overflow: hidden;
}
.adm-side {
flex: 0 0 200px;
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 10px;
border-right: 1px solid var(--border);
background: var(--bg-sunken);
}
.adm-side-title {
padding: 0 8px 8px;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
}
.adm-side-item {
display: block;
padding: 8px 10px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
font: inherit;
text-align: left;
text-decoration: none;
cursor: pointer;
}
.adm-side-item:hover { background: var(--bg-elev); }
.adm-side-item.on { background: var(--accent); color: var(--accent-fg); font-weight: 600; }
.adm-pane { flex: 1 1 auto; min-width: 0; padding: 14px 16px; }
.adm-table-wrap { overflow-x: auto; }
.adm-face {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 999px;
overflow: hidden;
background: var(--bg-sunken);
border: 1px solid var(--border);
color: var(--text-dim);
font-size: 11px;
font-weight: 600;
}
.adm-face img { display: block; width: 100%; height: 100%; object-fit: cover; }
.adm-filter { display: flex; align-items: center; gap: 8px; }
.adm-form { display: flex; flex-direction: column; gap: 8px; max-width: 360px; }
.field-label { font-size: 11.5px; color: var(--text-dim); }
/* --- profile picture ---------------------------------------------------- */
/* One face for every control that names the signed-in user: the workspace
TopBar, the landing chip and the profile form. No picture = initial. */
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 26px;
height: 26px;
border-radius: 999px;
overflow: hidden;
background: var(--bg-sunken);
border: 1px solid var(--border);
color: var(--text-dim);
font-size: 11px;
font-weight: 600;
line-height: 1;
}
.avatar img { display: block; width: 100%; height: 100%; object-fit: cover; }
.avatar-sm { width: 20px; height: 20px; font-size: 10px; }
.avatar-row { display: flex; align-items: center; gap: 10px; }
.avatar-row .avatar { width: 46px; height: 46px; font-size: 17px; }
.account-btn { display: inline-flex; align-items: center; gap: 7px; }
/* --- responsive --------------------------------------------------------- */
@media (max-width: 860px) {
.workspace { flex-direction: column; }
/* The frame's left column becomes a wrapping row above the pane. */
.adm-frame { flex-direction: column; }
.adm-side { flex: 0 0 auto; flex-direction: row; flex-wrap: wrap; border-right: 0; border-bottom: 1px solid var(--border); }
/* The header wraps on narrow screens, so a menu-wrap can land at the start of
a row — the popover is right-anchored to its button and would hang off the
left of the screen (measured: x=-280 of 390px). Anchor it to the full-width
+17 -1
View File
@@ -142,10 +142,26 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; }
.lp-theme-pop .chip { background: transparent; border-color: var(--lp-line); color: var(--lp-muted); }
.lp-theme-pop .chip:hover { color: var(--lp-text); }
.lp-theme-pop .chip.on { color: var(--lp-ink-amber); border-color: var(--lp-ink-amber); background: rgba(251, 191, 36, 0.12); }
/* The account chip: a face + a name, opening the two things that account can
do — its page (Admin / Profile) and log out. */
.lp-account { display: inline-flex; align-items: center; gap: 7px; }
.lp-account .avatar { border-color: var(--lp-line); background: rgba(255, 255, 255, 0.06); color: var(--lp-muted); }
.lp-acc-pop .hint { color: var(--lp-muted); }
.lp-acc-pop .btn {
display: block;
width: 100%;
margin-top: 6px;
text-align: left;
background: transparent;
border-color: var(--lp-line);
color: var(--lp-muted);
text-decoration: none;
}
.lp-acc-pop .btn:hover { color: var(--lp-text); border-color: rgba(251, 191, 36, 0.5); }
.lp-burger { display: none; }
@media (max-width: 1160px) {
.lp-nav-links, .lp-nav .lp-wrap > .lp-btn { display: none; }
.lp-nav-links, .lp-nav .lp-wrap > .lp-btn, .lp-nav .lp-account { display: none; }
.lp-burger {
display: inline-flex;
flex-direction: column;
+13
View File
@@ -0,0 +1,13 @@
import type { User } from '../api';
// The signed-in user's picture, wherever their name is shown: the workspace
// TopBar, the landing chip, the profile form. Falls back to the email's first
// letter, so an account without a picture still has a face.
export function Avatar({ user, small }: { user: User | null; small?: boolean }) {
const url = user?.avatar ?? null;
return (
<span className={`avatar${small ? ' avatar-sm' : ''}`} data-key="avatar">
{url ? <img src={url} alt="" /> : (user?.email[0] ?? '?').toUpperCase()}
</span>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { useEffect, useRef, useState } from 'react';
import { api, type User } from '../api';
import { useI18n } from '../i18n/I18nProvider';
import { Avatar } from './Avatar';
// The signed-in account editing itself: picture, email, password. The API
// re-verifies the current password and the login limiter caps guessing, so this
// form holds no secret. Shared by the /admin settings drawer and /profile.
export function Profile({ onSaved, onUser }: { onSaved: (note: string) => void; onUser?: (user: User) => void }) {
const { t } = useI18n();
const [user, setUser] = useState<User | null>(null);
const [email, setEmail] = useState('');
const [current, setCurrent] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const filePick = useRef<HTMLInputElement>(null);
useEffect(() => {
api.me().then((r) => {
setUser(r.user);
setEmail(r.user?.email ?? '');
}).catch(() => undefined);
}, []);
const keep = (u: User) => {
setUser(u);
setEmail(u.email);
onUser?.(u);
};
const pick = async (file: File) => {
setBusy(true);
setErr(null);
try {
keep((await api.uploadAvatar(file)).user);
onSaved(t('adm.saved'));
} catch (e2) {
setErr(e2 instanceof Error ? e2.message : String(e2));
} finally {
setBusy(false);
}
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
setErr(null);
try {
const r = await api.updateProfile({
email: email.trim(),
currentPassword: current,
...(password ? { password } : {}),
});
keep(r.user);
setPassword('');
setCurrent('');
onSaved(t('adm.saved'));
} catch (e2) {
setErr(e2 instanceof Error ? e2.message : String(e2));
} finally {
setBusy(false);
}
};
return (
<form className="adm-form" data-key="adm-profile" onSubmit={submit}>
<p className="hint">{t('adm.profileHint')}</p>
<div className="avatar-row">
<Avatar user={user} />
<button
type="button"
className="btn"
data-key="adm-avatar-pick"
disabled={busy}
onClick={() => filePick.current?.click()}
>
{t('adm.changeAvatar')}
</button>
<input
ref={filePick}
type="file"
accept="image/png,image/jpeg,image/webp"
hidden
data-key="adm-avatar-file"
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) void pick(file);
}}
/>
</div>
<label className="field-label" htmlFor="adm-prof-email">{t('adm.newEmail')}</label>
<input
id="adm-prof-email"
className="field"
type="email"
required
data-key="adm-profile-email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<label className="field-label" htmlFor="adm-prof-current">{t('adm.currentPassword')}</label>
<input
id="adm-prof-current"
className="field"
type="password"
required
data-key="adm-profile-current"
value={current}
onChange={(e) => setCurrent(e.target.value)}
/>
<label className="field-label" htmlFor="adm-prof-password">{t('adm.newPassword')}</label>
<input
id="adm-prof-password"
className="field"
type="password"
minLength={8}
placeholder="••••••••"
data-key="adm-profile-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{err ? <p className="err">{err}</p> : null}
<button type="submit" className="btn primary" data-key="adm-profile-save" disabled={busy}>
{busy ? t('auth.busy') : t('adm.save')}
</button>
</form>
);
}
+22 -4
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { useI18n, LANGS } from '../i18n/I18nProvider';
import { ACCENTS, FONT_SETS, useTheme } from '../theme/ThemeProvider';
import { Avatar } from './Avatar';
import type { User } from '../api';
// Header: the app's TopBar minus everything camera-only, plus the two controls
@@ -123,18 +124,35 @@ export function TopBar({
</div>
<div className="menu-wrap">
<button type="button" className="btn ghost" onClick={() => setMenu(menu === 'account' ? null : 'account')}>
{user ? user.email.split('@')[0] : t('nav.guest')}
<button
type="button"
className="btn ghost account-btn"
data-key="nav-account"
aria-expanded={menu === 'account'}
onClick={() => setMenu(menu === 'account' ? null : 'account')}
>
{user ? (
<>
<Avatar user={user} small />
{user.email.split('@')[0]}
</>
) : (
t('nav.guest')
)}
</button>
{menu === 'account' ? (
<div className="popover">
{user ? (
<>
<p className="hint">{t('auth.loggedInAs', { email: user.email })}</p>
{/* The account control opens the one page that account can act
on: moderation for an admin, its own profile for a member. */}
{user.admin ? (
<a className="btn" data-key="nav-admin" href="/admin">{t('nav.admin')}</a>
) : null}
<button type="button" className="btn" onClick={onLogout}>{t('nav.logout')}</button>
) : (
<a className="btn" data-key="nav-profile" href="/profile">{t('nav.profile')}</a>
)}
<button type="button" className="btn" data-key="nav-logout" onClick={onLogout}>{t('nav.logout')}</button>
</>
) : (
<>