Files
RecipesCam/docker/frontend/src/api.ts
T
3dtours 97836fde8a api+admin: let the curator take a photo off the landing, not just move it
Uploads still land in the community strip with consent on, by the uploader's own
tick — that default stays. What was missing is the curator's removal: the slot
select only offered the other three live placements, so "off the strip" meant
publishing the photo somewhere else or deleting the uploader's row.

`off` is a fifth slot value. The reel draws slot === 'strip' and each live slot
draws its own, so an `off` photo renders nowhere on the landing, while its owner
still has it in MY PHOTOS.
2026-09-18 15:07:04 +07:00

248 lines
11 KiB
TypeScript

// 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;
email: string;
// 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 {
id: number;
name: string;
recipe: unknown;
createdAt: string;
updatedAt: string;
}
// Where a curated photo may appear on the landing page. `strip` is the
// community reel; the rest are the three live slots, each of which shows one
// random photo out of its set per page load. `off` keeps the row but takes it
// off the landing entirely — the curator's removal.
export type PhotoSlot = 'strip' | 'tester' | 'creator' | 'qr' | 'off';
// A strip contribution as the public sees it — the API never puts an email on
// this shape. `tag`/`title`/`meta` are the frame's own labels (the amber
// tagline, the artwork title and the `ISO … · GRAIN …` line); null when the
// uploader sent none, and the reel then falls back to its built-in text.
export interface Photo {
id: number;
createdAt: string;
slot: PhotoSlot;
tag: string | null;
title: string | null;
meta: string | null;
// The uploader's consent to show this frame on the landing film strip. Own
// rows carry it back; the public reel only returns consented ones anyway.
consent?: boolean;
// The look the photo was saved with, on the owner's own listing only — the
// studio reads it back so a saved photo can be reopened for editing.
recipe?: Recipe | null;
// The looks that photo carried before its last saves, newest first and capped
// at 3 by the API. Owner's own listing only; absent means "never re-saved".
history?: Recipe[];
}
// What a caller may attach to an upload. Same three labels.
export interface PhotoLabels {
tag?: string;
title?: string;
meta?: string;
}
// Admin listing only: adds the owner, which /api/admin/photos is gated on.
export interface AdminPhoto extends Photo {
userId: number;
email: string;
mime: string;
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;
// Moderation state: blocked = cannot sign in; removed = pulled off the site
// but restorable (a hard delete drops the row entirely).
blocked: boolean;
removed: boolean;
}
// One row of a grouped count on the traffic screen. The API sorts them, largest
// first, and drops the empty ones.
export interface StatBucket {
key: string;
n: number;
}
// What /api/admin/stats reports: the day series the chart draws, plus one
// grouped breakdown per dimension. Admin only.
export interface Stats {
days: number;
totals: { views: number; clicks: number; visitors: number };
series: { date: string; views: number; clicks: number }[];
pages: StatBucket[];
targets: StatBucket[];
countries: StatBucket[];
regions: StatBucket[];
cities: StatBucket[];
browsers: StatBucket[];
systems: StatBucket[];
devices: StatBucket[];
}
async function call<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, {
credentials: 'same-origin',
headers: init?.body ? { 'content-type': 'application/json' } : undefined,
...init,
});
if (res.status === 204) return undefined as T;
const body = await readJson(res);
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
return body as T;
}
// What an upload (or a re-save) may carry beside the bytes, on the query string
// because the body is the image itself.
type PhotoMetaOpts = { recipe?: Recipe; consent?: boolean };
// The frame's labels and its look, as the query suffix the photo routes read.
// `?` is left on the caller: an empty one is a valid URL either way.
function photoQuery(labels?: PhotoLabels, opts?: PhotoMetaOpts): string {
const q = new URLSearchParams();
if (labels?.tag) q.set('tag', labels.tag);
if (labels?.title) q.set('title', labels.title);
if (labels?.meta) q.set('meta', labels.meta);
// The look rides the query string too, so the photo reopens with its own
// settings. The server caps the length and drops anything not an object.
if (opts?.recipe) q.set('recipe', JSON.stringify(opts.recipe));
if (opts?.consent === false) q.set('consent', '0');
return q.size > 0 ? `?${q}` : '';
}
// 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}` };
}
}
// 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,
method: 'POST' | 'PUT' = 'POST',
): Promise<Response> {
const bytes = new Uint8Array(await file.arrayBuffer());
const out = await shrinkForUpload(bytes, maxDim, maxBytes);
return fetch(url, {
method,
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'),
signup: (email: string, password: string) =>
call<{ user: User }>('/auth/signup', { method: 'POST', body: JSON.stringify({ email, password }) }),
login: (email: string, password: string) =>
call<{ user: User }>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
logout: () => call<void>('/auth/logout', { method: 'POST' }),
listRecipes: () => call<{ recipes: SavedRecipe[] }>('/recipes'),
createRecipe: (name: string, recipe: Recipe) =>
call<{ recipe: SavedRecipe }>('/recipes', { method: 'POST', body: JSON.stringify({ name, recipe }) }),
updateRecipe: (id: number, name: string, recipe: Recipe) =>
call<{ recipe: SavedRecipe }>(`/recipes/${id}`, { method: 'PUT', body: JSON.stringify({ name, recipe }) }),
deleteRecipe: (id: number) => call<void>(`/recipes/${id}`, { method: 'DELETE' }),
// The strip. Upload is the raw file as the request body — one image per
// request, so no multipart framing and no extra dependency. The frame's
// labels ride the query string, since the body is the image itself.
listPhotos: () => call<{ photos: Photo[] }>('/photos'),
listMyPhotos: () => call<{ photos: Photo[] }>('/photos/mine'),
uploadPhoto: async (file: File, labels?: PhotoLabels, opts?: PhotoMetaOpts) => {
const res = await uploadBytes(`/api/photos${photoQuery(labels, opts)}`, 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 };
},
// Re-saving a photo the account already owns: the bytes replace the row's own
// and the look it carried moves into its history. Owner-only, same shape as
// the upload.
replacePhoto: async (id: number, file: File, labels?: PhotoLabels, opts?: PhotoMetaOpts) => {
const res = await uploadBytes(`/api/photos/${id}${photoQuery(labels, opts)}`, file, MAX_PHOTO_DIM, MAX_PHOTO_UPLOAD, 'PUT');
const body = await readJson(res);
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
return body as unknown as { photo: Photo & { history: Recipe[] } };
},
// Toggle this photo's place on the landing film strip. Owner-only.
setPhotoConsent: (id: number, consent: boolean) =>
call<{ id: number; consent: boolean }>(`/photos/${id}`, { method: 'PATCH', body: JSON.stringify({ consent }) }),
// The owner's own delete; an admin may pass any id here too.
deletePhoto: (id: number) => call<void>(`/photos/${id}`, { method: 'DELETE' }),
photoUrl: (id: number) => `/api/photos/${id}/file`,
// Admin only: every account, with how many photos it owns.
adminListUsers: () => call<{ users: AdminUser[] }>('/admin/users'),
// The traffic screen. `days` is clamped server-side, never rejected.
adminStats: (days: number) => call<Stats>(`/admin/stats?days=${days}`),
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 }) }),
// 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) }),
// 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 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 };
},
};