Add the CREATE RECIPES tab to the web app

The phone's RecipeCreateModal becomes a rail tab with the same rows, seeding
from the look on screen and clamping the same way. SAVE RECIPE applies the new
look, lists it under RECIPES and, when signed in, stores it on the account; a
guest's copy stays in memory and goes away with the page. Signed-in users can
also export the recipe as the app's encrypted .recipe file (shared/utils
/recipeShare.ts vendored byte-identical from the RN project).
This commit is contained in:
2026-09-17 18:56:17 +07:00
parent f2e5e9abc9
commit d31d945827
9 changed files with 514 additions and 12 deletions
@@ -0,0 +1,298 @@
import { useState } 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<Recipe, 'id' | 'isCustom'>;
// 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).
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: 8000, 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;
};
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';
export function RecipeCreatePanel({
currentBase,
current,
signedIn,
onSave,
onExport,
}: {
currentBase?: BaseFilter;
current?: ColorAdjustments | null;
signedIn: boolean;
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),
color: String(s.saturation),
nr: String(s.denoise),
sharp: String(s.sharpening),
clarity: String(s.clarity),
ev: String(s.exposureCompensation),
};
};
const [sim, setSim] = useState<BaseFilter>(seedBase);
const [name, setName] = useState('');
const [dr, setDr] = useState<string>(seedAdj.dynamicRange == null ? 'auto' : String(seedAdj.dynamicRange));
const [grain, setGrain] = useState<string>(String(seedAdj.grain ?? 0));
const [cc, setCc] = useState<string>(seedAdj.colorChrome ?? 'none');
const [ccb, setCcb] = useState<string>(seedAdj.colorChromeBlue ?? 'none');
const [wbKey, setWbKey] = useState<string>(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 [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);
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),
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: int(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 };
};
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
) => (
<label className="create-num" key={label}>
<span>{label}</span>
<input
className="field"
data-key={`create-${label}`}
value={value}
placeholder={ph ?? '0'}
inputMode="numeric"
onChange={(e) => set(e.target.value)}
/>
</label>
);
return (
<div className="create-form" data-key="create-form">
<h2>{t('create.title')}</h2>
<label className="create-num wide">
<span>NAME</span>
<input
className="field"
data-key="create-name"
value={name}
placeholder="Recipe name (e.g. Retro Film)"
maxLength={40}
onChange={(e) => setName(e.target.value)}
/>
</label>
<span className="create-label">SIMULATION</span>
<ChipRow
chips={FILM_SIMS.map((s) => ({
key: s.id,
label: s.name,
active: sim === s.baseFilter,
onClick: () => selectSim(s.baseFilter),
}))}
/>
<span className="create-label">DYNAMIC RANGE</span>
<ChipRow chips={opts('dr', DR_OPTS, dr, setDr)} />
<div className="create-grid">
{[
{ label: 'HIGHLIGHT', value: fHigh, set: setFHigh },
{ label: 'SHADOW', value: fShadow, set: setFShadow },
{ 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 COMP.', value: fEv, set: setFEv, ph: 'EV' },
].map((f) => num(f.label, f.value, f.set, f.ph))}
</div>
<span className="create-label">GRAIN EFFECT</span>
<ChipRow chips={opts('grain', GRAIN_OPTS, grain, setGrain)} />
<span className="create-label">COLOR CHROME EFFECT</span>
<ChipRow chips={opts('cc', CC_OPTS, cc, setCc)} />
<span className="create-label">COLOR CHROME EFFECT BLUE</span>
<ChipRow chips={opts('ccb', CC_OPTS, ccb, setCcb)} />
<span className="create-label">WHITE BALANCE</span>
<ChipRow
chips={[
...opts('wb', WB_PRESETS.map((w) => ({ v: w.key, d: w.label })), wbKey, setWbKey),
...(wbKey === 'custom'
? [{ key: 'wb:custom', label: `${seedAdj.temperature}K`, active: true, onClick: () => setWbKey('custom') }]
: []),
]}
/>
<div className="create-shift">
<span className="create-label">WB SHIFT</span>
<span className="create-label">RED</span>
<input
className="field"
data-key="create-wbRed"
value={wbRed}
placeholder="-9..9"
inputMode="numeric"
onChange={(e) => setWbRed(e.target.value)}
/>
<span className="create-label">BLUE</span>
<input
className="field"
data-key="create-wbBlue"
value={wbBlue}
placeholder="-9..9"
inputMode="numeric"
onChange={(e) => setWbBlue(e.target.value)}
/>
</div>
<div className="create-shift">
<span className="create-label">ISO</span>
<ChipRow chips={[{ key: 'iso-auto', label: 'AUTO (camera default)', disabled: true, onClick: () => {} }]} />
</div>
<div className="create-actions">
<button
type="button"
className="btn primary"
data-key="create-save"
onClick={() => onSave(build())}
>
{t('create.save')}
</button>
<button
type="button"
className="btn"
data-key="create-export"
disabled={!signedIn}
title={signedIn ? t('create.export') : t('create.guestHint')}
onClick={() => onExport(build())}
>
{t('create.export')}
</button>
</div>
<p className="hint">{signedIn ? t('create.userHint') : t('create.guestHint')}</p>
</div>
);
}
+2 -1
View File
@@ -3,7 +3,7 @@ import { useI18n } from '../i18n/I18nProvider';
// The app's six tabs, in the same order as the phone rail. The rail is vertical
// on desktop and becomes the bottom bar under 860px (see styles/app.css).
export type TabId = 'presets' | 'favorited' | 'light' | 'wb' | 'fx' | 'frame';
export type TabId = 'presets' | 'favorited' | 'light' | 'wb' | 'fx' | 'frame' | 'create';
export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [
{ id: 'presets', label: 'tab.presets', glyph: '◉' },
@@ -12,6 +12,7 @@ export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [
{ id: 'wb', label: 'tab.wb', glyph: '◐' },
{ id: 'fx', label: 'tab.fx', glyph: '✦' },
{ id: 'frame', label: 'tab.frame', glyph: '▣' },
{ id: 'create', label: 'tab.create', glyph: '✎' },
];
export function ToolRail({ tab, onTab }: { tab: TabId; onTab: (tab: TabId) => void }) {