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:
@@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user