web: re-save over the open frame, keeping its last three looks

Opening one of the folder's own photos and hitting SAVE PHOTO used to
make a second copy of it. Now it replaces that row — same id, same place
— and the look the row carried steps into its history, newest first and
capped at three, because the pixels it described are gone. The frame's
own column in MY PHOTOS lists those looks (click one to put its settings
back on the stage) and carries the landing-page consent as a plain tick,
which answers the click at once. A file from the disk clears the open
id, so a fresh frame still adds one.
This commit is contained in:
2026-09-18 14:47:30 +07:00
parent 27099c2877
commit 72d5ce1c3e
7 changed files with 249 additions and 51 deletions
+40 -13
View File
@@ -52,6 +52,9 @@ export interface Photo {
// 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.
@@ -119,6 +122,24 @@ async function call<T>(path: string, init?: RequestInit): Promise<T> {
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>> {
@@ -134,11 +155,17 @@ async function readJson(res: Response): Promise<{ error?: string } & Record<stri
// 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): Promise<Response> {
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: 'POST',
method,
credentials: 'same-origin',
headers: { 'content-type': out === bytes ? file.type || 'image/jpeg' : 'image/jpeg' },
body: out as unknown as BodyInit,
@@ -166,21 +193,21 @@ export const api = {
// 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?: { recipe?: Recipe; consent?: boolean }) => {
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');
const suffix = q.size > 0 ? `?${q}` : '';
const res = await uploadBytes(`/api/photos${suffix}`, file, MAX_PHOTO_DIM, MAX_PHOTO_UPLOAD);
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 }) }),