// 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'; 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; } 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. export type PhotoSlot = 'strip' | 'tester' | 'creator' | 'qr'; // A strip contribution as the public sees it — the API never puts an email on // this shape. export interface Photo { id: number; createdAt: string; slot: PhotoSlot; } // 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; } async function call(path: string, init?: RequestInit): Promise { 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 text = await res.text(); const body = text ? JSON.parse(text) : {}; if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`); return body as T; } 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('/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(`/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. 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 text = await res.text(); const body = text ? JSON.parse(text) : {}; if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`); return body as { photo: Photo }; }, photoUrl: (id: number) => `/api/photos/${id}/file`, adminListPhotos: () => call<{ photos: AdminPhoto[] }>('/admin/photos'), adminDeletePhoto: (id: number) => call(`/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 }) }), };