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