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
+71 -8
View File
@@ -4,6 +4,7 @@ import { ChipRow, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColum
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';
@@ -12,6 +13,7 @@ import { api, 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 } from '../shared/utils/recipeShare';
import { writeJpegExif } from '../shared/utils/exifWrite';
import { CROP_W_H, type CropRatio, type FrameId, type GPSInfo, type Recipe } from '../shared/types';
import type { MsgKey } from './i18n/vi';
@@ -120,6 +122,9 @@ export function Workspace() {
const [exporting, setExporting] = useState(false);
const [user, setUser] = useState<User | null>(null);
const [saved, setSaved] = useState<SavedRecipe[]>([]);
// 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. The landing's register link is /app?auth=1, so it opens the
// dialog straight on the sign-up tab.
const [authMode, setAuthMode] = useState<'login' | 'signup' | null>(() =>
@@ -300,6 +305,41 @@ export function Workspace() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recipe, user, t]);
// 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]
);
// 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]
);
const exportPhoto = useCallback(async () => {
if (!engine || !source) return;
setExporting(true);
@@ -660,6 +700,9 @@ export function Workspace() {
return isWmPanel(openGroup)
? [{ key: 'wm-back', label: '< FRAME', onClick: () => setOpenGroup(null) }, ...wmChips()]
: framesRow();
// CREATE draws its own form (ui/RecipeCreatePanel) instead of a chip row.
case 'create':
return [];
default:
return [];
}
@@ -671,12 +714,21 @@ export function Workspace() {
...choiceChips(g, groupDefs[g].options, groupDefs[g].value, groupDefs[g].onPick),
];
const recipeStrip: ChipDef[] = DEFAULT_RECIPES.map((preset) => ({
key: preset.id,
label: preset.name,
active: recipe.name === preset.name && !recipe.isCustom,
onClick: () => applyRecipe({ ...preset }),
}));
// RECIPES = what this session made (newest first) + the bundled presets.
const recipeStrip: ChipDef[] = [
...created.map((entry) => ({
key: entry.id,
label: entry.name,
active: recipe.id === entry.id,
onClick: () => applyRecipe(entry),
})),
...DEFAULT_RECIPES.map((preset) => ({
key: preset.id,
label: preset.name,
active: recipe.name === preset.name && !recipe.isCustom,
onClick: () => applyRecipe({ ...preset }),
})),
];
const status = busy ? busy : rendered ? t('meta.rendered', { ms: rendered.ms, w: rendered.w, h: rendered.h }) : t('meta.ready');
@@ -723,7 +775,18 @@ export function Workspace() {
</div>
) : null}
{/* 4. FRAME -> WATERMARK sub-panel: the mark's own text + place */}
{/* 4. CREATE -> the phone's recipe form */}
{tab === 'create' ? (
<RecipeCreatePanel
currentBase={recipe.baseFilter}
current={recipe.adjustments}
signedIn={!!user}
onSave={(draft) => void addCreatedRecipe(draft)}
onExport={exportRecipeFile}
/>
) : null}
{/* 5. FRAME -> WATERMARK sub-panel: the mark's own text + place */}
{tab === 'frame' && isWmPanel(openGroup) ? (
<div className="strip wm-extra">
{markOn ? (
@@ -775,7 +838,7 @@ export function Workspace() {
</div>
) : null}
{/* 5. main chip row, RESET pinned outside the scroll */}
{/* 6. main chip row, RESET pinned outside the scroll */}
<div className="main-row">
<ChipRow chips={[resetChip()]} />
<ChipRow chips={chips} />