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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user