From 72d5ce1c3eb3a2e68a2b719fd476cb43251ec9fd Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 18 Sep 2026 14:47:30 +0700 Subject: [PATCH] web: re-save over the open frame, keeping its last three looks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docker/backend/src/db.ts | 102 ++++++++++++++++++++++++----- docker/backend/src/server.ts | 37 +++++++++++ docker/frontend/src/App.tsx | 92 +++++++++++++++++++------- docker/frontend/src/api.ts | 53 +++++++++++---- docker/frontend/src/i18n/en.ts | 3 + docker/frontend/src/i18n/vi.ts | 3 + docker/frontend/src/styles/app.css | 10 +++ 7 files changed, 249 insertions(+), 51 deletions(-) diff --git a/docker/backend/src/db.ts b/docker/backend/src/db.ts index 976a7dc..ac6bcbb 100644 --- a/docker/backend/src/db.ts +++ b/docker/backend/src/db.ts @@ -148,6 +148,18 @@ export const isPhotoSlot = (v: unknown): v is PhotoSlot => } } +// The photo's own edit history: the looks it carried before the last few saves, +// newest first, capped at PHOTO_HISTORY_MAX. Re-saving an open photo replaces +// its pixels and its recipe, so the previous recipe is the only way back — a +// short JSON array is enough of a log for that. +export const PHOTO_HISTORY_MAX = 3; +{ + const cols = db.prepare('PRAGMA table_info(photos)').all() as { name: string }[]; + if (!cols.some((c) => c.name === 'history')) { + db.exec(`ALTER TABLE photos ADD COLUMN history TEXT`); + } +} + // `avatar` is the stored file name, or null for "no picture". export type User = { id: number; @@ -313,8 +325,9 @@ export type Photo = { // The owner's own folder reads it back to draw the toggle. consent: boolean; }; -// The owner's own row adds the look that made it, so it can be opened again. -export type MyPhoto = Photo & { recipe: unknown | null }; +// The owner's own row adds the look that made it, so it can be opened again, +// and the looks it carried before: newest first, at most PHOTO_HISTORY_MAX. +export type MyPhoto = Photo & { recipe: unknown | null; history: unknown[] }; export type AdminPhoto = Photo & { userId: number; email: string; mime: string; bytes: number }; export type PhotoMeta = { tag?: string | null; @@ -331,20 +344,26 @@ const PHOTO_COLUMNS = `photos.id AS id, photos.created_at AS createdAt, photos.s // SQLite has no boolean: a row comes back 0/1 and a recipe as its JSON text. type PhotoRow = Omit & { consent: number }; -type MyPhotoRow = PhotoRow & { recipe: string | null }; +type MyPhotoRow = PhotoRow & { recipe: string | null; history: string | null }; const toPhoto = (row: PhotoRow): Photo => ({ ...row, consent: row.consent === 1 }); -const toMyPhoto = (row: MyPhotoRow): MyPhoto => ({ - ...toPhoto(row), - // A row whose JSON will not parse is still a photo: its settings are simply - // gone, not worth failing the whole folder over. - recipe: (() => { - try { - return row.recipe ? JSON.parse(row.recipe) : null; - } catch { - return null; - } - })(), -}); +// A row whose JSON will not parse is still a photo: its settings are simply +// gone, not worth failing the whole folder over. Same for one bad entry in the +// history — the rest of the list still stands. +const parseJson = (raw: string | null): unknown => { + try { + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +}; +const toMyPhoto = (row: MyPhotoRow): MyPhoto => { + const history = parseJson(row.history); + return { + ...toPhoto(row), + recipe: parseJson(row.recipe), + history: Array.isArray(history) ? history : [], + }; +}; export function listPhotos(): Photo[] { return ( @@ -379,7 +398,7 @@ export function listPhotosByUser(userId: number): MyPhoto[] { return ( db .prepare( - `SELECT ${PHOTO_COLUMNS}, photos.recipe AS recipe + `SELECT ${PHOTO_COLUMNS}, photos.recipe AS recipe, photos.history AS history FROM photos WHERE user_id = ? ORDER BY photos.id DESC`, ) .all(userId) as MyPhotoRow[] @@ -503,6 +522,57 @@ export function createPhoto( }; } +// Re-saving a photo the caller already owns: the pixels are replaced in place +// and the look the row carried steps into its history, newest first and capped +// — the only way back to it, since the bytes it described are gone. A row that +// is not theirs, or not there, comes back undefined. +export function replacePhoto( + userId: number, + id: number, + file: string, + mime: string, + bytes: number, + meta?: PhotoMeta, +): MyPhoto | undefined { + const row = db + .prepare( + `SELECT ${PHOTO_COLUMNS}, photos.recipe AS recipe, photos.history AS history + FROM photos WHERE id = ? AND user_id = ?`, + ) + .get(id, userId) as MyPhotoRow | undefined; + if (!row) return undefined; + const before = toMyPhoto(row); + // Only a save that carried a new look is a version of anything, and the log + // is capped on the way in so the column can never grow past it. + const history = + meta?.recipe === undefined || before.recipe === null + ? before.history + : [before.recipe, ...before.history].slice(0, PHOTO_HISTORY_MAX); + db.prepare( + `UPDATE photos + SET file = ?, mime = ?, bytes = ?, tag = ?, title = ?, meta = ?, consent = ?, recipe = ?, history = ? + WHERE id = ?`, + ).run( + file, + mime, + bytes, + meta?.tag ?? null, + meta?.title ?? null, + meta?.meta ?? null, + meta?.consent === false ? 0 : 1, + meta?.recipe === undefined ? JSON.stringify(before.recipe) : JSON.stringify(meta.recipe), + JSON.stringify(history), + id, + ); + return toMyPhoto( + db + .prepare( + `SELECT ${PHOTO_COLUMNS}, photos.recipe AS recipe, photos.history AS history FROM photos WHERE id = ?`, + ) + .get(id) as MyPhotoRow, + ); +} + // The uploader's own toggle: may this photo show on the landing strip? export function setPhotoConsent(userId: number, id: number, consent: boolean): boolean { return ( diff --git a/docker/backend/src/server.ts b/docker/backend/src/server.ts index 68c92b0..63e2bcb 100644 --- a/docker/backend/src/server.ts +++ b/docker/backend/src/server.ts @@ -30,6 +30,7 @@ import { listPhotosWithOwner, listRecipes, listUsersWithCounts, + replacePhoto, avatarPath, photoFile, photoPath, @@ -538,6 +539,42 @@ app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply return reply.status(201).send({ photo }); }); +// Re-saving one of the caller's own photos: the same bytes-in, meta-on-the- +// query shape as the upload, but it lands on the row they named instead of +// making a new one. The pixels it displaces are gone for good, so the look they +// carried steps into the row's history (see replacePhoto) and the old file is +// unlinked. Owner only — the user_id in the WHERE is the authorisation. +app.put<{ Params: { id: string } }>( + '/api/photos/:id', + { bodyLimit: MAX_PHOTO_BYTES + 8192 }, + async (req, reply) => { + const user = auth(req); + if (!user) return reply.status(401).send({ error: 'unauthorized' }); + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' }); + if (!allowUpload(String(user.id))) return tooMany(reply); + + const body = req.body; + if (!Buffer.isBuffer(body) || body.length === 0) return reply.status(400).send({ error: 'invalid body' }); + if (body.length > MAX_PHOTO_BYTES) return reply.status(413).send({ error: 'photo too large' }); + + const declared = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase(); + const mime = sniffImage(body); + if (!mime || mime !== declared) return reply.status(415).send({ error: 'unsupported image type' }); + + const previous = photoFile(id); + const file = `${randomBytes(16).toString('hex')}.${EXT[mime]}`; + writeFileSync(photoPath(file), body, { flag: 'wx' }); + const photo = replacePhoto(user.id, id, file, mime, body.length, photoMeta(req)); + if (!photo) { + unlink(file); + return reply.status(404).send({ error: 'photo not found' }); + } + if (previous && previous.file !== file) unlink(previous.file); + return reply.status(200).send({ photo }); + }, +); + // A profile picture is the same deal as a photo: raw bytes, sniffed, written // under a server-generated name. The picture it replaces goes with it. app.post('/api/auth/avatar', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => { diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 87aabd8..3796a5e 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -464,6 +464,10 @@ export function Workspace() { async (file: File) => { setError(null); setBusy(t('auth.busy')); + // A file from the disk is nobody's saved frame: SAVE PHOTO must add one, + // not overwrite whichever of the folder's own was open before. Opening one + // of those sets the id back, after this call. + setOpenPhotoId(null); try { const bytes = await fileToBytes(file); await adoptPhoto(bytes, file.name, file); @@ -797,7 +801,10 @@ export function Workspace() { setError(t('save.photoNoSource')); return; } - if (myPhotos.length >= MAX_PHOTOS) { + // Re-saving the frame already on the stage replaces its own row, so the cap + // only stands in the way of a new one. + const open = myPhotos.find((p) => p.id === openPhotoId); + if (!open && myPhotos.length >= MAX_PHOTOS) { setError(t('save.photoFull', { max: MAX_PHOTOS })); return; } @@ -828,17 +835,28 @@ export function Workspace() { const file = new File([out.bytes as BlobPart], `${strip.tag.slice(1).toLowerCase() || 'photo'}.jpg`, { type: 'image/jpeg', }); - const { photo } = await api.uploadPhoto(file, strip, { recipe: photoSettings, consent: shareToStrip }); - // The list entry carries the recipe we just sent, so reopening works - // before the next /photos/mine refresh. - setMyPhotos((prev) => [{ ...photo, recipe: photoSettings }, ...prev]); - setOpenPhotoId(photo.id); + if (open) { + // The frame on the stage is one of the folder's own: it keeps its id and + // its place, and the look it carried before this save lands in history. + // Its consent is the one it already had — a re-save is not a re-answer. + const { photo } = await api.replacePhoto(open.id, file, strip, { + recipe: photoSettings, + consent: open.consent !== false, + }); + setMyPhotos((prev) => prev.map((p) => (p.id === open.id ? { ...p, ...photo } : p))); + } else { + const { photo } = await api.uploadPhoto(file, strip, { recipe: photoSettings, consent: shareToStrip }); + // The list entry carries the recipe we just sent, so reopening works + // before the next /photos/mine refresh. + setMyPhotos((prev) => [{ ...photo, recipe: photoSettings }, ...prev]); + setOpenPhotoId(photo.id); + } } catch (err) { setError(t('save.photoFailed', { msg: err instanceof Error ? err.message : String(err) })); } finally { setSavingPhoto(false); } - }, [user, engine, source, myPhotos.length, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, strip, photoSettings, shareToStrip, t]); + }, [user, engine, source, myPhotos, openPhotoId, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, strip, photoSettings, shareToStrip, t]); // Reopen one of this account's saved frames: the bytes come back as a File // through the same door an upload uses, then the stored look goes on top. @@ -867,16 +885,18 @@ export function Workspace() { [loadFile, applyRecipe, t] ); - // The saved frame's own switch. The reel reads `consent`, so the change is - // live as soon as the store answers it. + // The saved frame's own switch. The reel reads `consent`, so the tick answers + // the click at once; only the store's refusal (a failed call) takes it back. const togglePhotoConsent = useCallback(async (photo: Photo | undefined) => { if (!photo) return; const next = photo.consent === false; + const mark = (value: boolean) => + setMyPhotos((prev) => prev.map((p) => (p.id === photo.id ? { ...p, consent: value } : p))); + mark(next); try { await api.setPhotoConsent(photo.id, next); - setMyPhotos((prev) => prev.map((p) => (p.id === photo.id ? { ...p, consent: next } : p))); } catch { - // Leave the old value; the next load states the truth. + mark(!next); } }, []); @@ -1197,6 +1217,10 @@ export function Workspace() { }, ]; + // The folder's frame standing on the stage, if any: its own column shows the + // looks it carried before its last saves and its landing-strip consent. + const openPhoto = myPhotos.find((p) => p.id === openPhotoId); + // The main chip row of the active tab, exactly the phone's per-tab sets. const chips: ChipDef[] = (() => { switch (tab) { @@ -1231,9 +1255,10 @@ export function Workspace() { onClick: () => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name }), })); case 'photos': { - // MY PHOTOS: the account's own saved frames, newest first, plus the two - // consent switches. Opening one reopens it for a further edit. - const open = myPhotos.find((p) => p.id === openPhotoId); + // MY PHOTOS: the account's own saved frames, newest first, plus the + // switch that decides how a NEW save lands. Opening one reopens it for a + // further edit; the open frame's own history and consent live in its own + // column (see the render). const rows: ChipDef[] = [ { key: 'strip-next', @@ -1242,14 +1267,6 @@ export function Workspace() { onClick: () => setShareToStrip((v) => !v), }, ]; - if (open) { - rows.push({ - key: 'strip-open', - label: t(open.consent === false ? 'myp.thisOff' : 'myp.thisOn'), - active: open.consent !== false, - onClick: () => void togglePhotoConsent(open), - }); - } if (myPhotos.length === 0) { rows.push({ key: 'no-photos', label: t('myp.empty'), disabled: true, onClick: () => {} }); } else { @@ -1468,6 +1485,37 @@ export function Workspace() { ) : null} + {/* MY PHOTOS' own column, for the frame standing on the stage: the + looks it carried before its last saves (newest first, three kept) + and the switch that puts it on the landing film strip. */} + {tab === 'photos' && openPhoto ? ( +
+ {openPhoto.history?.length ? ( + {} }, + ...openPhoto.history.map((look, i) => ({ + key: `hist-${i}`, + label: look.name || `#${i + 1}`, + onClick: () => applyRecipe(look), + })), + ]} + /> + ) : ( +

{t('myp.historyEmpty')}

+ )} + +
+ ) : null} + {/* column 4 — the open ruler, sliders stacked */} {slider ? (
diff --git a/docker/frontend/src/api.ts b/docker/frontend/src/api.ts index 9e711f9..9dd074e 100644 --- a/docker/frontend/src/api.ts +++ b/docker/frontend/src/api.ts @@ -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(path: string, init?: RequestInit): Promise { 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> { @@ -134,11 +155,17 @@ async function readJson(res: Response): Promise<{ error?: string } & Record { +async function uploadBytes( + url: string, + file: File, + maxDim: number, + maxBytes: number, + method: 'POST' | 'PUT' = 'POST', +): Promise { 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 }) }), diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index c48e1f1..3823ccd 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -122,6 +122,9 @@ export const en: Dict = { 'myp.thisOn': 'THIS PHOTO: FILM STRIP', 'myp.thisOff': 'THIS PHOTO: PRIVATE', 'myp.empty': 'NO SAVED PHOTOS YET.', + 'myp.history': 'HISTORY', + 'myp.historyEmpty': 'NO EARLIER SAVES YET.', + 'myp.consent': 'Allow this photo on the landing page', 'photo.contribute': 'CONTRIBUTE A PHOTO', 'photo.uploading': 'UPLOADING…', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 22a3c33..a5efc6a 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -130,6 +130,9 @@ export const vi = { 'myp.thisOn': 'ẢNH NÀY: LÊN DẢI PHIM', 'myp.thisOff': 'ẢNH NÀY: RIÊNG TƯ', 'myp.empty': 'CHƯA LƯU ẢNH NÀO.', + 'myp.history': 'LỊCH SỬ', + 'myp.historyEmpty': 'CHƯA CÓ MỐC LƯU NÀO TRƯỚC ĐÓ.', + 'myp.consent': 'Cho phép ảnh này lên landing page', 'photo.contribute': 'ĐÓNG GÓP ẢNH', 'photo.uploading': 'ĐANG TẢI…', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 4690fa1..81eb2b4 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -116,6 +116,16 @@ gap: 6px; } .col-sub .chip { background: var(--bg); } +/* The consent tick in MY PHOTOS' history column: a plain checkbox, not a chip — + it is a permission, and it reads as one. */ +.consent { + display: flex; align-items: flex-start; gap: 7px; + margin-top: 10px; padding-top: 10px; + border-top: 1px solid var(--border); + font-size: 12px; line-height: 1.4; color: var(--text-dim); + cursor: pointer; +} +.consent input { flex: 0 0 auto; margin: 1px 0 0; accent-color: var(--accent); } .chip[aria-pressed="true"], .chip.on { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); } /* Edited value, strip closed: the text glows amber, the border does not. */ .chip.amber, .chip.amber .val { color: var(--accent); }