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