Cookbook Submission Form
Build fields > design page (freeform grid) > enter recipes > export CSV
1Define Fields
2Arrange Layout
3Add Recipes
4Review & Export
Step 1 - Create your recipe fields
Add the fields you want. TEXT fields become textareas in Step 3. IMAGE fields are layout-only (no text needed).
Field builder
Tip: keep names short. You can edit later.
There are no TEXT fields. Add one in Step 1.
';
return;
}
const rowDiv = document.createElement('div'); rowDiv.className='recipe-row cols-1';
textFields.forEach(f=>{
const wrap=document.createElement('div'); wrap.className='recipe-field';
const label=document.createElement('label'); label.textContent=f.name;
const ta=document.createElement('textarea'); ta.setAttribute('data-name', f.name); ta.setAttribute('wrap','soft'); ta.value = values[f.name] || '';
wrap.appendChild(label); wrap.appendChild(ta);
rowDiv.appendChild(wrap);
});
recipeForm.appendChild(rowDiv);
}
function clearRecipeForm(){ Array.from(recipeForm.querySelectorAll('textarea')).forEach(t=> t.value=''); state.editingId = null; }
function getRecipeFormValues(){
const values = {};
Array.from(recipeForm.querySelectorAll('textarea')).forEach(t=>{ values[t.dataset.name] = t.value.trim(); });
return values;
}
document.getElementById('save-recipe').onclick = ()=>{
const values = getRecipeFormValues();
const hasAny = Object.values(values).some(v=> v && v.length);
if(!hasAny){ alert('Recipe is empty. Please fill at least one TEXT field.'); return; }
if(state.editingId){
const idx = state.recipes.findIndex(r=>r.id===state.editingId);
if(idx>-1) state.recipes[idx].values = values;
state.editingId = null;
} else {
state.recipes.push({id:uid(), values});
}
setSaving(); save(); clearRecipeForm(); renderRecipesTable();
alert('Recipe saved!');
};
document.getElementById('clear-current').onclick = (e)=>{ e.preventDefault(); clearRecipeForm(); };
const recipesHeaderRow = document.getElementById('recipes-header-row');
const recipesTbody = document.getElementById('recipes-tbody');
function renderRecipesTable(){
recipesHeaderRow.innerHTML = '';
state.fields.forEach(f=>{
const th=document.createElement('th');
th.textContent = f.name + (f.type==='IMAGE' ? ' (IMG)' : '');
recipesHeaderRow.appendChild(th);
});
const th2=document.createElement('th'); th2.textContent='Actions'; recipesHeaderRow.appendChild(th2);
recipesTbody.innerHTML = '';
if(state.recipes.length===0){
const tr=document.createElement('tr'); const td=document.createElement('td'); td.colSpan=state.fields.length+1; td.innerHTML='No recipes yet. Add one above.';
tr.appendChild(td); recipesTbody.appendChild(tr); return;
}
state.recipes.forEach(r=>{
const tr=document.createElement('tr');
state.fields.forEach(f=>{
const td=document.createElement('td');
td.textContent = r.values[f.name] || '';
tr.appendChild(td);
});
const tdAct=document.createElement('td');
const editBtn=document.createElement('button'); editBtn.className='ghost'; editBtn.type='button'; editBtn.textContent='Edit';
editBtn.onclick = ()=>{
state.editingId = r.id;
pendingRecipeValues = r.values;
goto(3);
const first = document.querySelector('#recipe-form textarea'); if(first) first.focus();
};
const delBtn=document.createElement('button'); delBtn.className='danger'; delBtn.type='button'; delBtn.textContent='Delete';
delBtn.onclick = ()=>{ if(confirm('Delete this recipe?')){ state.recipes = state.recipes.filter(x=>x.id!==r.id); setSaving(); save(); renderRecipesTable(); } };
tdAct.appendChild(editBtn); tdAct.appendChild(document.createTextNode(' ')); tdAct.appendChild(delBtn);
tr.appendChild(tdAct);
recipesTbody.appendChild(tr);
});
}
// ---------- Step 4 ----------
function renderReview(){
const head=document.getElementById('review-header');
const body=document.getElementById('review-body');
head.innerHTML=''; body.innerHTML='';
state.fields.forEach(f=>{
const th=document.createElement('th');
th.textContent = f.name + (f.type==='IMAGE' ? ' (IMG)' : '');
head.appendChild(th);
});
if(state.recipes.length===0){
const tr=document.createElement('tr'); const td=document.createElement('td'); td.colSpan=state.fields.length; td.innerHTML='No data yet.';
tr.appendChild(td); body.appendChild(tr); return;
}
state.recipes.forEach(r=>{
const tr=document.createElement('tr');
state.fields.forEach(f=>{
const td=document.createElement('td');
td.textContent=r.values[f.name]||'';
tr.appendChild(td);
});
body.appendChild(tr);
});
}
// CSV & downloads
function toCSV(){
const headers = state.fields.map(f=>f.name);
const rows = state.recipes.map(r=> headers.map(h=> csvEscape(r.values[h]||'')) );
return [headers.map(csvEscape).join(','), ...rows.map(row=>row.join(','))].join('\n');
}
function csvEscape(val){
const s = String(val).replace(/\r?\n/g, ' # ');
if(/[",\n]/.test(s)) return '"'+s.replace(/"/g,'""')+'"';
return s;
}
document.getElementById('export-csv').onclick = ()=>{
if(state.fields.length===0){ alert('No fields to export.'); return; }
const csv = toCSV();
const date = new Date().toISOString().slice(0,10);
downloadText(`recipes-${date}.csv`, csv, 'text/csv');
};
// Step navigation
document.getElementById('to-step-2').onclick = ()=>{ if(state.fields.length===0){ alert('Add at least one field first.'); return; } goto(2); };
document.getElementById('to-step-3').onclick = ()=>{ goto(3); };
document.getElementById('to-step-4').onclick = ()=>{ goto(4); };
document.getElementById('back-to-step-1').onclick = ()=>{ goto(1); };
document.getElementById('back-to-step-2').onclick = ()=>{ goto(2); };
document.getElementById('back-to-step-3').onclick = ()=>{ goto(3); };
document.querySelectorAll('.step').forEach(el=>{
el.addEventListener('click', ()=>{
const n = Number(el.dataset.step);
if(n===2 && state.fields.length===0){ alert('Add at least one field in Step 1.'); return; }
goto(n);
});
});
// JSON save/load/clear
document.getElementById('btn-download-json').onclick = ()=>{
const payload = {
fields: state.fields,
recipes: state.recipes,
gridItems: state.gridItems,
spreadMode: state.spreadMode,
activePage: state.activePage
};
downloadText('cookbook-project.json', JSON.stringify(payload, null, 2), 'application/json');
};
document.getElementById('btn-load-json')?.addEventListener('click', ()=>{
const input = document.getElementById('file-input');
if(!input){ alert('File input missing.'); return; }
input.value = ''; // allow re-selecting same file
input.click();
});
document.getElementById('file-input')?.addEventListener('change', (e)=>{
const file = e.target.files?.[0];
if(!file){ alert('No file selected.'); return; }
if(!/\.json$/i.test(file.name)){
if(!confirm('This does not look like a .json file. Try loading anyway?')) return;
}
const reader = new FileReader();
reader.onerror = () => alert('Could not read file. Please try again.');
reader.onload = ()=>{
try{
const text = String(reader.result || '');
const data = JSON.parse(text);
if(!Array.isArray(data.fields)) throw new Error('Missing "fields" array.');
if(!Array.isArray(data.recipes)) throw new Error('Missing "recipes" array.');
state.fields = data.fields;
state.recipes = data.recipes;
state.gridItems = Array.isArray(data.gridItems) ? data.gridItems : [];
if(typeof data.spreadMode === 'string') state.spreadMode = data.spreadMode;
if(typeof data.activePage === 'string') state.activePage = data.activePage;
const validIds = new Set(state.fields.map(f=>f.id));
state.gridItems = state.gridItems.filter(it => validIds.has(it.id))
.map(it => ({...it, page: it.page==='R'?'R':'L'}));
save();
renderFields(); renderPreview();
renderLayoutPool(); updateSpreadUI(); fitCanvasToViewport(); renderGrid();
renderRecipeForm(); renderRecipesTable(); renderReview();
alert('Project loaded!');
}catch(err){
console.error(err);
alert('Could not load JSON: ' + (err?.message || 'Invalid JSON file.'));
}
};
reader.readAsText(file);
});
document.getElementById('btn-clear-all').onclick = ()=>{
if(confirm('This will remove all fields, recipes, and layout from this browser. Continue?')){
state.fields = [];
state.recipes = [];
state.gridItems = [];
state.spreadMode = 'single';
state.activePage = 'L';
save();
renderFields(); renderPreview();
renderLayoutPool(); updateSpreadUI(); fitCanvasToViewport(); renderGrid();
renderRecipeForm(); renderRecipesTable(); renderReview();
}
};
// Init
load();
renderFields();
renderLayoutPool(); updateSpreadUI(); fitCanvasToViewport(); renderGrid();
renderRecipesTable();
renderReview();