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); }