import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ToolRail, type TabId } from './ui/ToolRail'; import { ChipRow, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColumn'; import { ImageStage } from './ui/ImageStage'; import { TopBar } from './ui/TopBar'; import { AuthModal } from './ui/AuthModal'; import { NameModal } from './ui/NameModal'; import { DeletePhotoModal } from './ui/DeletePhotoModal'; import { RecipeCreatePanel, type RecipeDraft } from './ui/RecipeCreatePanel'; import { useI18n } from './i18n/I18nProvider'; import { loadEngine } from './engine/assets'; import { renderPhoto, type RenderAssets } from './engine/exportEngine'; import { download, fileToBytes, readGps, readIso, resizedJpeg } from './engine/imageOps'; import { loadPhoto, loadState, savePhoto, saveState } from './engine/session'; import { api, type Photo, type SavedRecipe, type User } from './api'; import { TIERS } from './config/tiers'; import { DEFAULT_ADJUSTMENTS, DEFAULT_RECIPES, FILM_SIMS, filmSimToRecipe } from '../shared/utils/defaultRecipes'; import { PARAM_DEFS, type ContinuousParamDef } from '../shared/utils/paramDefs'; import { exportRecipeXml, importRecipeXml } from '../shared/utils/recipeShare'; import { writeJpegExif } from '../shared/utils/exifWrite'; import { CROP_W_H, DEFAULT_CROP_RECT, type CropRatio, type CropRect, type FrameId, type GPSInfo, type Recipe, } from '../shared/types'; import type { MsgKey } from './i18n/vi'; // Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there. const MAX_PHOTOS = 12; // Preview renders on a downscaled copy; export always re-renders the original // bytes, so nothing shown here is baked into the file. const PREVIEW_MAX = 1600; // A committed crop keeps only a fraction of the photo, so the 1600px copy // leaves too few pixels behind and the result reads as blocky once the stage // scales it up. While a crop is live the source is re-derived larger, inverted // by that fraction. The same ceiling bounds a zoomed-in stage, and 3200px is // what the decode can afford: past it a wheel gesture stops keeping up. const PREVIEW_HARD_MAX = 3200; const CROP_PREVIEW_MIN_FRAC = 0.05; // Zoom asks for a bigger copy; quantised so a smooth gesture does not re-cut it // on every notch. const PREVIEW_QUANTUM = 512; const PREVIEW_DEBOUNCE_MS = 120; const FRAMES: { id: FrameId; label: MsgKey }[] = [ { id: 'none', label: 'frame.none' }, { id: 'polaroid', label: 'frame.polaroid' }, { id: 'classic-white', label: 'frame.classic' }, { id: 'cinematic', label: 'frame.cinematic' }, { id: 'wallframe', label: 'frame.wall' }, { id: 'wallframe-landscape', label: 'frame.wallLand' }, ]; // FREE draws the frame on the canvas and lets the user drag its corners, so // every ratio is offered here — no ratio is applied until APPLY. const CROPS: CropRatio[] = ['none', 'free', '1:1', '2:3', '3:2', '3:4', '4:3', '16:9']; const TEMP_PRESETS: { label: string; kelvin: number }[] = [ { label: 'AUTO', kelvin: 5500 }, { label: 'DAYLIGHT', kelvin: 5600 }, { label: 'DAYLIGHT -3R', kelvin: 5800 }, { label: 'CLOUDY', kelvin: 6500 }, { label: 'SHADE', kelvin: 7500 }, { label: 'TUNGSTEN', kelvin: 3200 }, { label: 'FLUOR', kelvin: 4000 }, ]; // Kelvin -> sRGB (Tanner Helland's approximation, what every WB widget uses): // 2500K reads warm orange, 5500K a near-neutral white, 10000K blue. function kelvinToHex(k: number): string { const t = Math.min(40000, Math.max(1000, k)) / 100; const chan = (v: number) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, '0'); const r = t <= 66 ? 255 : 329.698727446 * (t - 60) ** -0.1332047592; const g = t <= 66 ? 99.4708025861 * Math.log(t) - 161.1195681661 : 288.1221695283 * (t - 60) ** -0.0755148492; const b = t >= 66 ? 255 : t <= 19 ? 0 : 138.5177312231 * Math.log(t - 10) - 305.0447927307; return `#${chan(r)}${chan(g)}${chan(b)}`; } // HSL -> #rrggbb (CSS keeps hsl() opaque to a test, and the swatch prints its // own hex, so the panel hands out one format). function hslToHex(h: number, s: number, l: number): string { const k = (n: number) => (n + h / 30) % 12; const a = (s / 100) * Math.min(l / 100, 1 - l / 100); const chan = (n: number) => Math.max(0, Math.min(255, Math.round(255 * (l / 100 - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)))))) .toString(16) .padStart(2, '0'); return `#${chan(0)}${chan(8)}${chan(4)}`; } // The knobs that drive a colour get a swatch under their ruler, so the hue is // visible next to the number. TINT runs green (-10) through a neutral grey (0) // to magenta (+10) — the axis every WB tool labels G..M. const SLIDER_SWATCH: Partial string>> = { temperature: kelvinToHex, tint: (v) => hslToHex(140 + ((v + 10) / 20) * 160, Math.abs(v) * 7, 58 + (10 - Math.abs(v)) * 3), }; // The option strips, one open at a time (phone's GroupKey set minus the groups // the web engine has no knob for). type GroupKey = 'cx' | 'cxb' | 'dr' | 'crop' | 'rotate' | 'style' | 'temp' | 'wm' | 'wmColor' | 'wmSize' | 'gpsColor' | 'gpsName' | 'gpsTime'; const WM_SUBGROUPS: GroupKey[] = ['wmColor', 'wmSize', 'gpsColor', 'gpsName', 'gpsTime']; const isWmPanel = (g: GroupKey | null) => g === 'wm' || (g !== null && WM_SUBGROUPS.includes(g)); // A slider row opened from a strip returns to it with "<" (COLOR TEMP -> TEMP). const PARAM_GROUP: Partial> = { temperature: 'temp' }; const CHROME_OPTIONS = [ { v: 'none', d: 'OFF' }, { v: 'weak', d: 'WEAK' }, { v: 'strong', d: 'STRONG' }, ]; const DR_OPTIONS = [ { v: 'auto', d: 'AUTO' }, { v: '100', d: 'DR100' }, { v: '200', d: 'DR200' }, { v: '400', d: 'DR400' }, ]; const WM_COLORS = [ { v: '#ffffff', d: 'WHITE' }, { v: '#f59e0b', d: 'AMBER' }, { v: '#000000', d: 'BLACK' }, ]; const WM_SIZES = [ { v: '1', d: '1x' }, { v: '1.5', d: '1.5x' }, { v: '2', d: '2x' }, ]; interface LoadedSource { bytes: Uint8Array; name: string; url: string; } // Everything a single UNDO step puts back. The recipe alone was not enough: // frame, crop, rotation, marks and geotag all change the photo too. interface Look { recipe: Recipe; simId: string | null; frameId: FrameId; crop: CropRatio; cropRect: CropRect; cropApplied: boolean; rotation: 0 | 90 | 180 | 270; markOn: boolean; markText: string; markStyle: { color: string; size: number }; useGeotag: boolean; gpsStyle: { color: string; showName: boolean; showTime: boolean }; } 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(null); const [busy, setBusy] = useState(null); const [source, setSource] = useState(null); const [previewBytes, setPreviewBytes] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const [recipe, setRecipe] = useState(() => ({ ...DEFAULT_RECIPES[0], id: 'web-current', name: DEFAULT_RECIPES[0]?.name ?? 'PROVIA', ...session.recipe, })); const [simId, setSimId] = useState(() => session.simId ?? null); const [frameId, setFrameId] = useState(() => session.frameId ?? 'none'); const [crop, setCrop] = useState(() => session.crop ?? 'none'); const [cropRect, setCropRect] = useState(() => session.cropRect ?? DEFAULT_CROP_RECT); const [cropApplied, setCropApplied] = useState(() => session.cropApplied ?? false); const [photoDims, setPhotoDims] = useState<{ w: number; h: number } | null>(null); const [rotation, setRotation] = useState<0 | 90 | 180 | 270>(() => session.rotation ?? 0); const [openParam, setOpenParam] = useState(null); const [openGroup, setOpenGroup] = useState(null); const [showRecipes, setShowRecipes] = useState(false); const [tab, setTab] = useState('presets'); const [peek, setPeek] = useState(false); const [gps, setGps] = useState(() => session.gps ?? null); // ISO off the loaded photo's EXIF; null means the strip prints AUTO. const [iso, setIso] = useState(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, cropRect, cropApplied, rotation, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle }); }, [recipe, simId, frameId, crop, cropRect, cropApplied, rotation, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle]); // Crop has three states: none, framed-but-uncommitted (the frame is draggable // and the whole photo is shown), and applied (the crop alone is rendered). // What the stage says it is painting at; PREVIEW_MAX until it reports. const [zoomNeedPx, setZoomNeedPx] = useState(PREVIEW_MAX); const onStageZoom = useCallback((longDevicePx: number) => { setZoomNeedPx( Math.max(PREVIEW_MAX, Math.min(PREVIEW_HARD_MAX, Math.ceil(longDevicePx / PREVIEW_QUANTUM) * PREVIEW_QUANTUM)) ); }, []); const cropEditing = crop !== 'none' && !cropApplied; const cropLive = crop !== 'none' && cropApplied; const cropWH = crop !== 'none' && crop !== 'free' ? CROP_W_H[crop] : null; // A committed crop keeps only part of the photo, so the 1600px copy leaves // too few pixels behind and the stage shows a blocky upscale. While the crop // is live the source is re-derived larger — 1/f, where f is the crop's // longest side as a fraction of the photo's, capped so the decode stays // bounded. cropRect is in the rotated frame, so the shape is swapped first. const cropLongFrac = (() => { const d = photoDims; const turn = rotation % 180 !== 0; const f = Math.max( (turn ? d?.h ?? 0 : d?.w ?? 0) * cropRect.w, (turn ? d?.w ?? 0 : d?.h ?? 0) * cropRect.h ); const long = Math.max(d?.w ?? 0, d?.h ?? 0); return long > 0 ? f / long : Math.min(cropRect.w, cropRect.h); })(); const previewMax = Math.min( PREVIEW_HARD_MAX, Math.max( zoomNeedPx, cropLive ? Math.max(PREVIEW_MAX, Math.round(PREVIEW_MAX / Math.max(cropLongFrac, CROP_PREVIEW_MIN_FRAC))) : PREVIEW_MAX ) ); // --- undo / redo ---------------------------------------------------------- // The stack holds whole looks. remember() reads a ref written during render, // so it is stable and always snapshots the state as of the last painted // frame — the same value the user is looking at when they press the button. // The floor is the look the session loaded with; REDO holds what UNDO walked // back over, up to the newest edit, and a fresh edit drops that branch. const [undo, setUndo] = useState([]); const [redo, setRedo] = useState([]); // Which look of the open frame's own history is standing on the stage, if one // was picked from its column; a fresh edit or another frame clears it. const [histPick, setHistPick] = useState(null); // FX's MONOCHROME switch remembers what the photo was wearing, so flipping it // back returns that filter — and its sim name — instead of guessing PROVIA. const monoPrev = useRef<{ baseFilter: Recipe['baseFilter']; simId: string | null; name: string } | null>(null); const lookRef = useRef(null); lookRef.current = { recipe, simId, frameId, crop, cropRect, cropApplied, rotation, markOn, markText, markStyle, useGeotag, gpsStyle }; // Twenty looks is enough to walk, but the oldest entry is the floor — the look // the session loaded with — and is never the one trimmed, so UNDO always // reaches the start of the session. const pushUndo = (snap: Look) => setUndo((stack) => (stack.length > 19 ? [stack[0], ...stack.slice(-18), snap] : [...stack, snap])); const remember = useCallback(() => { const snap = lookRef.current; if (!snap) return; pushUndo(snap); setRedo([]); // The look on the stage is no longer the one that was picked, so the // history column drops its mark. setHistPick(null); }, []); const restoreLook = useCallback((l: Look) => { setRecipe(l.recipe); setSimId(l.simId); setFrameId(l.frameId); setCrop(l.crop); setCropRect(l.cropRect); setCropApplied(l.cropApplied); setRotation(l.rotation); setMarkOn(l.markOn); setMarkText(l.markText); setMarkStyle(l.markStyle); setUseGeotag(l.useGeotag); setGpsStyle(l.gpsStyle); }, []); const defaultLook = useCallback((): Look => { const provia = FILM_SIMS.find((f) => f.id === 'sim-provia'); const cur = lookRef.current?.recipe ?? DEFAULT_RECIPES[0]; return { recipe: { ...(provia ? filmSimToRecipe(provia) : cur), id: cur.id, name: cur.name, isCustom: true }, simId: provia ? provia.id : null, frameId: 'none', crop: 'none', cropRect: DEFAULT_CROP_RECT, cropApplied: false, rotation: 0, markOn: false, markText: '', markStyle: { color: '#ffffff', size: 1 }, useGeotag: false, gpsStyle: { color: '#f59e0b', showName: true, showTime: true }, }; }, []); const [exporting, setExporting] = useState(false); const [savingPhoto, setSavingPhoto] = useState(false); const [user, setUser] = useState(null); const [saved, setSaved] = useState([]); // Bumped by RESET while CREATE is open: the form above the button listens for // it and drops back to its defaults. const [createReset, setCreateReset] = useState(0); // This account's own folder. The list drives the MY PHOTOS tab, which reopens // a saved frame with the settings it was stored with; the count labels the // save button. The API is the real cap — this only labels the button. const [myPhotos, setMyPhotos] = useState([]); // The saved photo standing on the stage, if any — its own consent chip shows. const [openPhotoId, setOpenPhotoId] = useState(null); // Recipes made in this session, guest ones included. A guest's copy lives // here and nowhere else, so reloading the page is what deletes it. const [created, setCreated] = useState([]); // Which save flow is asking for a name, if any: `photo` on the first filing // of an uploaded frame, `recipe` on SAVE RECENT. One modal, two callers. const [naming, setNaming] = useState<'photo' | 'recipe' | null>(null); // The saved photo whose removal is being confirmed, if any — its own history // goes with it, so the dialog says so and offers a download first. const [removing, setRemoving] = useState(null); // null = closed. /app?auth=1 opens the studio's dialog straight on the sign-in // tab (sign-in is the default; ?auth=signup asks for the other one); the // landing page runs the same dialog in place instead of linking here. const [authMode, setAuthMode] = useState<'login' | 'signup' | null>(() => { const asked = new URLSearchParams(window.location.search).get('auth'); return asked ? (asked === 'signup' ? 'signup' : 'login') : null; }); const renderToken = useRef(0); // Which previewMax the bytes in previewBytes were made at, so the upgrade // effect does not redo the load-time 1600px copy. const previewMaxRef = useRef(PREVIEW_MAX); const previewJobRef = useRef(0); // --- boot ----------------------------------------------------------------- useEffect(() => { let alive = true; loadEngine() .then((loaded) => { if (!alive) return; setEngine(loaded); if (!loaded.gpu) setError(t('err.noWebgl')); }) .catch((err) => alive && setError(String(err))); api .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; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (!user) { setSaved([]); setMyPhotos([]); return; } api.listRecipes().then((r) => setSaved(r.recipes)).catch(() => setSaved([])); api.listMyPhotos().then((r) => setMyPhotos(r.photos)).catch(() => setMyPhotos([])); }, [user]); // --- preview render ------------------------------------------------------- useEffect(() => { if (!engine || !previewBytes) return; const token = ++renderToken.current; const timer = setTimeout(async () => { setBusy(t('act.exporting')); try { const out = await renderPhoto({ source: previewBytes, recipe, // An uncommitted crop is framed on the bare photo, so the overlay // measures exactly what the engine will cut. frameId: cropEditing ? 'none' : frameId, useGeotag, gpsInfo: gps, assets: engine.assets, options: { wallframeLandscape: frameId === 'wallframe-landscape', sharpen: true, photoRotation: rotation, cropRect: cropLive ? cropRect : null, watermark: markOn && markText ? { text: markText, x: 0.5, y: 0.92, ...markStyle } : null, gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, ...gpsStyle } : null, }, }); if (token !== renderToken.current) return; if (!out) throw new Error('render returned null'); const url = URL.createObjectURL(new Blob([out.bytes as BlobPart], { type: 'image/jpeg' })); setPreviewUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return url; }); } catch (err) { if (token === renderToken.current) setError(t('err.generic', { msg: String(err) })); } finally { if (token === renderToken.current) setBusy(null); } }, PREVIEW_DEBOUNCE_MS); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [engine, previewBytes, recipe, frameId, cropEditing, cropLive, cropRect, rotation, useGeotag, gps, gpsStyle, markOn, markText, markStyle]); // The source copy behind previewBytes, re-made only when previewMax moves: // applying a crop upgrades it, CANCEL/UNDO brings the 1600px one back. useEffect(() => { if (!source || previewMaxRef.current === previewMax) return; const job = ++previewJobRef.current; resizedJpeg(source.bytes, previewMax) .then((bytes) => { if (job !== previewJobRef.current) return; previewMaxRef.current = previewMax; setPreviewBytes(bytes); }) .catch(() => undefined); }, [source, previewMax]); // --- 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. // A frame arriving on the stage replaces whatever the preview was holding. // The old render is thrown away FIRST, so the new one can never be painted // over it: the buffer is empty before the load starts. const clearPreview = useCallback(() => { setPreviewUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; }); }, []); const adoptPhoto = useCallback( async (bytes: Uint8Array, name: string, blob: Blob, keepGeo = false) => { const preview = await resizedJpeg(bytes, PREVIEW_MAX); const found = await readGps(bytes); const foundIso = await readIso(bytes); // The photo's own shape drives the default crop frame. Read off the // downscaled copy — same aspect, a fraction of the decode cost. const dims = await createImageBitmap(new Blob([preview as BlobPart], { type: 'image/jpeg' })) .then((bmp) => { const d = { w: bmp.width, h: bmp.height }; bmp.close(); return d; }) .catch(() => null); setPhotoDims(dims); setSource((prev) => { if (prev) URL.revokeObjectURL(prev.url); return { bytes, name, url: URL.createObjectURL(blob) }; }); // The new photo's copy is the 1600px one; a live crop or a zoomed stage // then upgrades it. previewMaxRef.current = PREVIEW_MAX; setZoomNeedPx(PREVIEW_MAX); previewJobRef.current++; setPreviewBytes(preview); setGps(found); setIso(foundIso); // A new photo starts a new history whose floor is the default look, so // UNDO walks all the way back and still leaves the photo loaded. REDO has // nothing to walk forward onto in a history that just began. setUndo([defaultLook()]); setRedo([]); if (keepGeo) return; setUseGeotag(!!found); if (!found) setGpsManual({ lat: '', lng: '' }); }, [defaultLook] ); const loadFile = useCallback( 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); void savePhoto(file.name, bytes); } catch { setError(t('err.decode')); } finally { setBusy(null); } }, [t, adoptPhoto] ); useEffect(() => { const onPaste = (e: ClipboardEvent) => { const file = e.clipboardData?.files?.[0]; if (file) void loadFile(file); }; window.addEventListener('paste', onPaste); return () => window.removeEventListener('paste', onPaste); }, [loadFile]); // --- recipe edits --------------------------------------------------------- const applyRecipe = useCallback( (next: Recipe) => { remember(); setRecipe(next); setSimId(next.isCustom ? null : next.id); // A recipe filed by SAVE RECENT carries the frame it was saved with; // a bundled preset carries none, and then the frame on the stage stays. const stored = (next as Recipe & { frameId?: FrameId }).frameId; if (stored) setFrameId(stored); }, [remember] ); const setAdjustment = useCallback( (patch: Partial) => { setRecipe((prev) => ({ ...prev, isCustom: true, adjustments: { ...prev.adjustments, ...patch } })); }, [] ); // A slider drag is one edit, not one per pixel: the first move after the // ruler opened takes the snapshot, the rest ride on it. const sliderEditRef = useRef(false); useEffect(() => { sliderEditRef.current = false; }, [openParam]); const setAdjustmentOnce = useCallback( (patch: Partial) => { if (!sliderEditRef.current) { sliderEditRef.current = true; remember(); } setAdjustment(patch); }, [remember, setAdjustment] ); // --- crop ----------------------------------------------------------------- // Picking a ratio only opens the frame; the crop is committed by APPLY. const pickCrop = useCallback( (ratio: CropRatio) => { remember(); setCrop(ratio); setCropApplied(false); if (ratio === 'none' || ratio === 'free' || !photoDims) return; // Largest centred rect of that shape the photo can hold. CROP_W_H is a // PIXEL aspect; in fractions (of the photo's width/height) it becomes // R = ratio / (w/h), so a 3:4 frame lands on a 3:4 photo and a square one // on anything else. const k = rotation % 180 === 0 ? photoDims.w / photoDims.h : photoDims.h / photoDims.w; const R = CROP_W_H[ratio] / k; const w = R >= 1 ? 1 : R; const h = R >= 1 ? 1 / R : 1; setCropRect({ x: (1 - w) / 2, y: (1 - h) / 2, w, h }); }, [remember, photoDims, rotation] ); const applyCrop = useCallback(() => setCropApplied(true), []); // One step back: the look standing now goes onto REDO and the stack's top // comes back. CANCEL and the UNDO button are the same move. const stepBack = useCallback(() => { const last = undo[undo.length - 1]; const cur = lookRef.current; if (!last || !cur) return; restoreLook(last); setUndo((stack) => stack.slice(0, -1)); setRedo((stack) => [...stack.slice(-19), cur]); }, [undo, restoreLook]); // CANCEL throws the framing away — the pick pushed the look it replaced, so // one undo is exactly "never mind". const cancelCrop = useCallback(() => stepBack(), [stepBack]); const reset = useCallback(() => { remember(); // Back to the startup look, exactly like the phone: the PROVIA sim, every // knob neutral, no frame/crop, no rotation, both marks off and restyled. restoreLook(defaultLook()); setOpenParam(null); setOpenGroup(null); setShowRecipes(false); }, [remember, restoreLook, defaultLook]); // UNDO walks back to the look the session loaded with; REDO walks forward // again over exactly what it stepped past, up to the newest edit. const undoLast = useCallback(() => stepBack(), [stepBack]); const redoLast = useCallback(() => { const next = redo[redo.length - 1]; const cur = lookRef.current; if (!next || !cur) return; restoreLook(next); setRedo((stack) => stack.slice(0, -1)); setUndo((stack) => [...stack, cur]); }, [redo, restoreLook]); // SAVE RECENT — its own tab, not a corner of CREATE RECIPES. The look standing // on the stage (sim, WB, light, FX and the frame) is filed under a name this // account has not used; the frame rides in the recipe's own JSON, so applying // the entry puts the whole look back, frame included. const saveRecipeNamed = useCallback( async (name: string) => { try { const look: Recipe & { frameId?: FrameId } = { ...recipe, name, frameId: cropEditing ? 'none' : frameId }; const { recipe: stored } = await api.createRecipe(name, look); setSaved((list) => [stored, ...list]); setRecipe((prev) => ({ ...prev, name })); } catch (err) { setError(t('err.generic', { msg: String(err) })); } }, [recipe, frameId, cropEditing, t] ); const saveRecipe = useCallback(() => { if (!user) { setAuthMode('login'); return; } setNaming('recipe'); }, [user]); // A recipe name is only free while this account has not spent it. Checked on // the field rather than at the store: the list is already here, whole. const recipeNameTaken = useCallback( (name: string): string | null => saved.some((entry) => entry.name.trim().toLowerCase() === name.toLowerCase()) ? t('name.duplicate') : null, [saved, t] ); const deleteRecipeEntry = useCallback( async (entry: SavedRecipe) => { try { await api.deleteRecipe(entry.id); setSaved((list) => list.filter((r) => r.id !== entry.id)); } catch (err) { setError(t('err.generic', { msg: String(err) })); } }, [t] ); // FAVORITED holds the account's own recipes. `*`, or dragging a recipe chip // onto the star in the rail, files the look on the stage there; a guest is // asked to sign in first, like every other write in the studio. const favoriteRecipe = useCallback( async (entry: Recipe) => { if (!user) { setAuthMode('login'); return; } try { const stored = await api.createRecipe(entry.name, { ...entry }); setSaved((list) => [stored.recipe, ...list]); setTab('favorited'); } catch (err) { setError(t('err.generic', { msg: String(err) })); } }, [user, t] ); // The star reads the id the dragged chip carried back to its recipe. const favoriteById = useCallback( (id: string) => { const entry = [...created, ...DEFAULT_RECIPES].find((r) => r.id === id); if (entry) void favoriteRecipe(entry); }, [created, favoriteRecipe] ); // `*` files the look on the stage without leaving the tab it was built in. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key !== '*' || e.ctrlKey || e.metaKey || e.altKey) return; const el = e.target as HTMLElement | null; if (el && (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable)) return; e.preventDefault(); void favoriteRecipe(recipe); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [favoriteRecipe, recipe]); // CREATE tab: a new recipe is applied at once and listed under RECIPES. A // signed-in account also stores it; a guest's copy stays in `created`, which // is exactly what "forgotten when you leave" means. const addCreatedRecipe = useCallback( async (draft: RecipeDraft) => { const entry: Recipe = { ...draft, id: `local-${Date.now()}`, isCustom: true }; setCreated((list) => [entry, ...list]); applyRecipe(entry); if (!user) return; try { const stored = await api.createRecipe(entry.name, entry); setSaved((list) => [stored.recipe, ...list]); } catch (err) { setError(t('err.generic', { msg: String(err) })); } }, [user, applyRecipe, t] ); // CREATE edits the photo live: each knob lands on the render as it is typed so // the recipe can be judged before saving. No undo entry — this is a draft, and // SAVE/EXPORT is what commits it. const previewCreatedRecipe = useCallback((draft: Pick) => { setRecipe((prev) => ({ ...prev, ...draft, isCustom: true })); }, []); // The phone's encrypted .recipe envelope (`shared/utils/recipeShare` is the // app's own file, byte-identical). Signed in only — a guest's recipe is never // written to disk. const exportRecipeFile = useCallback( (draft: RecipeDraft) => { if (!user) return; const xml = exportRecipeXml({ ...draft, id: 'share' }); download( new TextEncoder().encode(xml), `${(draft.name || 'recipe').replace(/\W+/g, '-').toLowerCase()}.recipe`, 'application/xml' ); }, [user] ); // IMPORT is the other half of EXPORT: the phone's .recipe file, or one saved // from another account. `shared/utils/recipeShare` is the app's own parser, so // the look arrives intact — a guest gets it for the session, like CREATE. const importRecipeFile = useCallback( async (file: File) => { setError(null); try { const parsed = importRecipeXml(await file.text()); const entry: Recipe = { ...parsed, id: `import-${Date.now()}`, isCustom: true }; setCreated((list) => [entry, ...list]); applyRecipe(entry); setFrameId(parsed.frameId ?? 'none'); setUseGeotag(!!parsed.useGeotag); setShowRecipes(true); } catch (err) { setError(t('err.generic', { msg: String(err) })); } }, [applyRecipe, t] ); // The picker is built on the spot: a second in the page // would compete with the stage's own for `input[type=file]`. const pickRecipeFile = useCallback(() => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.recipe,application/xml,text/xml'; input.onchange = () => { const file = input.files?.[0]; if (file) void importRecipeFile(file); }; input.click(); }, [importRecipeFile]); // The strip this look shows — the tagline off the recipe name, and the // technical line off the camera (EXIF ISO, or AUTO) plus the recipe's own // grain, warmth and monochrome state. Metadata only: an exported file keeps // clean pixels (the strip rides EXIF), and a saved photo stores it so the // landing reel can frame it the same way. const strip = useMemo(() => { const name = (recipe.name || 'RECIPE').trim(); const slug = name.replace(/[^\p{L}\p{N}]+/gu, '_').replace(/^_+|_+$/g, '').toUpperCase(); const grain = Math.max(0, Math.round(recipe.adjustments.grain ?? 0)); const warmth = Math.round((recipe.adjustments.temperature - 5500) / 250); const tail = recipe.baseFilter === 'monochrome' ? 'MONO' : `WARMTH ${warmth >= 0 ? '+' : ''}${warmth}`; return { tag: `#${slug || 'RECIPE'}`, title: name, meta: `ISO ${iso ?? 'AUTO'} · GRAIN ${grain} · ${tail}` }; }, [recipe.name, recipe.adjustments, recipe.baseFilter, iso]); // Everything needed to put this photo back on the stage: the recipe plus the // frame and geotag the stage held. EXIF and a saved photo both carry it. const photoSettings = useMemo( () => ({ ...recipe, frameId: cropEditing ? 'none' : frameId, useGeotag }), [recipe, frameId, cropEditing, useGeotag] ); const exportPhoto = useCallback(async () => { if (!engine || !source) return; setExporting(true); setError(null); try { const tier = TIERS[user ? 'user' : 'guest']; const src = tier.maxExportDim ? await resizedJpeg(source.bytes, tier.maxExportDim, 0.95) : source.bytes; const out = await renderPhoto({ source: src, recipe, // The file is the preview: an uncommitted frame is not exported. frameId: cropEditing ? 'none' : frameId, useGeotag, gpsInfo: gps, assets: engine.assets, options: { wallframeLandscape: frameId === 'wallframe-landscape', dpi: 300, sharpen: true, photoRotation: rotation, cropRect: cropLive ? cropRect : null, watermark: markOn && markText ? { text: markText, x: 0.5, y: 0.92, ...markStyle } : tier.watermark ? { text: tier.watermark, x: 0.5, y: 0.94, color: '#ffffff', size: 1 } : null, gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, ...gpsStyle } : null, }, }); if (!out) throw new Error('render returned null'); // Encode → JFIF density 300 (inside renderPhoto) → EXIF rebuild, the same // order the app uses. The source photo donates its camera/lens tags; the // recipe is stamped as metadata, never burned into the pixels. const bytes = writeJpegExif( out.bytes, { dateTime: new Date(), gps: gps ? { latitude: gps.latitude, longitude: gps.longitude } : null, width: out.width, height: out.height, dpi: 300, locationName: place || gps?.locality || null, imageDescription: strip.tag, userComment: JSON.stringify(photoSettings), }, source.bytes ); const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14); download(bytes, `recipescam_${(recipe.name || 'photo').replace(/\W+/g, '-').toLowerCase()}_${stamp}.jpg`); } catch (err) { setError(t('err.generic', { msg: String(err) })); } finally { setExporting(false); } }, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, place, user, t, strip, photoSettings]); // SAVE PHOTO: the stage as it stands, uploaded to this account's own folder. // The upload is left clean (no burned strip) so the landing reel can draw its // own `.lp-shot` frame around it; the labels are stored alongside instead. // The 12-photo cap is the API's, mirrored here so the button can say no. const savePhotoToFolder = useCallback(async (photoName?: string) => { if (!user) { setAuthMode('login'); return; } if (!engine || !source) { setError(t('save.photoNoSource')); return; } // 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; } // A frame being filed for the first time is asked for a name — the folder // keeps it, and the landing reel draws it as the frame's title. A re-save // keeps the name it already has, so it never asks twice. if (!open && photoName === undefined) { setNaming('photo'); return; } // A re-save keeps the name the frame already has — the folder's title, not // the recipe it happens to be standing in. const labels = { ...strip, title: open ? open.title ?? strip.title : (photoName ?? strip.title) }; setSavingPhoto(true); setError(null); try { const src = TIERS.user.maxExportDim ? await resizedJpeg(source.bytes, TIERS.user.maxExportDim, 0.95) : source.bytes; const out = await renderPhoto({ source: src, recipe, frameId: cropEditing ? 'none' : frameId, useGeotag, gpsInfo: gps, assets: engine.assets, options: { wallframeLandscape: frameId === 'wallframe-landscape', dpi: 300, sharpen: true, photoRotation: rotation, cropRect: cropLive ? cropRect : null, watermark: markOn && markText ? { text: markText, x: 0.5, y: 0.92, ...markStyle } : null, gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, ...gpsStyle } : null, }, }); if (!out) throw new Error('render returned null'); const file = new File([out.bytes as BlobPart], `${strip.tag.slice(1).toLowerCase() || 'photo'}.jpg`, { type: 'image/jpeg', }); 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, labels, { 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, labels, { recipe: photoSettings }); // 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); } // The stage now stands on the newest look of this frame, so no earlier // one in its history is the picked one. setHistPick(null); } catch (err) { setError(t('save.photoFailed', { msg: err instanceof Error ? err.message : String(err) })); } finally { setSavingPhoto(false); } }, [user, engine, source, myPhotos, openPhotoId, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, strip, photoSettings, 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. const openMyPhoto = useCallback( async (photo: Photo) => { setBusy(t('auth.busy')); setError(null); // The stage is emptied before the fetch, not after it: the frame being // reopened must not land on top of the one already standing there. clearPreview(); setHistPick(null); try { const res = await fetch(api.photoUrl(photo.id)); if (!res.ok) throw new Error(`HTTP ${res.status}`); const blob = await res.blob(); await loadFile(new File([blob], `${photo.id}.jpg`, { type: blob.type || 'image/jpeg' })); const saved = photo.recipe as (Recipe & { frameId?: FrameId; useGeotag?: boolean }) | null; if (saved) { applyRecipe(saved); if (saved.frameId) setFrameId(saved.frameId); setUseGeotag(!!saved.useGeotag); } setOpenPhotoId(photo.id); } catch { setError(t('err.decode')); } finally { setBusy(null); } }, [loadFile, applyRecipe, clearPreview, t] ); // 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); } catch { mark(!next); } }, []); // Removing a frame takes its row and the looks its history kept. Confirmed // first (ui/DeletePhotoModal), never on a single click. const removeMyPhoto = useCallback( async (photo: Photo) => { try { await api.deletePhoto(photo.id); setMyPhotos((prev) => prev.filter((p) => p.id !== photo.id)); if (openPhotoId === photo.id) setOpenPhotoId(null); } catch (err) { setError(t('err.generic', { msg: err instanceof Error ? err.message : String(err) })); } finally { setRemoving(null); } }, [openPhotoId, t] ); // --- strips --------------------------------------------------------------- // The cascade: a chip opens the next column, and the column it came from stays // put (TEMP -> COLOR TEMP leaves TEMP visible with the ruler beside it). The // recipes list is the one exception — it belongs to PRESETS, not to a chip, so // anything else opening closes it. const toggleParam = useCallback( (key: string) => { const close = openParam === key; setOpenParam(close ? null : key); setShowRecipes(false); // Opening a ruler clears a strip from another branch; closing one leaves // its own strip standing, so the column the ruler came from is still there. if (!close) setOpenGroup(PARAM_GROUP[key] ?? null); }, [openParam] ); const toggleGroup = useCallback( (g: GroupKey) => { const next = openGroup === g ? null : g; if (next) { // A ruler only survives if it belongs to the strip being opened. if (PARAM_GROUP[openParam ?? ''] !== next) setOpenParam(null); setShowRecipes(false); } setOpenGroup(next); }, [openGroup, openParam] ); useEffect(() => { setOpenParam(null); setOpenGroup(null); setShowRecipes(false); }, [tab]); // Continuous parameter chips: label + live value; amber text when edited, // amber border while its ruler is open. const paramChips = (defs: ContinuousParamDef[]): ChipDef[] => defs.map((def) => { const value = def.get(recipe.adjustments); return { key: def.key, label: def.label, value: def.display(value), active: openParam === def.key, amberValue: openParam !== def.key && value !== def.defaultValue, // The web build is the demo: HDF EFFECT is part of what it shows off, // so no knob is held back here. The PRO gate stays a phone-build rule. onClick: () => toggleParam(def.key), }; }); const slider: SliderDef | null = useMemo(() => { if (!openParam) return null; const all = [...PARAM_DEFS.iq, ...PARAM_DEFS.wb, ...PARAM_DEFS.filters]; const def = all.find((d) => d.key === openParam); if (!def) return null; const value = def.get(recipe.adjustments); return { key: def.key, label: def.label, value, min: def.min, max: def.max, step: def.step, display: def.display(value), edited: value !== def.defaultValue, swatch: SLIDER_SWATCH[def.key]?.(value), onChange: (value) => setAdjustmentOnce(def.set(value)), onReset: () => setAdjustmentOnce(def.set(def.defaultValue)), // Back reopens the strip the row was launched from (COLOR TEMP -> TEMP). onBack: () => { const g = PARAM_GROUP[def.key]; setOpenParam(null); if (g) setOpenGroup(g); }, }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [openParam, recipe.adjustments, setAdjustmentOnce]); // Option strips: a chip opens one, each option picks a value. `off` is the // neutral value the chip glows amber against. const groupDefs: Record, { label: string; off: string; value: string; options: { v: string; d: string }[]; onPick: (v: string) => void; }> = { cx: { label: 'COLOR CHROME', off: 'none', value: recipe.adjustments.colorChrome || 'none', options: CHROME_OPTIONS, onPick: (v) => { remember(); setAdjustment({ colorChrome: v as Recipe['adjustments']['colorChrome'] }); }, }, cxb: { label: 'CHROME BLUE', off: 'none', value: recipe.adjustments.colorChromeBlue || 'none', options: CHROME_OPTIONS, onPick: (v) => { remember(); setAdjustment({ colorChromeBlue: v as Recipe['adjustments']['colorChromeBlue'] }); }, }, dr: { label: 'D.RANGE', off: 'auto', value: String(recipe.adjustments.dynamicRange ?? 'auto'), options: DR_OPTIONS, onPick: (v) => { remember(); setAdjustment({ dynamicRange: (v === 'auto' ? 'auto' : Number(v)) as Recipe['adjustments']['dynamicRange'] }); }, }, // Picking a ratio frames it on the photo (drag the corners, then APPLY) — // nothing is cut until APPLY. crop: { label: 'CROP', off: 'none', value: crop, options: CROPS.map((c) => ({ v: c, d: c === 'none' ? 'NONE' : c.toUpperCase() })), onPick: (v) => pickCrop(v as CropRatio), }, // ponytail: no STRAIGHTEN/AUTO — the web export carries photoRotation only. rotate: { label: 'ROTATE', off: '0', value: String(rotation), options: [ { v: 'reset', d: 'RESET' }, { v: '0', d: '0°' }, { v: '90', d: '90°' }, { v: '180', d: '180°' }, { v: '270', d: '270°' }, ], onPick: (v) => { remember(); setRotation(v === 'reset' ? 0 : (Number(v) as 0 | 90 | 180 | 270)); }, }, style: { label: 'PHOTO STYLE', off: 'sim-provia', value: simId && FILM_SIMS.some((s) => s.id === simId) ? simId : '', options: FILM_SIMS.map((s) => ({ v: s.id, d: s.name })), onPick: (v) => { const sim = FILM_SIMS.find((s) => s.id === v); if (!sim) return; setShowRecipes(false); applyRecipe(filmSimToRecipe(sim)); }, }, // TEMP: the presets plus COLOR TEMP, which opens the ruler above instead // of picking a value. temp: { label: 'TEMP', off: 'auto', value: String(recipe.adjustments.temperature), options: [ { v: 'color-temp', d: 'COLOR TEMP' }, ...TEMP_PRESETS.map((p) => ({ v: String(p.kelvin), d: p.label })), ], onPick: (v) => { if (v !== 'color-temp') remember(); return v === 'color-temp' ? toggleParam('temperature') : setAdjustment({ temperature: Number(v) }); }, }, wmColor: { label: 'TEXT COLOR', off: '#ffffff', value: markStyle.color, options: WM_COLORS, onPick: (v) => { remember(); setMarkStyle((prev) => ({ ...prev, color: v })); }, }, wmSize: { label: 'TEXT SIZE', off: '1', value: String(markStyle.size), options: WM_SIZES, onPick: (v) => { remember(); setMarkStyle((prev) => ({ ...prev, size: Number(v) })); }, }, gpsColor: { label: 'COLOR', off: '#f59e0b', value: gpsStyle.color, options: WM_COLORS, onPick: (v) => { remember(); setGpsStyle((prev) => ({ ...prev, color: v })); }, }, gpsName: { label: 'PLACE NAME', off: 'ON', value: gpsStyle.showName ? 'ON' : 'OFF', options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }], onPick: (v) => { remember(); setGpsStyle((prev) => ({ ...prev, showName: v === 'on' })); }, }, gpsTime: { label: 'TIME', off: 'ON', value: gpsStyle.showTime ? 'ON' : 'OFF', options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }], onPick: (v) => { remember(); setGpsStyle((prev) => ({ ...prev, showTime: v === 'on' })); }, }, }; // A strip chip names its pick on the right edge, spelled the way the strip's // own option spells it. Still on its neutral value -> nothing to name. const groupChip = (g: Exclude): ChipDef => { const { label, value, off, options } = groupDefs[g]; const picked = options.find((o) => o.v === value); return { key: `grp-${g}`, label, value: value !== off ? picked?.d ?? value : undefined, active: openGroup === g, amberValue: openGroup !== g && value !== off, onClick: () => toggleGroup(g), }; }; const choiceChips = ( prefix: string, options: { v: string; d: string }[], current: string, onPick: (v: string) => void ): ChipDef[] => options.map((o) => ({ key: `${prefix}:${o.v}`, label: o.d, active: current === o.v, onClick: () => onPick(o.v), })); const wmOn = useGeotag || markOn; const wmChips = (): ChipDef[] => [ { key: 'gps', label: useGeotag ? 'GPS WATERMARK ON' : 'GPS WATERMARK OFF', active: useGeotag, disabled: !gps && !(gpsManual.lat && gpsManual.lng), onClick: () => { remember(); setUseGeotag(!useGeotag); }, }, ...(useGeotag ? [groupChip('gpsColor'), groupChip('gpsName'), groupChip('gpsTime')] : []), { key: 'custom-wm', label: markOn ? 'CUSTOM WATERMARK ON' : 'CUSTOM WATERMARK OFF', active: markOn, onClick: () => { remember(); setMarkOn(!markOn); }, }, ...(markOn ? [groupChip('wmColor'), groupChip('wmSize')] : []), ]; // Every tab carries RESET, pinned outside the scrolling chips. // RESET lights up only while the look differs from what reset() puts back — // every clause has a matching setter there, or the star never goes out. const resetDirty = useMemo(() => { const a = recipe.adjustments; const tweaked = (Object.keys(DEFAULT_ADJUSTMENTS) as (keyof typeof DEFAULT_ADJUSTMENTS)[]) .some((k) => a[k] !== DEFAULT_ADJUSTMENTS[k]); return tweaked || (simId ?? 'sim-provia') !== 'sim-provia' || frameId !== 'none' || crop !== 'none' || rotation !== 0 || markOn || markText !== '' || markStyle.color !== '#ffffff' || markStyle.size !== 1 || useGeotag || gpsStyle.color !== '#f59e0b' || !gpsStyle.showName || !gpsStyle.showTime; }, [recipe, simId, frameId, crop, rotation, markOn, markText, markStyle, useGeotag, gpsStyle]); const resetChip = (): ChipDef => ({ key: 'reset-all', label: resetDirty ? 'RESET *' : 'RESET', amberValue: resetDirty, onClick: () => { setOpenParam(null); setOpenGroup(null); setShowRecipes(false); // Under CREATE the button belongs to the form above it: it clears the // draft, which live-previews its defaults straight onto the photo. if (tab === 'create') { setCreateReset((n) => n + 1); return; } reset(); }, }); const framesRow = (): ChipDef[] => [ ...FRAMES.map((frame) => ({ key: `fr:${frame.id}`, label: t(frame.label), active: frameId === frame.id, onClick: () => { setOpenParam(null); setOpenGroup(null); remember(); setFrameId(frame.id); }, })), groupChip('crop'), { key: 'grp-rotate', label: 'ROTATE', value: `${rotation}°`, active: openGroup === 'rotate', amberValue: openGroup !== 'rotate' && rotation !== 0, onClick: () => toggleGroup('rotate'), }, { key: 'grp-wm', label: wmOn ? 'WATERMARK ON' : 'WATERMARK', active: openGroup === 'wm', amberValue: openGroup !== 'wm' && wmOn, onClick: () => toggleGroup('wm'), }, ]; // 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); // FX's MONOCHROME switch is not a look of its own: it swaps the base filter // for the mono stock and swaps right back. Adjustments are never touched, so // a knob moved while the switch is on survives the trip back. const monoOn = recipe.baseFilter === 'monochrome'; const toggleMono = () => { remember(); if (monoOn) { const back = monoPrev.current ?? { baseFilter: 'provia' as Recipe['baseFilter'], simId: 'sim-provia', name: 'PROVIPES' }; setRecipe((prev) => ({ ...prev, baseFilter: back.baseFilter, name: back.name, isCustom: true })); setSimId(back.simId); } else { monoPrev.current = { baseFilter: recipe.baseFilter, simId, name: recipe.name }; setRecipe((prev) => ({ ...prev, baseFilter: 'monochrome', name: 'MONOCHROME', isCustom: true })); setSimId(null); } }; // The main chip row of the active tab, exactly the phone's per-tab sets. const chips: ChipDef[] = (() => { switch (tab) { case 'presets': return [ groupChip('style'), { key: 'recipes-toggle', label: 'RECIPES', active: showRecipes, onClick: () => { setOpenGroup(null); setOpenParam(null); setShowRecipes((v) => !v); }, }, // ponytail: no CREATE chip — making a recipe and filing the look on // the stage are their own rail tabs (SAVE RECENT, CREATE RECIPES). // IMPORT is a chip inside the RECIPES strip. ]; case 'favorited': return saved.length === 0 ? [{ key: 'no-favorites', label: 'NO FAVORITES YET - TAP THE STAR ON A RECIPE', disabled: true, onClick: () => {} }] : saved.map((entry) => ({ key: String(entry.id), label: entry.name, // The look on the stage came from this entry: its name turns // accent so the open recipe is visible in the list. A same-id // preset that was merely filed under a new name must not light up, // hence the name check too. active: recipe.id === (entry.recipe as Recipe)?.id && recipe.name === entry.name, onClick: () => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name }), })); case 'photos': { // MY PHOTOS: the account's own saved frames, newest first. Opening one // reopens it for a further edit; that frame's own history, consent and // removal live in its own column (see the render). A save always joins // the landing strip — the owner takes it off again with that switch, or // the curator with the admin slot. const rows: ChipDef[] = []; if (myPhotos.length === 0) { rows.push({ key: 'no-photos', label: t('myp.empty'), disabled: true, onClick: () => {} }); } else { rows.push( ...myPhotos.map((p) => ({ key: `photo-${p.id}`, label: p.title ?? `#${p.id}`, active: p.id === openPhotoId, onClick: () => void openMyPhoto(p), })) ); } return rows; } case 'light': return [...paramChips(PARAM_DEFS.iq), groupChip('dr')]; case 'wb': return [ { // TEMP is the one strip chip that names its value — nothing else // does. ...groupChip('temp'), label: 'TEMP', value: Math.round(recipe.adjustments.temperature) === 5500 ? 'AUTO' : `${recipe.adjustments.temperature}K`, }, ...paramChips(PARAM_DEFS.wb.filter((p) => p.key !== 'temperature')), groupChip('cx'), groupChip('cxb'), ]; case 'fx': return [ { key: 'mono', label: 'MONOCHROME', active: monoOn, onClick: toggleMono }, ...paramChips(PARAM_DEFS.filters), ]; case 'frame': // WATERMARK is a chip like the rest: its own sub-panel opens as the next // column (see the wm column in the render), so the frame chips stay put. return framesRow(); // SAVE RECENT draws its own button and list, CREATE its own form; they // are not chip rows like the rest. case 'save': case 'create': return []; default: return []; } // eslint-disable-next-line react-hooks/exhaustive-deps })(); const stripChips = (g: Exclude): ChipDef[] => [ { key: `hint-${g}`, label: groupDefs[g].label, disabled: true, onClick: () => {} }, ...choiceChips(g, groupDefs[g].options, groupDefs[g].value, groupDefs[g].onPick), // The frame is on the photo but the crop is not in force until APPLY. ...(g === 'crop' && cropEditing ? [ { key: 'crop-apply', label: t('crop.apply'), amberValue: true, onClick: applyCrop }, { key: 'crop-cancel', label: t('crop.cancel'), onClick: cancelCrop }, ] : []), ]; // RECIPES = this account's own files (SAVE RECENT) + what this session made // but never filed + the bundled presets. A CREATE that a signed-in account // stored lands in `saved` too, so it is dropped from `created` by name here. // IMPORT takes the .recipe file the phone (or another account) exports — same // envelope, same parser, so a look crosses devices intact. const recipeStrip: ChipDef[] = [ { key: 'recipes-import', label: 'IMPORT .RECIPE', onClick: pickRecipeFile }, ...saved.map((entry) => ({ key: `saved-${entry.id}`, label: entry.name, active: recipe.name === entry.name, onClick: () => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name }), })), ...created .filter((entry) => !saved.some((s) => s.name.trim().toLowerCase() === entry.name.trim().toLowerCase())) .map((entry) => ({ key: entry.id, label: entry.name, active: recipe.id === entry.id, drag: entry.id, onClick: () => applyRecipe(entry), })), ...DEFAULT_RECIPES.map((preset) => ({ key: preset.id, label: preset.name, active: recipe.name === preset.name && !recipe.isCustom, drag: preset.id, onClick: () => applyRecipe({ ...preset }), })), ]; // The panel is a cascade of columns (see styles/app.css): the tab's chips, the // open chip's own panel, the open group's options, the open ruler. Each level // is a column of its own, so a child never hides the column it came from. const wmOpen = isWmPanel(openGroup); const optGroup = openGroup && openGroup !== 'wm' ? openGroup : null; const status = busy; return (
0} canRedo={redo.length > 0} onUndo={undoLast} onRedo={redoLast} onReset={reset} onSavePhoto={() => void savePhotoToFolder()} onExport={exportPhoto} photoCount={myPhotos.length} photoMax={MAX_PHOTOS} onAuth={() => setAuthMode('login')} onSignup={() => setAuthMode('signup')} onLogout={async () => { await api.logout().catch(() => undefined); setUser(null); }} />
{/* column 1 — the tab's own chips, RESET ruled off at the foot */}
{tab === 'create' ? ( void addCreatedRecipe(draft)} onExport={exportRecipeFile} /> ) : tab === 'save' ? ( <> {/* SAVE RECENT files the look standing on the stage — sim, WB, light, FX and frame — as a recipe of this account's own. */}

{t('save.recentHint')}

{saved.length === 0 ? (

{t('save.recentEmpty')}

) : (
{saved.map((entry) => (
{/* Only this account can delete its own file — the API scopes the delete by user, the button mirrors it. */}
))}
)} ) : ( )} {tab === 'favorited' && saved.length === 0 ?

{t('sec.savedEmpty')}

: null} {/* RESET is the way out of an edit, not one more chip: it sits under a rule at the foot of the column so it reads as its own thing. */}
{/* column 2 — FRAME's WATERMARK chip opens the mark's own panel */} {wmOpen ? (
setOpenGroup(null) }, ...wmChips(), ]} /> {markOn ? ( setMarkText(e.target.value)} /> ) : null} {useGeotag ? ( <> setPlace(e.target.value)} />

{gps ? t('wm.hasExif') : t('wm.noExif')}

{!gps ? (
setGpsManual((prev) => ({ ...prev, lat: e.target.value }))} onBlur={() => { const lat = Number(gpsManual.lat); const lng = Number(gpsManual.lng); if (Number.isFinite(lat) && Number.isFinite(lng) && gpsManual.lat && gpsManual.lng) { setGps({ latitude: lat, longitude: lng, locality: place, timestamp: Date.now(), source: 'device' }); setUseGeotag(true); } }} /> setGpsManual((prev) => ({ ...prev, lng: e.target.value }))} />
) : null} ) : null}
) : null} {/* column 3 — the open chip's sub-chips, or PRESETS' RECIPES list */} {optGroup ? (
) : null} {tab === 'presets' && showRecipes ? (
{recipeStrip.length === 1 ?

{t('sec.savedEmpty')}

: null}
) : 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}`, // The picked look is marked like the open recipe in the // RECIPES list — accent, so it follows the theme. active: histPick === i, onClick: () => { // An older look is loaded the same way a frame is: the // preview goes first, then the look it names is painted. clearPreview(); applyRecipe({ ...look }); setHistPick(i); }, })), ]} /> ) : (

{t('myp.historyEmpty')}

)} {/* The frame's own removal. The row goes, and with it the looks its history kept, so the dialog asks before it happens. */}
) : null} {/* column 4 — the open ruler, sliders stacked */} {slider ? (
) : null}
void loadFile(file)} cropEditing={cropEditing} cropRect={cropRect} cropWH={cropWH} onCropRectChange={setCropRect} onStageZoom={onStageZoom} canCompare={crop === 'none'} />
{naming === 'recipe' ? ( { setNaming(null); void saveRecipeNamed(name); }} onClose={() => setNaming(null)} /> ) : null} {naming === 'photo' ? ( { setNaming(null); void savePhotoToFolder(name); }} onClose={() => setNaming(null)} /> ) : null} {removing ? ( void removeMyPhoto(removing)} onClose={() => setRemoving(null)} /> ) : null} {authMode ? ( setAuthMode(null)} onDone={async () => { setAuthMode(null); const me = await api.me().catch(() => null); if (me) setUser(me.user); }} /> ) : null}
); }