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
+70 -22
View File
@@ -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">