Files
RecipesCam/docker/frontend/src/App.tsx
T
3dtours 0627f8dd91 web: keep a saved photo's look in EXIF and its own row
EXPORT no longer burns the caption strip: the pixels stay the photo's own
and the look travels as metadata — ImageDescription (0x010e) for the tag,
UserComment (0x9286, ASCII header) for the recipe JSON.

SAVE PHOTO now stores the look with the frame (photos.recipe) and the
uploader's consent for the community film strip (photos.consent, PATCH
/api/photos/:id for the owner). The landing reel skips non-consented frames,
and a new MY PHOTOS tab lists the account's saves, reopens one with the
settings it was stored with, and carries the two consent switches.
2026-09-18 12:49:05 +07:00

1510 lines
58 KiB
TypeScript

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 { 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<Record<string, (v: number) => 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<Record<string, GroupKey>> = { 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<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [source, setSource] = useState<LoadedSource | null>(null);
const [previewBytes, setPreviewBytes] = useState<Uint8Array | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [recipe, setRecipe] = useState<Recipe>(() => ({
...DEFAULT_RECIPES[0],
id: 'web-current',
name: DEFAULT_RECIPES[0]?.name ?? 'PROVIA',
...session.recipe,
}));
const [simId, setSimId] = useState<string | null>(() => session.simId ?? null);
const [frameId, setFrameId] = useState<FrameId>(() => session.frameId ?? 'none');
const [crop, setCrop] = useState<CropRatio>(() => session.crop ?? 'none');
const [cropRect, setCropRect] = useState<CropRect>(() => 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<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>(() => session.gps ?? null);
// ISO off the loaded photo's EXIF; null means the strip prints AUTO.
const [iso, setIso] = useState<number | null>(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 -----------------------------------------------------------------
// 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.
const [undo, setUndo] = useState<Look[]>([]);
const lookRef = useRef<Look | null>(null);
lookRef.current = { recipe, simId, frameId, crop, cropRect, cropApplied, rotation, markOn, markText, markStyle, useGeotag, gpsStyle };
const remember = useCallback(() => {
const snap = lookRef.current;
if (snap) setUndo((stack) => [...stack.slice(-19), snap]);
}, []);
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<User | null>(null);
const [saved, setSaved] = useState<SavedRecipe[]>([]);
// 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<Photo[]>([]);
// Consent for the NEXT save: does it join the landing film strip? Sticky, so
// the answer survives several saves. A guest's frames never reach the reel.
const [shareToStrip, setShareToStrip] = useState(true);
// The saved photo standing on the stage, if any — its own consent chip shows.
const [openPhotoId, setOpenPhotoId] = useState<number | null>(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<Recipe[]>([]);
// 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.
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.
setUndo([defaultLook()]);
if (keepGeo) return;
setUseGeotag(!!found);
if (!found) setGpsManual({ lat: '', lng: '' });
},
[defaultLook]
);
const loadFile = useCallback(
async (file: File) => {
setError(null);
setBusy(t('auth.busy'));
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);
},
[remember]
);
const setAdjustment = useCallback(
(patch: Partial<Recipe['adjustments']>) => {
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<Recipe['adjustments']>) => {
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), []);
// CANCEL throws the framing away — the pick pushed the look it replaced, so
// one undo is exactly "never mind".
const cancelCrop = useCallback(() => {
setUndo((stack) => {
const last = stack[stack.length - 1];
if (last) restoreLook(last);
return stack.slice(0, -1);
});
}, [restoreLook]);
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]);
const undoLast = useCallback(() => {
setUndo((stack) => {
const last = stack[stack.length - 1];
if (last) restoreLook(last);
return stack.slice(0, -1);
});
}, [restoreLook]);
const saveRecipe = useCallback(async () => {
if (!user) {
setAuthMode('login');
return;
}
const name = window.prompt(t('save.name'), recipe.name);
if (!name) return;
try {
const created = await api.createRecipe(name, { ...recipe, name });
setSaved((list) => [created.recipe, ...list]);
setRecipe((prev) => ({ ...prev, name }));
} catch (err) {
setError(t('err.generic', { msg: String(err) }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recipe, user, 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<Recipe, 'baseFilter' | 'adjustments'>) => {
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 <input type=file> 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 () => {
if (!user) {
setAuthMode('login');
return;
}
if (!engine || !source) {
setError(t('save.photoNoSource'));
return;
}
if (myPhotos.length >= MAX_PHOTOS) {
setError(t('save.photoFull', { max: MAX_PHOTOS }));
return;
}
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',
});
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]);
// 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);
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, t]
);
// The saved frame's own switch. The reel reads `consent`, so the change is
// live as soon as the store answers it.
const togglePhotoConsent = useCallback(async (photo: Photo | undefined) => {
if (!photo) return;
const next = photo.consent === false;
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.
}
}, []);
// --- 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<Exclude<GroupKey, 'wm'>, {
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<GroupKey, 'wm'>): 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 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 — the save button lives under the CREATE
// RECIPES tab (see the create column). 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, plus the two
// consent switches. Opening one reopens it for a further edit.
const open = myPhotos.find((p) => p.id === openPhotoId);
const rows: ChipDef[] = [
{
key: 'strip-next',
label: t(shareToStrip ? 'myp.newOn' : 'myp.newOff'),
active: shareToStrip,
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 {
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 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();
// CREATE draws its own form (ui/RecipeCreatePanel) instead of a chip row.
case 'create':
return [];
default:
return [];
}
// eslint-disable-next-line react-hooks/exhaustive-deps
})();
const stripChips = (g: Exclude<GroupKey, 'wm'>): 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 = what this session made (newest first) + the bundled presets.
// 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 },
...created.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 (
<div className="shell">
<TopBar
recipeName={recipe.name}
user={user}
exporting={exporting}
savingPhoto={savingPhoto}
canUndo={undo.length > 0}
onUndo={undoLast}
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);
}}
/>
<div className="workspace">
<ToolRail tab={tab} onTab={setTab} onDropRecipe={favoriteById} />
<div className="chips">
{/* column 1 — the tab's own chips, RESET ruled off at the foot */}
<div className={`col col-main${tab === 'create' ? ' col-create' : ''}`} data-col="main">
{tab === 'create' ? (
<>
<RecipeCreatePanel
currentBase={recipe.baseFilter}
current={recipe.adjustments}
signedIn={!!user}
resetToken={createReset}
onPreview={previewCreatedRecipe}
onSave={(draft) => void addCreatedRecipe(draft)}
onExport={exportRecipeFile}
/>
{/* The studio's own save lives here now, under the CREATE tab:
it stores the recipe standing on the stage, where the panel
above stores the draft being built. */}
<button
type="button"
className="btn"
data-key="save-recipes"
onClick={() => void saveRecipe()}
>
{t('save.recipes')}
</button>
</>
) : (
<ChipRow chips={chips} />
)}
{tab === 'favorited' && saved.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : 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. */}
<div className="reset-row">
<ChipRow chips={[resetChip()]} />
</div>
</div>
{/* column 2 — FRAME's WATERMARK chip opens the mark's own panel */}
{wmOpen ? (
<div className="col col-sub strip wm-extra" data-col="wm">
<ChipRow
chips={[
{ key: 'wm-back', label: '< FRAME', onClick: () => setOpenGroup(null) },
...wmChips(),
]}
/>
{markOn ? (
<input
className="field"
value={markText}
placeholder="TYPE WATERMARK TEXT"
maxLength={80}
onChange={(e) => setMarkText(e.target.value)}
/>
) : null}
{useGeotag ? (
<>
<input
className="field"
value={place}
placeholder={t('wm.placePh')}
onChange={(e) => setPlace(e.target.value)}
/>
<p className="hint">{gps ? t('wm.hasExif') : t('wm.noExif')}</p>
{!gps ? (
<div className="chip-row">
<input
className="field"
style={{ width: '48%' }}
placeholder={t('wm.lat')}
value={gpsManual.lat}
onChange={(e) => 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);
}
}}
/>
<input
className="field"
style={{ width: '48%' }}
placeholder={t('wm.lng')}
value={gpsManual.lng}
onChange={(e) => setGpsManual((prev) => ({ ...prev, lng: e.target.value }))}
/>
</div>
) : null}
</>
) : null}
</div>
) : null}
{/* column 3 — the open chip's sub-chips, or PRESETS' RECIPES list */}
{optGroup ? (
<div className="col col-sub strip" data-col="options">
<ChipRow chips={stripChips(optGroup)} />
</div>
) : null}
{tab === 'presets' && showRecipes ? (
<div className="col col-sub strip" data-col="recipes">
{recipeStrip.length === 1 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
<ChipRow chips={recipeStrip} />
</div>
) : null}
{/* column 4 — the open ruler, sliders stacked */}
{slider ? (
<div className="col col-slider" data-col="slider">
<SliderRow slider={slider} />
</div>
) : null}
</div>
<ImageStage
previewUrl={previewUrl}
originalUrl={source?.url ?? null}
busy={status}
error={error}
peek={peek}
onPeek={setPeek}
onFile={(file) => void loadFile(file)}
cropEditing={cropEditing}
cropRect={cropRect}
cropWH={cropWH}
onCropRectChange={setCropRect}
onStageZoom={onStageZoom}
canCompare={crop === 'none'}
/>
</div>
{authMode ? (
<AuthModal
initialMode={authMode}
onClose={() => setAuthMode(null)}
onDone={async () => {
setAuthMode(null);
const me = await api.me().catch(() => null);
if (me) setUser(me.user);
}}
/>
) : null}
</div>
);
}