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
+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 };
},
};