// app/screen-edit.jsx — create / edit a recipe. Generous single column, // big textareas; sub-section headers ("For the chilli oil:") preserved on save. function slugify(s) { return (s || 'recipe').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'recipe'; } const FIELD_LABEL = { fontFamily: 'Bricolage Grotesque', fontSize: 10, fontWeight: 600, letterSpacing: '0.24em', textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 8, display: 'block' }; // The fixed meal vocabulary (CONTRACT: Recipe.meals). Replaces the dated free-text // Paprika categories — a known, toggleable set that drives the library filters. const MEAL_OPTIONS = ['breakfast', 'lunch', 'dinner', 'snack', 'dessert']; function MealPicker({ value, onToggle }) { return (
{MEAL_OPTIONS.map((m) => { const on = (value || []).includes(m); return ( ); })}
); } const DIFFICULTY_OPTIONS = ['easy', 'medium', 'hard']; function DifficultyPicker({ value, onChange }) { return (
{DIFFICULTY_OPTIONS.map((d) => { const on = value === d; return ( ); })}
); } // Settable rating — click a star to set, click the current one to step back to it // minus one (so a 1★ → 0 clears it). function StarPicker({ value, onChange }) { const v = value || 0; return (
{[1, 2, 3, 4, 5].map((n) => ( ))}
); } function Field({ label, hint, children }) { return (
{children}
); } const inputStyle = { width: '100%', padding: '13px 15px', borderRadius: 10, background: 'rgba(0,0,0,0.3)', border: '1px solid var(--hairline)', color: 'var(--ink)', fontFamily: 'Bricolage Grotesque', fontSize: 16, outline: 'none', boxSizing: 'border-box' }; const areaStyle = { ...inputStyle, fontFamily: 'Spectral', fontSize: 16, lineHeight: 1.6, resize: 'vertical' }; function EditScreen() { const { layout, state, getRecipe, saveRecipe, openRecipe, back, calculateCost, estimateEffort } = useApp(); const isNew = !!state.params.isNew; const existing = !isNew ? getRecipe(state.params.recipeId) : null; const isMobile = layout === 'mobile'; // Live recipe (reactive) — lets the editor watch an in-flight AI effort estimate. const liveRecipe = !isNew ? getRecipe(state.params.recipeId) : null; const effortInitInfo = window.LCDomain.effortInfo(existing); const [form, setForm] = React.useState(() => ({ name: existing?.name || '', description: existing?.description || '', meals: existing?.meals || [], effort: effortInitInfo.known ? effortInitInfo.label : '', servings: (existing?.servings || '').replace(/serves\s*/i, '') || '4', difficulty: existing?.difficulty || 'easy', rating: existing?.rating || 0, ingredients: (existing?.ingredients || []).join('\n'), directions: (existing?.directions || []).join('\n\n'), notes: existing?.notes || '', })); // Whether the effort value is currently an unconfirmed AI estimate (vs typed). const [accepted, setAccepted] = React.useState(effortInitInfo.known && effortInitInfo.estimated); const [effortErr, setEffortErr] = React.useState(null); const awaitingEstimate = React.useRef(false); const prevEffortStatus = React.useRef(liveRecipe?.effortStatus); const upd = (k, v) => setForm((f) => ({ ...f, [k]: v })); const toggleMeal = (m) => setForm((f) => ({ ...f, meals: f.meals.includes(m) ? f.meals.filter((x) => x !== m) : [...f.meals, m] })); const updEffort = (v) => { setForm((f) => ({ ...f, effort: v })); setAccepted(false); setEffortErr(null); }; // When an AI estimate we requested lands, drop it into the field (marked as // an unconfirmed estimate the user can edit to confirm). const estimating = liveRecipe?.effortStatus === 'estimating'; React.useEffect(() => { const st = liveRecipe?.effortStatus; if (awaitingEstimate.current && prevEffortStatus.current === 'estimating' && st === 'done' && liveRecipe) { setForm((f) => ({ ...f, effort: liveRecipe.effort })); setAccepted(true); setEffortErr(null); awaitingEstimate.current = false; } prevEffortStatus.current = st; }, [liveRecipe?.effortStatus, liveRecipe?.effort]); const runEstimate = () => { if (isNew || !existing) return; awaitingEstimate.current = true; estimateEffort(existing.id); }; const save = () => { const trimmedEffort = form.effort.trim(); // Gentle validation — don't silently drop a value we can't parse. if (trimmedEffort && window.LCDomain.parseDuration(trimmedEffort) == null) { setEffortErr('Couldn’t read that time — try “30 min”, “1h30”, or “1:30”.'); return; } const id = existing?.id || (slugify(form.name) + '-' + Date.now().toString(36).slice(-4)); const servesN = parseInt(String(form.servings).match(/\d+/)?.[0] || '0', 10) || null; const recipe = { id, name: form.name.trim() || 'Untitled recipe', description: form.description.trim(), // Fixed meal tags drive the library filters (CONTRACT: meals). meals: form.meals, // Imported Paprika categories kept as hidden provenance — no longer edited or shown. categories: existing?.categories || [], // Raw prep/cook/total kept as hidden provenance — never edited here now. prep: existing?.prep || '—', cook: existing?.cook || '—', total: existing?.total || '—', // Effort — one flexible field; the backend normalises effortInput → minutes. effortInput: trimmedEffort, effortEstimated: !!(trimmedEffort && accepted), servings: form.servings.toString().match(/serves/i) ? form.servings : `serves ${form.servings}`, servingsCount: servesN, cost: existing?.cost || '', difficulty: form.difficulty, rating: form.rating, ingredients: form.ingredients.split('\n').map((l) => l.replace(/\s+$/, '')).filter((l, i, arr) => !(l === '' && (i === 0 || arr[i - 1] === ''))), directions: form.directions.split(/\n\s*\n/).map((d) => d.trim()).filter(Boolean), notes: form.notes.trim(), photo: 'placeholder', bg: existing?.bg || '#3a2e22', angle: existing?.angle || 30, placeholder: form.name.trim().toLowerCase() || 'your recipe', }; saveRecipe(recipe); // Editing an already-costed recipe re-triggers the cost on save (PRD rule). const wasCosted = existing && (existing.costStatus === 'costed' || existing.costStatus === 'needs_attention'); if (wasCosted && servesN) setTimeout(() => calculateCost(id, { force: true }), 40); openRecipe(id); }; const maxW = isMobile ? '100%' : 720; const pad = isMobile ? '24px 18px 60px' : '40px 48px 80px'; return ( {/* Top bar */}
{isNew ? 'New recipe' : 'Editing'}

{isNew ? 'New' : 'Edit'} recipe.

Fill in what you know — everything stays editable later.

upd('name', e.target.value)} placeholder="e.g. Sichuan-style cold chicken with chilli oil" style={inputStyle} />