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:
2026-09-18 10:33:35 +07:00
parent 8e6c1493e8
commit 43d86b4b6f
12 changed files with 386 additions and 45 deletions
+55 -1
View File
@@ -116,6 +116,22 @@ export function Admin() {
setTab('pictures');
};
// Block/unblock and remove/restore share one route; the list is reloaded
// because either flag also changes what the built-in viewer would show.
const setUserState = (id: number, patch: { blocked?: boolean; removed?: boolean }) =>
run(async () => {
await api.adminSetUser(id, patch);
await refreshPhotos();
});
const deleteUser = (u: AdminUser) => {
if (!window.confirm(t('adm.userDeleteConfirm', { email: u.email }))) return;
return run(async () => {
await api.adminDeleteUser(u.id);
await refreshPhotos();
});
};
const shown = owner ? photos.filter((p) => p.email === owner) : photos;
return (
@@ -270,7 +286,11 @@ export function Admin() {
<span className="adm-face adm-face-empty">{(u.email[0] ?? '?').toUpperCase()}</span>
)}
</td>
<td>{u.email.split('@')[0]}</td>
<td>
{u.email.split('@')[0]}
{u.blocked ? <span className="adm-badge adm-badge-warn">{t('adm.userBlocked')}</span> : null}
{u.removed ? <span className="adm-badge adm-badge-warn">{t('adm.userRemoved')}</span> : null}
</td>
<td>{u.email}</td>
<td>
<span className={`adm-badge${u.admin ? ' on' : ''}`}>
@@ -288,6 +308,40 @@ export function Admin() {
>
{t('adm.viewPhotos')}
</button>
{/* An admin account is the API's own privilege
source, so it is never a moderation target and
offers no buttons here. */}
{u.admin ? null : (
<>
<button
type="button"
className="btn"
data-key={`adm-user-block-${u.id}`}
disabled={busy}
onClick={() => void setUserState(u.id, { blocked: !u.blocked })}
>
{u.blocked ? t('adm.userUnblock') : t('adm.userBlock')}
</button>
<button
type="button"
className="btn"
data-key={`adm-user-remove-${u.id}`}
disabled={busy}
onClick={() => void setUserState(u.id, { removed: !u.removed })}
>
{u.removed ? t('adm.userRestore') : t('adm.userRemove')}
</button>
<button
type="button"
className="btn adm-danger"
data-key={`adm-user-delete-${u.id}`}
disabled={busy}
onClick={() => void deleteUser(u)}
>
{t('adm.userDelete')}
</button>
</>
)}
</td>
</tr>
))}
+24 -11
View File
@@ -1023,8 +1023,9 @@ export function Workspace() {
setShowRecipes((v) => !v);
},
},
// ponytail: no CREATE chip — saving goes through the top bar. IMPORT
// is a chip inside the RECIPES strip.
// ponytail: no CREATE chip — the save button lives under the CREATE
// RECIPES tab (see the create column). IMPORT is a chip inside the
// RECIPES strip.
];
case 'favorited':
return saved.length === 0
@@ -1114,7 +1115,6 @@ export function Workspace() {
onUndo={undoLast}
onReset={reset}
onExport={exportPhoto}
onSave={saveRecipe}
onAuth={() => setAuthMode('login')}
onSignup={() => setAuthMode('signup')}
onLogout={async () => {
@@ -1131,14 +1131,27 @@ export function Workspace() {
<div className={`col col-main${tab === 'create' ? ' col-create' : ''}`} data-col="main">
<ChipRow chips={[resetChip()]} />
{tab === 'create' ? (
<RecipeCreatePanel
currentBase={recipe.baseFilter}
current={recipe.adjustments}
signedIn={!!user}
onPreview={previewCreatedRecipe}
onSave={(draft) => void addCreatedRecipe(draft)}
onExport={exportRecipeFile}
/>
<>
<RecipeCreatePanel
currentBase={recipe.baseFilter}
current={recipe.adjustments}
signedIn={!!user}
onPreview={previewCreatedRecipe}
onSave={(draft) => void addCreatedRecipe(draft)}
onExport={exportRecipeFile}
/>
{/* The studio's own save lives here now, under the CREATE tab:
it stores the recipe standing on the stage, where the panel
above stores the draft being built. */}
<button
type="button"
className="btn"
data-key="save-recipes"
onClick={() => void saveRecipe()}
>
{t('save.recipes')}
</button>
</>
) : (
<ChipRow chips={chips} />
)}
+34 -12
View File
@@ -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 };
+37
View File
@@ -31,6 +31,43 @@ export async function resizedJpeg(bytes: Uint8Array, maxDim: number, quality = 0
}
}
// Uploads are capped by the API (and by nginx in front of it), so a phone's
// 12MP JPEG has to shrink before it goes up — the same downscale the preview
// uses. The ORIGINAL bytes come back untouched (same reference) when the photo
// already fits both the pixel and the byte budget, which is what keeps a small
// PNG's declared content-type honest instead of silently turning it into JPEG.
export async function shrinkForUpload(
bytes: Uint8Array,
maxDim: number,
maxBytes: number
): Promise<Uint8Array> {
try {
return await shrinkOrKeep(bytes, maxDim, maxBytes);
} catch {
// Undecodable here (corrupt or exotic): hand it to the server unchanged and
// let its magic-number sniff give the real answer.
return bytes;
}
}
async function shrinkOrKeep(bytes: Uint8Array, maxDim: number, maxBytes: number): Promise<Uint8Array> {
const resized = await resizedJpeg(bytes, maxDim);
if (resized !== bytes) return resized;
if (bytes.length <= maxBytes) return bytes;
// Already inside maxDim yet still over the byte cap: force one JPEG re-encode.
const bitmap = await createImageBitmap(new Blob([bytes as BlobPart]));
try {
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d');
if (!ctx) return bytes;
ctx.drawImage(bitmap, 0, 0);
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.9 });
return new Uint8Array(await blob.arrayBuffer());
} finally {
bitmap.close();
}
}
// EXIF GPS of the loaded photo, in the shape the renderer + EXIF writer expect.
// Returns null when the photo has none — the caller then offers manual entry.
export async function readGps(bytes: Uint8Array): Promise<GPSInfo | null> {
+10 -1
View File
@@ -103,7 +103,7 @@ export const en: Dict = {
'save.title': 'Save recipe',
'save.name': 'Recipe name',
'save.save': 'SAVE',
'save.recipes': 'SAVE RECIPES',
'save.saved': 'Saved "{name}"',
'save.needLogin': 'Log in to save a recipe.',
@@ -159,6 +159,15 @@ export const en: Dict = {
'adm.newPassword': 'New password',
'adm.save': 'SAVE',
'adm.saved': 'Profile saved.',
'adm.userActions': 'Actions',
'adm.userBlock': 'BLOCK',
'adm.userUnblock': 'UNBLOCK',
'adm.userRemove': 'REMOVE',
'adm.userRestore': 'RESTORE',
'adm.userDelete': 'DELETE',
'adm.userBlocked': 'Blocked',
'adm.userRemoved': 'Removed',
'adm.userDeleteConfirm': 'Permanently delete the account {email} with all of its photos and recipes? This cannot be undone.',
'profile.title': 'My profile',
+10 -1
View File
@@ -109,7 +109,7 @@ export const vi = {
'save.title': 'Lưu công thức',
'save.name': 'Tên công thức',
'save.save': 'LƯU',
'save.recipes': 'LƯU CÔNG THỨC',
'save.saved': 'Đã lưu "{name}"',
'save.needLogin': 'Đăng nhập để lưu công thức.',
@@ -162,6 +162,15 @@ export const vi = {
'adm.newPassword': 'Mật khẩu mới',
'adm.save': 'LƯU',
'adm.saved': 'Đã lưu hồ sơ.',
'adm.userActions': 'Thao tác',
'adm.userBlock': 'KHOÁ',
'adm.userUnblock': 'MỞ KHOÁ',
'adm.userRemove': 'GỠ',
'adm.userRestore': 'KHÔI PHỤC',
'adm.userDelete': 'XOÁ HẲN',
'adm.userBlocked': 'Đã khoá',
'adm.userRemoved': 'Đã gỡ',
'adm.userDeleteConfirm': 'Xoá vĩnh viễn tài khoản {email} cùng toàn bộ ảnh và công thức của nó? Không hoàn tác được.',
'profile.title': 'Hồ sơ của tôi',
+5
View File
@@ -400,6 +400,11 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
}
.adm-face img { display: block; width: 100%; height: 100%; object-fit: cover; }
.adm-filter { display: flex; align-items: center; gap: 8px; }
/* Moderation state next to the account name, and the row's own actions: the
three controls sit side by side and the pane scrolls when it runs out. */
.adm-badge-warn { margin-left: 6px; color: var(--danger); font-size: 11px; }
.adm-table td:last-child { white-space: nowrap; }
.adm-table td:last-child .btn { margin-right: 6px; }
.adm-form { display: flex; flex-direction: column; gap: 8px; max-width: 360px; }
.field-label { font-size: 11.5px; color: var(--text-dim); }
-3
View File
@@ -14,7 +14,6 @@ export function TopBar({
onUndo,
onReset,
onExport,
onSave,
onAuth,
onSignup,
onLogout,
@@ -26,7 +25,6 @@ export function TopBar({
onUndo: () => void;
onReset: () => void;
onExport: () => void;
onSave: () => void;
onAuth: () => void;
onSignup: () => void;
onLogout: () => void;
@@ -57,7 +55,6 @@ export function TopBar({
<button type="button" className="btn ghost" disabled={!canUndo} onClick={onUndo}>{t('act.undo')}</button>
<button type="button" className="btn ghost" onClick={onReset}>{t('act.reset')}</button>
<button type="button" className="btn" onClick={onSave}>{t('save.save')}</button>
<button type="button" className="btn primary" disabled={exporting} onClick={onExport}>
{exporting ? t('act.exporting') : t('act.export')}
</button>