Persist the studio session across reloads
The working photo and every knob the workspace holds now survive a reload, for guests as much as for signed-in users: - engine/session.ts: the knobs go to localStorage (rc.studio.v1) as small JSON; the photo goes to IndexedDB, because a 12MP JPEG does not fit in localStorage. Both fail soft (private mode, quota) — the studio still works, it just forgets. - App.tsx: state seeds from the stored snapshot synchronously, so the first paint already holds the user's settings; the boot effect pulls the photo back and adopts it with keepGeo, so the restored params are not clobbered by the photo's own EXIF. Recipes a guest creates with SAVE RECIPE stay session-only, as asked — they are still gone on reload (create-test asserts it).
This commit is contained in:
+63
-23
@@ -9,6 +9,7 @@ import { useI18n } from './i18n/I18nProvider';
|
||||
import { loadEngine } from './engine/assets';
|
||||
import { renderPhoto, type RenderAssets } from './engine/exportEngine';
|
||||
import { download, fileToBytes, readGps, resizedJpeg } from './engine/imageOps';
|
||||
import { loadPhoto, loadState, savePhoto, saveState } from './engine/session';
|
||||
import { api, type SavedRecipe, type User } from './api';
|
||||
import { TIERS } from './config/tiers';
|
||||
import { DEFAULT_ADJUSTMENTS, DEFAULT_RECIPES, FILM_SIMS, filmSimToRecipe } from '../shared/utils/defaultRecipes';
|
||||
@@ -85,6 +86,11 @@ interface LoadedSource {
|
||||
export function Workspace() {
|
||||
const { t } = useI18n();
|
||||
|
||||
// Session restore: read once, synchronously, so the first paint already holds
|
||||
// the user's own settings instead of a flash of the defaults. The photo comes
|
||||
// back from IndexedDB in the boot effect below.
|
||||
const session = useMemo(loadState, []);
|
||||
|
||||
const [engine, setEngine] = useState<{ assets: RenderAssets; gpu: boolean } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
@@ -98,25 +104,35 @@ export function Workspace() {
|
||||
...DEFAULT_RECIPES[0],
|
||||
id: 'web-current',
|
||||
name: DEFAULT_RECIPES[0]?.name ?? 'PROVIA',
|
||||
...session.recipe,
|
||||
}));
|
||||
const [simId, setSimId] = useState<string | null>(null);
|
||||
const [frameId, setFrameId] = useState<FrameId>('none');
|
||||
const [crop, setCrop] = useState<Exclude<CropRatio, 'free'>>('none');
|
||||
const [rotation, setRotation] = useState<0 | 90 | 180 | 270>(0);
|
||||
const [simId, setSimId] = useState<string | null>(() => session.simId ?? null);
|
||||
const [frameId, setFrameId] = useState<FrameId>(() => session.frameId ?? 'none');
|
||||
const [crop, setCrop] = useState<Exclude<CropRatio, 'free'>>(() => session.crop ?? 'none');
|
||||
const [rotation, setRotation] = useState<0 | 90 | 180 | 270>(() => session.rotation ?? 0);
|
||||
const [openParam, setOpenParam] = useState<string | null>(null);
|
||||
const [openGroup, setOpenGroup] = useState<GroupKey | null>(null);
|
||||
const [showRecipes, setShowRecipes] = useState(false);
|
||||
const [tab, setTab] = useState<TabId>('presets');
|
||||
const [peek, setPeek] = useState(false);
|
||||
|
||||
const [gps, setGps] = useState<GPSInfo | null>(null);
|
||||
const [gpsManual, setGpsManual] = useState({ lat: '', lng: '' });
|
||||
const [place, setPlace] = useState('');
|
||||
const [useGeotag, setUseGeotag] = useState(false);
|
||||
const [gpsStyle, setGpsStyle] = useState({ color: '#f59e0b', showName: true, showTime: true });
|
||||
const [markText, setMarkText] = useState('');
|
||||
const [markOn, setMarkOn] = useState(false);
|
||||
const [markStyle, setMarkStyle] = useState({ color: '#ffffff', size: 1 });
|
||||
const [gps, setGps] = useState<GPSInfo | null>(() => session.gps ?? null);
|
||||
const [gpsManual, setGpsManual] = useState(() => session.gpsManual ?? { lat: '', lng: '' });
|
||||
const [place, setPlace] = useState(() => session.place ?? '');
|
||||
const [useGeotag, setUseGeotag] = useState(() => session.useGeotag ?? false);
|
||||
const [gpsStyle, setGpsStyle] = useState(
|
||||
() => session.gpsStyle ?? { color: '#f59e0b', showName: true, showTime: true }
|
||||
);
|
||||
const [markText, setMarkText] = useState(() => session.markText ?? '');
|
||||
const [markOn, setMarkOn] = useState(() => session.markOn ?? false);
|
||||
const [markStyle, setMarkStyle] = useState(() => session.markStyle ?? { color: '#ffffff', size: 1 });
|
||||
|
||||
// Every knob the workspace holds, written back on change — the reload half of
|
||||
// the session. Small JSON, so no debounce is worth the risk of losing the last
|
||||
// edit; the photo has its own store (engine/session.ts).
|
||||
useEffect(() => {
|
||||
saveState({ recipe, simId, frameId, crop, rotation, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle });
|
||||
}, [recipe, simId, frameId, crop, rotation, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle]);
|
||||
|
||||
const [undo, setUndo] = useState<Recipe[]>([]);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
@@ -146,6 +162,18 @@ export function Workspace() {
|
||||
.me()
|
||||
.then((r) => alive && setUser(r.user))
|
||||
.catch(() => undefined); // signed out is a valid state — the demo needs no account
|
||||
// ...and the photo half of the session, back out of IndexedDB.
|
||||
loadPhoto()
|
||||
.then((photo) => {
|
||||
if (!alive || !photo) return;
|
||||
return adoptPhoto(
|
||||
photo.bytes,
|
||||
photo.name,
|
||||
new Blob([photo.bytes as BlobPart], { type: 'image/jpeg' }),
|
||||
true
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
@@ -202,29 +230,41 @@ export function Workspace() {
|
||||
}, [engine, previewBytes, recipe, frameId, crop, rotation, useGeotag, gps, gpsStyle, markOn, markText, markStyle]);
|
||||
|
||||
// --- input ---------------------------------------------------------------
|
||||
// One photo, two ways in: a drop/pick (which also stores it for the next
|
||||
// visit) and the session restore, whose params are already in state and so
|
||||
// must not be overwritten by the photo's own EXIF.
|
||||
const adoptPhoto = useCallback(
|
||||
async (bytes: Uint8Array, name: string, blob: Blob, keepGeo = false) => {
|
||||
const preview = await resizedJpeg(bytes, PREVIEW_MAX);
|
||||
const found = await readGps(bytes);
|
||||
setSource((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev.url);
|
||||
return { bytes, name, url: URL.createObjectURL(blob) };
|
||||
});
|
||||
setPreviewBytes(preview);
|
||||
setGps(found);
|
||||
if (keepGeo) return;
|
||||
setUseGeotag(!!found);
|
||||
if (!found) setGpsManual({ lat: '', lng: '' });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const loadFile = useCallback(
|
||||
async (file: File) => {
|
||||
setError(null);
|
||||
setBusy(t('auth.busy'));
|
||||
try {
|
||||
const bytes = await fileToBytes(file);
|
||||
const preview = await resizedJpeg(bytes, PREVIEW_MAX);
|
||||
const found = await readGps(bytes);
|
||||
setSource((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev.url);
|
||||
return { bytes, name: file.name, url: URL.createObjectURL(file) };
|
||||
});
|
||||
setPreviewBytes(preview);
|
||||
setGps(found);
|
||||
setUseGeotag(!!found);
|
||||
if (!found) setGpsManual({ lat: '', lng: '' });
|
||||
await adoptPhoto(bytes, file.name, file);
|
||||
void savePhoto(file.name, bytes);
|
||||
} catch {
|
||||
setError(t('err.decode'));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[t]
|
||||
[t, adoptPhoto]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user