web: moderate accounts, accept 12MB uploads, put SAVE under CREATE
- /admin User account rows gain BLOCK/UNBLOCK, REMOVE/RESTORE and DELETE. Blocked = cannot sign in (sessions swept), removed = hidden from the strip and cannot sign in, both reversible; DELETE drops the account with its photos and recipes and unlinks the files. An allowlisted account is never a target, so an admin cannot moderate or delete itself. - Photo uploads move from a 3MB API cap / 4m nginx cap to 12MB / 16m, and the browser shrinks an oversized still before sending it (2048px JPEG, avatars 512px) so the declared type still matches the sniffed bytes. - The studio SAVE leaves the top bar and sits under the CREATE RECIPES tab, labelled SAVE RECIPES.
This commit is contained in:
+34
-12
@@ -1,6 +1,14 @@
|
||||
// Thin wrapper over the API container. Same origin in production (nginx proxies
|
||||
// /api), the Vite dev server proxies it too — so no base URL, no CORS.
|
||||
import type { Recipe } from '../shared/types';
|
||||
import { shrinkForUpload } from './engine/imageOps';
|
||||
|
||||
// Upload caps. The API + nginx both refuse oversized bodies, so shrink in the
|
||||
// browser first; the server then sniffs the bytes and requires the declared
|
||||
// content-type to match, which is why the type follows the re-encode below.
|
||||
const MAX_PHOTO_UPLOAD = 12 * 1024 * 1024;
|
||||
const MAX_PHOTO_DIM = 2048;
|
||||
const MAX_AVATAR_DIM = 512;
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
@@ -52,6 +60,10 @@ export interface AdminUser {
|
||||
photos: number;
|
||||
admin: boolean;
|
||||
avatar: string | null;
|
||||
// Moderation state: blocked = cannot sign in; removed = pulled off the site
|
||||
// but restorable (a hard delete drops the row entirely).
|
||||
blocked: boolean;
|
||||
removed: boolean;
|
||||
}
|
||||
|
||||
async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -78,6 +90,20 @@ async function readJson(res: Response): Promise<{ error?: string } & Record<stri
|
||||
}
|
||||
}
|
||||
|
||||
// POST a picture as raw bytes, after shrinking it under the server's caps.
|
||||
// Declared type stays the original unless the bytes were re-encoded to JPEG —
|
||||
// the server compares it against the sniffed magic number.
|
||||
async function uploadBytes(url: string, file: File, maxDim: number, maxBytes: number): Promise<Response> {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const out = await shrinkForUpload(bytes, maxDim, maxBytes);
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'content-type': out === bytes ? file.type || 'image/jpeg' : 'image/jpeg' },
|
||||
body: out as unknown as BodyInit,
|
||||
});
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// null user = signed out; the API answers 200 either way.
|
||||
me: () => call<{ user: User | null }>('/auth/me'),
|
||||
@@ -98,12 +124,7 @@ export const api = {
|
||||
// request, so no multipart framing and no extra dependency.
|
||||
listPhotos: () => call<{ photos: Photo[] }>('/photos'),
|
||||
uploadPhoto: async (file: File) => {
|
||||
const res = await fetch('/api/photos', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'content-type': file.type },
|
||||
body: file,
|
||||
});
|
||||
const res = await uploadBytes('/api/photos', file, MAX_PHOTO_DIM, MAX_PHOTO_UPLOAD);
|
||||
const body = await readJson(res);
|
||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
return body as unknown as { photo: Photo };
|
||||
@@ -118,6 +139,12 @@ export const api = {
|
||||
adminSetPhotoSlot: (id: number, slot: PhotoSlot) =>
|
||||
call<{ id: number; slot: PhotoSlot }>(`/admin/photos/${id}`, { method: 'PATCH', body: JSON.stringify({ slot }) }),
|
||||
|
||||
// Moderation of an account. Both flags are reversible; `adminDeleteUser` is
|
||||
// the final act and takes the account's photos and recipes with it.
|
||||
adminSetUser: (id: number, patch: { blocked?: boolean; removed?: boolean }) =>
|
||||
call<{ user: AdminUser }>(`/admin/users/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
|
||||
adminDeleteUser: (id: number) => call<void>(`/admin/users/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// 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) }),
|
||||
@@ -125,12 +152,7 @@ export const api = {
|
||||
// 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 res = await uploadBytes('/api/auth/avatar', file, MAX_AVATAR_DIM, MAX_PHOTO_UPLOAD);
|
||||
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