144 lines
5.4 KiB
TypeScript
144 lines
5.4 KiB
TypeScript
import type { CropRatio, CropRect, FrameId, GPSInfo, Recipe } from '../../shared/types';
|
|
|
|
// Studio session: the working photo plus every knob the workspace holds, so a
|
|
// reload resumes exactly where the user left off — guest or signed in (the
|
|
// login cookie already outlives the tab; this covers the rest).
|
|
//
|
|
// ponytail: one session per browser, not per account — two accounts in the same
|
|
// browser share the working photo. Key it by user id if that ever matters.
|
|
const STATE_KEY = 'rc.studio.v1';
|
|
const DB_NAME = 'recipescam-studio';
|
|
const STORE = 'session';
|
|
const PHOTO_KEY = 'photo';
|
|
|
|
export interface StudioState {
|
|
recipe?: Recipe;
|
|
simId?: string | null;
|
|
frameId?: FrameId;
|
|
framePanX?: number;
|
|
crop?: CropRatio;
|
|
cropRect?: CropRect;
|
|
// false = the frame is on screen but not accepted yet (still draggable).
|
|
cropApplied?: boolean;
|
|
rotation?: 0 | 90 | 180 | 270;
|
|
// FRAME's STRAIGHTEN: degrees off the quarter turn, -180..180.
|
|
straighten?: number;
|
|
// FRAME's two mirrors. Absent in older sessions -> no flip.
|
|
flipH?: boolean;
|
|
flipV?: boolean;
|
|
gps?: GPSInfo | null;
|
|
gpsManual?: { lat: string; lng: string };
|
|
place?: string;
|
|
useGeotag?: boolean;
|
|
// size/x/y are the GPS box on the photo; older sessions hold only the colour
|
|
// and the two switches and take the defaults for the rest. font is the family
|
|
// the FONT strip picked, null for the bundled face.
|
|
gpsStyle?: { color: string; showName: boolean; showTime: boolean; size?: number; x?: number; y?: number; font?: string | null };
|
|
markText?: string;
|
|
markOn?: boolean;
|
|
// x/y are fractions of the stamp the mark is drawn on (the drag box writes
|
|
// them straight back); older sessions hold no x/y and take the default.
|
|
markStyle?: { color: string; size: number; x?: number; y?: number; font?: string | null };
|
|
}
|
|
|
|
export function loadState(): StudioState {
|
|
try {
|
|
const raw = localStorage.getItem(STATE_KEY);
|
|
return raw ? (JSON.parse(raw) as StudioState) : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
export function saveState(state: StudioState): void {
|
|
try {
|
|
localStorage.setItem(STATE_KEY, JSON.stringify(state));
|
|
} catch {
|
|
// Quota or private mode: the studio still works, it just forgets.
|
|
}
|
|
}
|
|
|
|
// --- the photo: IndexedDB, because a 12MP JPEG does not fit in localStorage ---
|
|
// The version is not this file's to name: the frame store shares this database
|
|
// and raises it (see `customFrame.ts`, which adds `custom_frames` and keeps this
|
|
// store on the way). Asking for a number of our own is a gate we fail at the
|
|
// moment the other store has gone past it — `VersionError`, on every read and
|
|
// every write, and a session photo that silently never survives a reload.
|
|
function openDb(): Promise<IDBDatabase> {
|
|
return new Promise((resolve, reject) => {
|
|
const req = indexedDB.open(DB_NAME);
|
|
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
async function idb<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest): Promise<T> {
|
|
const db = await openDb();
|
|
try {
|
|
return await new Promise<T>((resolve, reject) => {
|
|
const req = run(db.transaction(STORE, mode).objectStore(STORE));
|
|
req.onsuccess = () => resolve(req.result as T);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
export async function savePhoto(name: string, bytes: Uint8Array): Promise<void> {
|
|
try {
|
|
await idb('readwrite', (s) => s.put({ name, bytes }, PHOTO_KEY));
|
|
} catch {
|
|
// Private mode / no IndexedDB: the photo just will not survive a reload.
|
|
}
|
|
}
|
|
|
|
export async function loadPhoto(): Promise<{ name: string; bytes: Uint8Array } | null> {
|
|
try {
|
|
const row = await idb<{ name: string; bytes: Uint8Array } | undefined>('readonly', (s) => s.get(PHOTO_KEY));
|
|
return row && row.bytes instanceof Uint8Array ? { name: row.name, bytes: row.bytes } : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// CLEAR takes the working photo off the stage but keeps the knobs: the look was
|
|
// the user's work and outlives the picture it was made on. The session's own
|
|
// copy has to go with it, or the next reload would put the frame straight back.
|
|
export async function forgetPhoto(): Promise<void> {
|
|
try {
|
|
await idb('readwrite', (s) => s.delete(PHOTO_KEY));
|
|
} catch {
|
|
// Private mode / no IndexedDB: the photo just will not survive a reload.
|
|
}
|
|
}
|
|
|
|
// Signing out ends the session: drop the knobs and the working photo, so the
|
|
// next visit — guest or another account — opens the studio empty. The account's
|
|
// own photos stay in MY PHOTOS, ready to reopen.
|
|
//
|
|
// Async on purpose: the caller reloads the moment this returns, and a reload
|
|
// aborts a delete still in flight. Resolve on the transaction's completion, not
|
|
// on the request's success, or the bytes outlive the logout.
|
|
export async function clearSession(): Promise<void> {
|
|
try {
|
|
localStorage.removeItem(STATE_KEY);
|
|
} catch {
|
|
// Nothing to clear.
|
|
}
|
|
try {
|
|
const db = await openDb();
|
|
await new Promise<void>((resolve, reject) => {
|
|
const tx = db.transaction(STORE, 'readwrite');
|
|
tx.objectStore(STORE).delete(PHOTO_KEY);
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
tx.onabort = () => reject(tx.error);
|
|
});
|
|
db.close();
|
|
} catch {
|
|
// Private mode / no IndexedDB: there is nothing stored to forget.
|
|
}
|
|
}
|