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
+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> {