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:
+70
-22
@@ -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() {
|
||||
</div>
|
||||
) : 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 ? (
|
||||
<div className="col col-sub strip" data-col="history">
|
||||
{openPhoto.history?.length ? (
|
||||
<ChipRow
|
||||
chips={[
|
||||
{ key: 'hist-title', label: t('myp.history'), disabled: true, onClick: () => {} },
|
||||
...openPhoto.history.map((look, i) => ({
|
||||
key: `hist-${i}`,
|
||||
label: look.name || `#${i + 1}`,
|
||||
onClick: () => applyRecipe(look),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<p className="hint">{t('myp.historyEmpty')}</p>
|
||||
)}
|
||||
<label className="consent">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-key="strip-open"
|
||||
checked={openPhoto.consent !== false}
|
||||
onChange={() => void togglePhotoConsent(openPhoto)}
|
||||
/>
|
||||
<span>{t('myp.consent')}</span>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* column 4 — the open ruler, sliders stacked */}
|
||||
{slider ? (
|
||||
<div className="col col-slider" data-col="slider">
|
||||
|
||||
+40
-13
@@ -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 }) }),
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user