Files
RecipesCam/docker/frontend/src/api.ts
T
3dtours 45fc852d04 fix(library): land a reading's rows as they are read, and keep the copy the reader reads offline
- scan: rows go down on a clock as well as on a full batch. The reader's folder
  of 26 RAWs is under one batch of 50, so nothing of it reached the screen until
  the reading was over — a wall that stood empty for the whole scan and then
  filled in one go. Frames now reach the screen every 400ms while the roll is
  still being read, so the count climbs and the first tiles are drawn as the
  frames land.
- tiles: a HEIC or a RAW has its tile written the first time it is drawn, so
  every later visit reads the tile back instead of the file.
- resume: a reading a reload cut off is offered by the next visit with a chip
  instead of being run behind the reader's back.
- offline listing: the desk's listing is kept whole in IndexedDB under
  `recipescam-offline`/`reads`, past the 200 000 characters localStorage held,
  so an offline visit opens on the listing it kept instead of on nothing.
- offline writes: a vote or a view cast with no server is held and goes out when
  one answers again, with a bar that says how many are waiting.
- sw: the shell precaches its own bundle, stylesheet and icon, read out of the
  document — a worker that precached only the document answered an offline
  launch with a page whose JavaScript the machine did not have.
- checks in docker/frontend/scripts: tree-scan-check (counts while reading,
  resume after a reload, tiles of a long roll), offline-read-check,
  offline-queue-check.
2026-10-09 12:02:05 +07:00

390 lines
18 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, resizedJpeg } from './engine/imageOps';
import { forgetStale, hold, keepStale, serverAnswered, serverGone, stale } from './pwa/offline';
import { t } from './i18n/I18nProvider';
// 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;
// Signed-in accounts only unlock the PRO tier once their address is proven;
// an unverified one is served exactly like a guest. Admins count as verified.
verified?: boolean;
// The PRO tier itself: the proof above, or a grant the operator ticked for
// this account in the admin users table.
pro?: 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 sections, each of which shows one
// random photo out of its set per page load. A photo may sit in several at
// once; an empty set is the curator's removal from the landing.
export type PhotoSlot = 'strip' | 'tester' | 'creator' | 'qr';
// 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;
slots: 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;
// Whether the row still carries the look that made it. That look is the QR
// card's payload, so the admin needs to know a photo can hand one out without
// the listing shipping the recipe itself.
hasPreset: 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;
// The name of the look the photo was saved with, or null — the admin's A→Z
// ordering key for the album strip.
recipeName: string | null;
}
// One film-strip frame's score. `avg` is the mean of every vote (0 when nobody
// has voted), `n` how many there were, and `mine` this visitor's own vote (0
// when they have not rated it) — what the landing's star row is drawn from.
export interface Rating {
avg: number;
n: number;
mine: number;
}
// One frame of the landing hero's award column: what a window of votes left on
// one photo. `at` is the most recent of them, the API's own last tie-break; the
// labels and the file come from the public listing, keyed by `id`.
export interface PhotoTally {
id: number;
avg: number;
n: number;
at: string;
}
// The windows the award column reads: `day` is today's votes, `week` this ISO
// week's — so today's winners appear in both, and the column shows them once,
// as the day's. `ever` is every vote ever cast, and it is filled only when both
// windows came back empty: with nothing to name a frame today or this week, the
// best of all time is the only honest answer the column has left.
export interface Highlights {
day: PhotoTally[];
week: PhotoTally[];
ever: PhotoTally[];
}
// 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;
// The "Activated Pro" box: true hands this account the studio's PRO tier.
pro: 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> {
// A read is answered from what the server last said. A write is not answered
// at all when no server is there — pretending it landed is a lie the screen
// would act on — but it is not lost either: it is held, in order, and sent
// when one answers. See `hold`/`flush` in pwa/offline.ts.
const read = !init?.method || init.method === 'GET';
let res: Response;
try {
res = await fetch(`/api${path}`, {
credentials: 'same-origin',
headers: init?.body ? { 'content-type': 'application/json' } : undefined,
...init,
});
} catch (err) {
// No answer at all: the server is gone. Serve the last one it gave for this
// read, or fail as before — the caller has its own empty state.
serverGone();
// A write goes on the queue instead. The one exception is the sole call that
// is not about the server's own data: `open-explorer` asks it to pop a
// folder on this machine, and popping it minutes later is not popping it.
if (!read && path !== '/open-explorer') {
hold(path, init?.method ?? 'POST', typeof init?.body === 'string' ? init.body : null);
throw new Error(t('offline.queued'));
}
const staleOne = read ? await stale<T>(path) : null;
if (staleOne !== null) return staleOne;
throw err;
}
serverAnswered();
if (res.status === 204) return undefined as T;
const body = await readJson(res);
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
if (read) keepStale(path, body);
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 }) })
// The answers held offline are the previous account's and the next one must
// not be shown them; this browser is not an account until it has one.
.finally(forgetStale),
login: (email: string, password: string) =>
call<{ user: User }>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) })
.finally(forgetStale),
logout: () => call<void>('/auth/logout', { method: 'POST' }).finally(forgetStale),
// Mail the verification link to the signed-in address again. Works while
// unverified (that is the whole point); 429 once the hourly cap is spent.
resendVerification: () => call<{ ok: boolean; verified?: boolean }>('/auth/resend-verification', { method: 'POST' }),
// The six digits from the same letter, typed instead of clicking the link.
// The route answers to the session the signup already granted, so the code
// proves the address to the browser that asked for it.
verifyCode: (code: string) =>
call<{ ok: boolean; verified?: boolean }>('/auth/verify-code', { method: 'POST', body: JSON.stringify({ code }) }),
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'),
// The name of a coordinate, for the stamp. Resolved server-side (the browser
// has no OS geocoder), so a hit is a network round trip; null = no name.
place: (lat: number, lng: number) => call<{ place: string | null }>(`/place?lat=${lat}&lng=${lng}`),
// Film-strip ratings, keyed by subject (`photo:<id>` or `look:<TAG>`). Public:
// any visitor may read the tallies and cast one vote per frame.
ratings: () => call<{ ratings: Record<string, Rating> }>('/ratings'),
rate: (key: string, stars: number) =>
call<{ key: string; rating: Rating }>('/ratings', { method: 'POST', body: JSON.stringify({ key, stars }) }),
// The hero's award column: the best-rated contributions of the UTC day and of
// the ISO week, most deserving first. Public, same as the tally it is read
// off; the frames' own labels come from the public listing.
highlights: () => call<{ highlights: Highlights }>('/highlights'),
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`,
// The editable base beside a saved render — the pixels the look was applied
// to, written right after the photo itself. Opening a saved photo loads this,
// so its look lands on the original instead of a second time on its own
// output. 404 for a photo saved before the base existed.
photoBaseUrl: (id: number) => `/api/photos/${id}/base`,
putPhotoBase: async (id: number, bytes: Uint8Array) => {
// Always JPEG, always inside an upload's caps: the server compares the
// declared type against the bytes' magic number, and a base is only ever
// read back by the engine (`0` = no byte budget, so a copy already inside
// `MAX_PHOTO_DIM` is still re-encoded rather than passed through).
const out = await shrinkForUpload(bytes, MAX_PHOTO_DIM, 0);
const res = await fetch(`/api/photos/${id}/base`, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'content-type': 'image/jpeg' },
body: out as unknown as BodyInit,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
},
// The look a photo was uploaded with, as the `.recipe` file the app imports on
// a scan. Only answers for a photo the curator ticked into the `qr` section.
presetUrl: (id: number) => `/api/photos/${id}/preset.recipe`,
// 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' }),
adminSetPhotoSlots: (id: number, slots: PhotoSlot[]) =>
call<{ id: number; slots: PhotoSlot[] }>(`/admin/photos/${id}`, {
method: 'PATCH',
body: JSON.stringify({ slots }),
}),
// 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; pro?: boolean }) =>
call<{ user: AdminUser }>(`/admin/users/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
adminDeleteUser: (id: number) => call<void>(`/admin/users/${id}`, { method: 'DELETE' }),
// The whole data dir (SQLite + media) as one tar.gz, and the route that takes
// the same file back. The download is a plain link — the session cookie rides
// along — so nothing here fetches it.
adminBackupUrl: () => '/api/admin/backup',
// Raw bytes as the body, like a photo upload: the archive is already
// compressed, so no multipart wrapper and nothing to re-encode. The API
// replaces its data and restarts itself, so a 200 means "come back shortly".
adminRestore: async (file: File) => {
const res = await fetch('/api/admin/restore', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/gzip' },
body: file,
});
const body = await readJson(res);
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
return body as unknown as { ok: boolean };
},
// 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 };
},
// Open photo location in native system file manager (Windows Explorer / Finder / Linux file manager)
openExplorer: (path: string) => call<{ ok: boolean }>('/open-explorer', { method: 'POST', body: JSON.stringify({ path }) }),
};