import { useEffect, useRef, useState, type ReactNode } from 'react'; import { ChipRow, type ChipDef } from './ChipColumn'; import { useI18n } from '../i18n/I18nProvider'; import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from '../../shared/utils/defaultRecipes'; import type { BaseFilter, ColorAdjustments, Recipe } from '../../shared/types'; // The phone's RecipeCreateModal, minus the modal: the same rows in the same // order with the same clamping. CREATE opens on the look live on screen (the // phone's rule — a recipe is a snapshot of what the user is looking at). export type RecipeDraft = Omit; // WB presets mirror the phone's WB-tab chips but keep pure presets (no -R // variants — those are the separate Red/Blue offset boxes below). SHADE is the // WB tab's own 7500K/+2: it read 8000K here, which made a recipe created in one // place print a different white balance from the same chip picked in the other. const WB_PRESETS: { key: string; label: string; temperature: number; tint: number }[] = [ { key: 'auto', label: 'AUTO', temperature: 5500, tint: 0 }, { key: 'daylight', label: 'DAYLIGHT', temperature: 5500, tint: 0 }, { key: 'cloudy', label: 'CLOUDY', temperature: 6500, tint: 1 }, { key: 'shade', label: 'SHADE', temperature: 7500, tint: 2 }, { key: 'tungsten', label: 'TUNGSTEN', temperature: 3200, tint: 0 }, { key: 'fluor', label: 'FLUOR', temperature: 4000, tint: 3 }, ]; const WB_KEYS = WB_PRESETS.map((w) => w.key); const DR_OPTS = [ { v: 'auto', d: 'AUTO' }, { v: '100', d: 'DR100' }, { v: '200', d: 'DR200' }, { v: '400', d: 'DR400' }, ]; const CC_OPTS = [ { v: 'none', d: 'OFF' }, { v: 'weak', d: 'WEAK' }, { v: 'strong', d: 'STRONG' }, ]; const GRAIN_OPTS = [ { v: '0', d: 'OFF' }, { v: '3', d: 'WEAK' }, { v: '6', d: 'STRONG' }, ]; const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); const int = (s: string, fallback: number, lo: number, hi: number) => { const n = parseInt(s, 10); return Number.isFinite(n) ? clamp(n, lo, hi) : fallback; }; // GRAIN's own row: the ruler spends HALF steps (paramDefs.ts), so the form must // not drop the half the strip was showing — the chips are its coarse picks, and // the live value rides along on a chip of its own. Only grain is read this way; // every other knob here is a whole number. const half = (s: string, fallback: number, lo: number, hi: number) => { const n = Number(s); return Number.isFinite(n) ? clamp(Math.round(n * 2) / 2, lo, hi) : fallback; }; const wbKeyOf = (s: ColorAdjustments) => WB_KEYS.find((k) => { const p = WB_PRESETS.find((w) => w.key === k)!; return p.temperature === s.temperature && p.tint === s.tint; }) ?? 'custom'; // One collapsible group. A native
— no state, no library, and the // browser keeps the open/closed flag itself (the `open` prop only seeds it). function Fold({ label, children }: { label: string; children: ReactNode }) { return (
{label} {children}
); } export function RecipeCreatePanel({ currentBase, current, signedIn, onPreview, onSave, onExport, resetToken = 0, }: { currentBase?: BaseFilter; current?: ColorAdjustments | null; signedIn: boolean; // Bumped by the RESET chip above the form: the draft drops back to the state // the form opened with. resetToken?: number; onPreview: (draft: Pick) => void; onSave: (draft: RecipeDraft) => void; onExport: (draft: RecipeDraft) => void; }) { const { t } = useI18n(); const seedBase: BaseFilter = currentBase && FILM_SIMS.some((s) => s.baseFilter === currentBase) ? currentBase : 'provia'; const seedAdj: ColorAdjustments = current ?? FILM_SIMS.find((s) => s.baseFilter === seedBase)?.adjustments ?? DEFAULT_ADJUSTMENTS; // Every sim's standard values as form state — used to seed the form when the // SIMULATION chip changes. const valuesOf = (b: BaseFilter, src?: ColorAdjustments) => { const s = src ?? FILM_SIMS.find((x) => x.baseFilter === b)?.adjustments ?? DEFAULT_ADJUSTMENTS; return { dr: String(s.dynamicRange ?? 'auto'), grain: String(s.grain ?? 0), cc: s.colorChrome ?? 'none', ccb: s.colorChromeBlue ?? 'none', wbKey: wbKeyOf(s), highlight: String(s.highlight), shadow: String(s.shadow), whites: String(s.whites ?? 0), blacks: String(s.blacks ?? 0), exposure: String(s.exposure), color: String(s.saturation), nr: String(s.denoise), sharp: String(s.sharpening), clarity: String(s.clarity), ev: String(s.exposureCompensation), }; }; const [sim, setSim] = useState(seedBase); const [name, setName] = useState(''); const [dr, setDr] = useState(seedAdj.dynamicRange == null ? 'auto' : String(seedAdj.dynamicRange)); const [grain, setGrain] = useState(String(seedAdj.grain ?? 0)); const [cc, setCc] = useState(seedAdj.colorChrome ?? 'none'); const [ccb, setCcb] = useState(seedAdj.colorChromeBlue ?? 'none'); const [wbKey, setWbKey] = useState(wbKeyOf(seedAdj)); const [wbRed, setWbRed] = useState(String(seedAdj.wbRed ?? 0)); const [wbBlue, setWbBlue] = useState(String(seedAdj.wbBlue ?? 0)); const [fHigh, setFHigh] = useState(String(seedAdj.highlight)); const [fShadow, setFShadow] = useState(String(seedAdj.shadow)); const [fWhites, setFWhites] = useState(String(seedAdj.whites ?? 0)); const [fBlacks, setFBlacks] = useState(String(seedAdj.blacks ?? 0)); const [fExposure, setFExposure] = useState(String(seedAdj.exposure)); const [fColor, setFColor] = useState(String(seedAdj.saturation)); const [fNr, setFNr] = useState(String(seedAdj.denoise)); const [fSharp, setFSharp] = useState(String(seedAdj.sharpening)); const [fClarity, setFClarity] = useState(String(seedAdj.clarity)); const [fEv, setFEv] = useState(String(seedAdj.exposureCompensation)); // Switching the simulation reloads its standard values into the form. const selectSim = (b: BaseFilter) => { setSim(b); const v = valuesOf(b); setDr(v.dr); setGrain(v.grain); setCc(v.cc); setCcb(v.ccb); setWbKey(v.wbKey); setFHigh(v.highlight); setFShadow(v.shadow); setFWhites(v.whites); setFBlacks(v.blacks); setFExposure(v.exposure); setFColor(v.color); setFNr(v.nr); setFSharp(v.sharp); setFClarity(v.clarity); setFEv(v.ev); }; // Same assembly as the phone's handleSave: the live look rides along for every // knob the form has no row for, so the saved recipe matches the screen. const build = (): RecipeDraft => { const wb = WB_PRESETS.find((w) => w.key === wbKey); const simAdj = FILM_SIMS.find((s) => s.baseFilter === sim)?.adjustments ?? DEFAULT_ADJUSTMENTS; const adjustments: ColorAdjustments = { ...DEFAULT_ADJUSTMENTS, ...seedAdj, temperature: wb ? wb.temperature : seedAdj.temperature, tint: wb ? wb.tint : seedAdj.tint, wbRed: int(wbRed, 0, -9, 9), wbBlue: int(wbBlue, 0, -9, 9), highlight: int(fHigh, simAdj.highlight, -10, 10), shadow: int(fShadow, simAdj.shadow, -10, 10), whites: int(fWhites, simAdj.whites ?? 0, -10, 10), blacks: int(fBlacks, simAdj.blacks ?? 0, -10, 10), exposure: int(fExposure, simAdj.exposure, -10, 10), saturation: int(fColor, simAdj.saturation, -10, 10), denoise: int(fNr, simAdj.denoise, -10, 10), sharpening: int(fSharp, simAdj.sharpening, -10, 10), clarity: int(fClarity, simAdj.clarity, -10, 10), grain: half(grain, seedAdj.grain, 0, 10), colorChrome: cc as ColorAdjustments['colorChrome'], colorChromeBlue: ccb as ColorAdjustments['colorChromeBlue'], dynamicRange: (dr === 'auto' ? 'auto' : parseInt(dr, 10)) as ColorAdjustments['dynamicRange'], exposureCompensation: int(fEv, simAdj.exposureCompensation, -3, 3), }; return { name: name.trim() || 'NEW RECIPE', baseFilter: sim, adjustments, frameId: 'none', useGeotag: true }; }; // RESET clears the draft: back to the seed simulation, its standard values and // an empty name. The form previews live, so the photo follows it home. useEffect(() => { if (resetToken === 0) return; selectSim(seedBase); setName(''); setWbRed(String(seedAdj.wbRed ?? 0)); setWbBlue(String(seedAdj.wbBlue ?? 0)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [resetToken]); // Realtime: every knob applies to the photo on screen as it is typed, so the // effect of the recipe is visible before saving it. Opening the tab is not an // edit — the first run (the seeded values) is skipped. const first = useRef(true); useEffect(() => { if (first.current) { first.current = false; return; } const { baseFilter, adjustments } = build(); onPreview({ baseFilter, adjustments }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [sim, dr, grain, cc, ccb, wbKey, wbRed, wbBlue, fHigh, fShadow, fWhites, fBlacks, fColor, fNr, fSharp, fClarity, fExposure, fEv]); const opts = ( prefix: string, options: { v: string; d: string }[], value: string, onPick: (v: string) => void ): ChipDef[] => options.map((o) => ({ key: `${prefix}:${o.v}`, label: o.d, active: value === o.v, onClick: () => onPick(o.v), })); const num = ( label: string, value: string, set: (s: string) => void, ph?: string ) => ( ); return (

{t('create.title')}

({ key: s.id, label: s.name, active: sim === s.baseFilter, onClick: () => selectSim(s.baseFilter), }))} />
{[ { label: 'HIGHLIGHT', value: fHigh, set: setFHigh }, { label: 'SHADOW', value: fShadow, set: setFShadow }, { label: 'WHITE', value: fWhites, set: setFWhites }, { label: 'BLACK', value: fBlacks, set: setFBlacks }, { label: 'COLOR', value: fColor, set: setFColor }, { label: 'NOISE RED.', value: fNr, set: setFNr, ph: '-10..10' }, { label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '-10..10' }, { label: 'CLARITY', value: fClarity, set: setFClarity }, { label: 'EXPOSURE', value: fExposure, set: setFExposure }, { label: 'EV', value: fEv, set: setFEv, ph: '±3 EV' }, ].map((f) => num(f.label, f.value, f.set, f.ph))}
({ v: w.key, d: w.label })), wbKey, setWbKey), ...(wbKey === 'custom' ? [{ key: 'wb:custom', label: `${seedAdj.temperature}K`, active: true, onClick: () => setWbKey('custom') }] : []), ]} />
WB SHIFT RED setWbRed(e.target.value)} /> BLUE setWbBlue(e.target.value)} />
ISO {} }]} />

{signedIn ? t('create.userHint') : t('create.guestHint')}

); }