{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.