web: give each member a photo folder and burn the strip into the export

Every member gets /photos — their own uploads, counted against a 12-photo
cap, each card showing the tagline and the technical line the studio would
print. The studio gains SAVE PHOTO n/12 in the top bar: it renders the full
resolution look, stores the strip (tag/title/meta) with the upload so the
landing reel frames it the same way, and refuses past the cap.

EXPORT now burns that strip into the file: the amber #TAG over the photo's
top-left plus a dark caption band below carrying the recipe name and the
ISO / grain / warmth line. The live preview stays clean, and the saved
upload stays clean too — the reel draws its own frame from the stored
labels, so a burned band would tag the tag twice.

Admins manage any photo through DELETE /api/photos/:id; members only their
own. The users table's photo counts stay in step with the folder.
This commit is contained in:
2026-09-18 10:56:26 +07:00
parent 43d86b4b6f
commit b4d5d2926b
14 changed files with 576 additions and 22 deletions
+25 -4
View File
@@ -36,11 +36,23 @@ export interface SavedRecipe {
export type PhotoSlot = 'strip' | 'tester' | 'creator' | 'qr';
// A strip contribution as the public sees it — the API never puts an email on
// this shape.
// 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;
}
// 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.
@@ -121,14 +133,23 @@ export const api = {
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.
// 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'),
uploadPhoto: async (file: File) => {
const res = await uploadBytes('/api/photos', file, MAX_PHOTO_DIM, MAX_PHOTO_UPLOAD);
listMyPhotos: () => call<{ photos: Photo[] }>('/photos/mine'),
uploadPhoto: async (file: File, labels?: PhotoLabels) => {
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);
const suffix = q.size > 0 ? `?${q}` : '';
const res = await uploadBytes(`/api/photos${suffix}`, 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 };
},
// 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.