Files
bonpin-adrian-meal-api/index.html
T
adrianbonpin a7e7ad238d Add zod-validated CRUD, OpenAPI docs and a styled CRUD frontend
- Define the Meal once as a zod v4 schema and derive everything from it:
  request validation, response validation, types and the OpenAPI spec.
- Full CRUD on /api/menu: list (filter/search/sort), get one, create, replace,
  update and delete, while keeping the original GET error messages intact.
- Normalize Elysia's 422 validation failures into 400 with a per-field
  `fields` map the frontend renders under each input.
- Add the @elysiajs/openapi plugin: Scalar UI at /openapi, spec at
  /openapi/json, with zod -> JSON Schema via mapJsonSchema.
- Rebuild index.html on the it3206-minisys design language (dark, white/10
  hairlines) with create/edit/delete, filters and a two-step delete confirm.
- Read index.html per request so frontend edits need no restart.
2026-09-16 20:26:04 +08:00

1064 lines
28 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark">
<title>Food Menu · Meal API</title>
<style>
/* ---------------------------------------------------------------------
Design tokens, mirroring the it3206-minisys look:
neutral-900 page, white text, white/10 hairlines, white/5 fills.
--------------------------------------------------------------------- */
:root {
--bg: #171717; /* neutral-900 */
--panel: rgba(255, 255, 255, 0.05); /* white/5 */
--panel-hover: rgba(255, 255, 255, 0.05);
--line: rgba(255, 255, 255, 0.1); /* white/10 */
--line-strong: rgba(255, 255, 255, 0.15);
--on: #ffffff;
--muted: rgba(255, 255, 255, 0.5); /* white/50 */
--muted-2: rgba(255, 255, 255, 0.4); /* white/40 */
--muted-3: rgba(255, 255, 255, 0.3); /* white/30 */
--danger: #f87171; /* red-400 */
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100svh;
padding: 6rem 1rem 2rem; /* clears the fixed navbar (pt-24) */
display: flex;
flex-direction: column;
align-items: center;
background: var(--bg);
color: var(--on);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
}
/* ---- navbar ---------------------------------------------------------- */
.nav {
position: fixed;
left: 0;
top: 0;
z-index: 50;
margin-top: 0.5rem;
width: 100%;
display: flex;
justify-content: center;
padding: 0 0.75rem;
}
.nav-inner {
width: 100%;
max-width: 48rem; /* max-w-3xl */
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius-xl);
background: rgba(23, 23, 23, 0.8);
backdrop-filter: blur(8px);
}
.brand {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
text-decoration: none;
color: var(--on);
}
.nav-links {
display: flex;
gap: 0.25rem;
}
.nav-link {
padding: 0.25rem 0.625rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
text-decoration: none;
transition: color 0.15s, background-color 0.15s;
}
.nav-link:hover {
color: var(--on);
}
.nav-link.is-active {
background: rgba(255, 255, 255, 0.1);
color: var(--on);
}
/* ---- layout ---------------------------------------------------------- */
main {
width: 100%;
max-width: 48rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
}
.page-head {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.025em;
}
h2 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.subtitle {
margin: 0;
font-size: 0.875rem;
color: var(--muted);
}
.subtle {
font-size: 0.75rem;
color: var(--muted-3);
}
/* ---- controls -------------------------------------------------------- */
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: rgba(23, 23, 23, 0.6);
color: var(--on);
font: inherit;
font-size: 0.875rem;
outline: none;
transition: border-color 0.15s;
}
.input::placeholder {
color: var(--muted-3);
}
.input:focus {
border-color: var(--muted-2);
}
.toolbar .input {
flex: 1 1 10rem;
width: auto;
}
.toolbar #f-category,
.toolbar #f-available {
flex: 0 1 auto;
width: auto;
min-width: 9rem;
}
select.input option {
background: #171717;
}
textarea.input {
resize: vertical;
min-height: 2.75rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius-lg);
font: inherit;
font-size: 0.875rem;
cursor: pointer;
white-space: nowrap;
transition: background-color 0.15s, border-color 0.15s, color 0.15s;
}
.btn-primary {
background: var(--on);
color: #171717;
font-weight: 600;
}
.btn-primary:hover {
background: rgba(255, 255, 255, 0.9);
}
.btn-ghost {
border: 1px solid var(--line-strong);
background: transparent;
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
}
.btn-ghost:hover {
border-color: rgba(255, 255, 255, 0.3);
color: var(--on);
}
.btn-danger {
background: transparent;
border: 1px solid rgba(248, 113, 113, 0.35);
color: var(--danger);
font-weight: 500;
}
.btn-danger:hover {
background: rgba(248, 113, 113, 0.1);
border-color: var(--danger);
}
/* ---- panels, fields, errors ------------------------------------------ */
.panel {
padding: 1.25rem;
border: 1px solid var(--line);
border-radius: var(--radius-xl);
background: var(--panel);
}
.panel-head {
display: flex;
flex-direction: column;
gap: 0.15rem;
margin-bottom: 1rem;
}
.grid {
display: grid;
gap: 1rem;
}
@media (min-width: 640px) {
.grid {
grid-template-columns: 1fr 1fr;
}
.span-2 {
grid-column: span 2;
}
}
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.label {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.error {
margin: 0;
font-size: 0.75rem;
color: var(--danger);
}
.checkbox {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
}
.checkbox input {
width: 1rem;
height: 1rem;
accent-color: #ffffff;
cursor: pointer;
}
.form-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-top: 1.25rem;
}
/* ---- notice ---------------------------------------------------------- */
.notice {
margin: 0;
padding: 0.625rem 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: var(--panel);
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.8);
}
.notice.is-error {
border-color: rgba(248, 113, 113, 0.35);
background: rgba(248, 113, 113, 0.1);
color: var(--danger);
}
/* ---- table ----------------------------------------------------------- */
.table-wrap {
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-xl);
background: var(--panel);
}
.table-scroll {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
border-bottom: 1px solid var(--line);
background: var(--panel);
}
th {
padding: 0.75rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted-2); /* white/40 */
white-space: nowrap;
}
th.right,
td.right {
text-align: right;
}
tbody tr {
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: background-color 0.15s;
}
tbody tr:last-child {
border-bottom: none;
}
tbody tr:hover {
background: var(--panel-hover);
}
td {
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.8);
vertical-align: top;
}
.meal-name {
font-weight: 500;
color: var(--on);
}
.meal-note {
margin-top: 0.15rem;
font-size: 0.75rem;
color: var(--muted-3);
}
.num {
font-variant-numeric: tabular-nums;
color: var(--muted-2);
}
.badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--panel);
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.8);
white-space: nowrap;
}
.badge.is-off {
color: var(--muted-3);
border-style: dashed;
}
.row-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
white-space: nowrap;
}
.link-btn {
padding: 0;
border: none;
background: none;
font: inherit;
font-size: 0.75rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
text-decoration: underline;
text-underline-offset: 4px;
cursor: pointer;
transition: color 0.15s;
}
.link-btn:hover {
color: var(--on);
}
.link-btn.is-danger {
color: var(--danger);
}
/* ---- empty state ----------------------------------------------------- */
.empty {
padding: 3.5rem 1.5rem;
border: 1px dashed var(--line-strong);
border-radius: var(--radius-xl);
background: var(--panel);
text-align: center;
}
.empty p {
margin: 0;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.6);
}
.empty p + p {
margin-top: 0.25rem;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.35);
}
/* ---- footer ---------------------------------------------------------- */
footer {
width: 100%;
max-width: 48rem;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--line);
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--muted-3);
}
footer a {
color: var(--muted-2);
text-decoration: underline;
text-underline-offset: 4px;
}
footer a:hover {
color: var(--on);
}
[hidden] {
display: none !important;
}
</style>
</head>
<body>
<nav class="nav">
<div class="nav-inner">
<a class="brand" href="/">Meal API</a>
<div class="nav-links">
<a class="nav-link is-active" href="/" aria-current="page">menu</a>
<a class="nav-link" href="/openapi" target="_blank" rel="noreferrer">docs</a>
</div>
</div>
</nav>
<main>
<header class="page-head">
<h1>Meals</h1>
<p class="subtitle" id="count">Loading…</p>
</header>
<p class="notice" id="notice" role="status" aria-live="polite" hidden></p>
<div class="toolbar">
<input class="input" id="f-search" type="search" placeholder="Search meals…" autocomplete="off" aria-label="Search meals">
<select class="input" id="f-category" aria-label="Filter by category">
<option value="">All categories</option>
<option value="appetizer">appetizer</option>
<option value="main">main</option>
<option value="dessert">dessert</option>
<option value="drink">drink</option>
</select>
<select class="input" id="f-available" aria-label="Filter by availability">
<option value="">Any availability</option>
<option value="true">Available</option>
<option value="false">Unavailable</option>
</select>
<button class="btn btn-ghost" id="f-reset" type="button">reset</button>
<button class="btn btn-primary" id="open-create" type="button">Add meal</button>
</div>
<section class="panel" id="panel-form" hidden>
<div class="panel-head">
<h2 id="form-title">Add a meal</h2>
<p class="subtitle" id="form-subtitle">Saved through <code>POST /api/menu</code>.</p>
</div>
<form id="meal-form" novalidate>
<div class="grid">
<div class="field span-2">
<label class="label" for="m-name">Name</label>
<input class="input" id="m-name" name="name" maxlength="80" placeholder="e.g. Chicken Adobo" autocomplete="off">
<p class="error" id="err-name" hidden></p>
</div>
<div class="field">
<label class="label" for="m-category">Category</label>
<select class="input" id="m-category" name="category">
<option value="">Select a category</option>
<option value="appetizer">appetizer</option>
<option value="main">main</option>
<option value="dessert">dessert</option>
<option value="drink">drink</option>
</select>
<p class="error" id="err-category" hidden></p>
</div>
<div class="field">
<label class="label" for="m-price">Price (₱)</label>
<input class="input" id="m-price" name="price" type="number" min="0" max="100000" step="0.01" placeholder="75">
<p class="error" id="err-price" hidden></p>
</div>
<div class="field span-2">
<label class="label" for="m-description">Description</label>
<textarea class="input" id="m-description" name="description" rows="2" maxlength="240" placeholder="Optional — what's in it?"></textarea>
<p class="error" id="err-description" hidden></p>
</div>
<div class="field span-2">
<label class="checkbox">
<input type="checkbox" id="m-available" name="available" checked>
<span>Available today</span>
</label>
<p class="error" id="err-available" hidden></p>
</div>
</div>
<div class="form-actions">
<button class="btn btn-primary" id="form-submit" type="submit">Save meal</button>
<button class="btn btn-ghost" id="form-cancel" type="button" hidden>cancel</button>
</div>
</form>
</section>
<div class="table-wrap" id="table-wrap" hidden>
<div class="table-scroll">
<table>
<thead>
<tr>
<th>ID</th>
<th>Meal</th>
<th>Category</th>
<th>Price</th>
<th>Availability</th>
<th class="right"><span class="subtle">Actions</span></th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
</div>
<div class="empty" id="empty" hidden>
<p id="empty-title">No meals to show.</p>
<p id="empty-hint">Use <span>Add meal</span> to create the first one.</p>
</div>
</main>
<footer>
<span>Elysia + zod · <span id="meal-total">0</span> meals on the menu</span>
<span><a href="/openapi" target="_blank" rel="noreferrer">OpenAPI docs</a> · <a href="/openapi/json" target="_blank" rel="noreferrer">spec</a></span>
</footer>
<script>
const els = {
count: document.getElementById('count'),
notice: document.getElementById('notice'),
rows: document.getElementById('rows'),
tableWrap: document.getElementById('table-wrap'),
empty: document.getElementById('empty'),
emptyTitle: document.getElementById('empty-title'),
emptyHint: document.getElementById('empty-hint'),
total: document.getElementById('meal-total'),
panel: document.getElementById('panel-form'),
form: document.getElementById('meal-form'),
formTitle: document.getElementById('form-title'),
formSubtitle: document.getElementById('form-subtitle'),
submit: document.getElementById('form-submit'),
cancel: document.getElementById('form-cancel'),
openCreate: document.getElementById('open-create'),
search: document.getElementById('f-search'),
category: document.getElementById('f-category'),
available: document.getElementById('f-available'),
reset: document.getElementById('f-reset'),
fields: {
name: document.getElementById('m-name'),
category: document.getElementById('m-category'),
price: document.getElementById('m-price'),
description: document.getElementById('m-description'),
available: document.getElementById('m-available'),
},
}
const state = {
meals: [],
editing: null, // the meal being edited, or null when creating
armed: null, // id whose delete button is waiting for confirmation
}
// ---- talking to the API -------------------------------------------------
/** fetch + JSON, never throwing on an error status. */
async function api(path, options = {}) {
const init = { ...options }
if (init.body !== undefined) {
init.headers = { 'content-type': 'application/json', ...init.headers }
init.body = JSON.stringify(init.body)
}
try {
const res = await fetch(path, init)
const text = await res.text()
let body = null
try {
body = text ? JSON.parse(text) : null
} catch {
body = text
}
return { ok: res.ok, status: res.status, body }
} catch {
return { ok: false, status: 0, body: { error: 'Could not reach the API. Is the server running?' } }
}
}
function queryString() {
const params = new URLSearchParams()
const search = els.search.value.trim()
if (search) params.set('search', search)
if (els.category.value) params.set('category', els.category.value)
if (els.available.value) params.set('available', els.available.value)
const qs = params.toString()
return qs ? `?${qs}` : ''
}
function hasFilters() {
return Boolean(els.search.value.trim() || els.category.value || els.available.value)
}
// ---- rendering ----------------------------------------------------------
/** Built with createElement/textContent so meal text can never inject markup. */
function row(meal) {
const tr = document.createElement('tr')
const id = document.createElement('td')
id.className = 'num'
id.textContent = meal.id
const name = document.createElement('td')
const nameText = document.createElement('div')
nameText.className = 'meal-name'
nameText.textContent = meal.name
name.append(nameText)
if (meal.description) {
const note = document.createElement('div')
note.className = 'meal-note'
note.textContent = meal.description
name.append(note)
}
const category = document.createElement('td')
const badge = document.createElement('span')
badge.className = 'badge'
badge.textContent = meal.category
category.append(badge)
const price = document.createElement('td')
price.className = 'num'
price.textContent = `₱${meal.price.toFixed(2)}`
const availability = document.createElement('td')
const status = document.createElement('span')
status.className = meal.available ? 'badge' : 'badge is-off'
status.textContent = meal.available ? 'available' : 'unavailable'
availability.append(status)
const actions = document.createElement('td')
actions.className = 'right'
actions.append(actionButtons(meal))
tr.append(id, name, category, price, availability, actions)
return tr
}
function actionButtons(meal) {
const wrap = document.createElement('div')
wrap.className = 'row-actions'
if (state.armed === meal.id) {
const confirm = document.createElement('button')
confirm.type = 'button'
confirm.className = 'link-btn is-danger'
confirm.textContent = 'confirm delete'
confirm.setAttribute('aria-label', `Confirm deleting ${meal.name}`)
confirm.addEventListener('click', () => remove(meal.id))
const cancel = document.createElement('button')
cancel.type = 'button'
cancel.className = 'link-btn'
cancel.textContent = 'keep'
cancel.setAttribute('aria-label', `Keep ${meal.name}`)
cancel.addEventListener('click', () => {
state.armed = null
render()
})
wrap.append(confirm, cancel)
return wrap
}
const edit = document.createElement('button')
edit.type = 'button'
edit.className = 'link-btn'
edit.textContent = 'edit'
edit.setAttribute('aria-label', `Edit ${meal.name}`)
edit.addEventListener('click', () => startEdit(meal.id))
const del = document.createElement('button')
del.type = 'button'
del.className = 'link-btn'
del.textContent = 'delete'
del.setAttribute('aria-label', `Delete ${meal.name}`)
del.addEventListener('click', () => {
state.armed = meal.id
render()
})
wrap.append(edit, del)
return wrap
}
function render() {
els.rows.replaceChildren(...state.meals.map(row))
els.tableWrap.hidden = state.meals.length === 0
els.empty.hidden = state.meals.length > 0
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
const filtered = hasFilters()
els.count.textContent = filtered
? `${plural(state.meals.length, 'meal')} ${state.meals.length === 1 ? 'matches' : 'match'} the current filters.`
: `${plural(state.meals.length, 'meal')} on the menu.`
els.total.textContent = state.meals.length
els.emptyTitle.textContent = filtered ? 'No meals match those filters.' : 'No meals to show.'
els.emptyHint.replaceChildren(
...(filtered
? [document.createTextNode('Try '), hint('reset'), document.createTextNode(' to clear them.')]
: [document.createTextNode('Use '), hint('Add meal'), document.createTextNode(' to create the first one.')]),
)
}
function hint(text) {
const span = document.createElement('span')
span.textContent = text
return span
}
function setNotice(text, kind = 'info') {
els.notice.textContent = text
els.notice.className = kind === 'error' ? 'notice is-error' : 'notice'
els.notice.hidden = false
}
function clearNotice() {
els.notice.hidden = true
}
// ---- loading ------------------------------------------------------------
async function load() {
const { ok, body } = await api(`/api/menu${queryString()}`)
if (!ok) {
state.meals = []
render()
setNotice(body?.error ?? 'Could not load the menu.', 'error')
return
}
state.meals = body
render()
}
let searchTimer
els.search.addEventListener('input', () => {
clearTimeout(searchTimer)
searchTimer = setTimeout(load, 250)
})
els.category.addEventListener('change', load)
els.available.addEventListener('change', load)
els.reset.addEventListener('click', () => {
els.search.value = ''
els.category.value = ''
els.available.value = ''
clearNotice()
load()
})
// ---- create / edit form -------------------------------------------------
function clearFieldErrors() {
for (const key of Object.keys(els.fields)) {
const message = document.getElementById(`err-${key}`)
message.textContent = ''
message.hidden = true
}
}
function showFieldErrors(body) {
const fields = body?.fields ?? {}
let focused = false
for (const [key, message] of Object.entries(fields)) {
const target = document.getElementById(`err-${key}`)
if (!target) continue
target.textContent = message
target.hidden = false
if (!focused && els.fields[key]) {
els.fields[key].focus()
focused = true
}
}
setNotice(body?.error ?? 'Please fix the highlighted fields.', 'error')
}
function readForm() {
const price = els.fields.price.value.trim()
return {
name: els.fields.name.value,
category: els.fields.category.value,
// "" is sent as-is so the API answers "Price is required." instead of ₱0.
price: price === '' ? '' : Number(price),
description: els.fields.description.value,
available: els.fields.available.checked,
}
}
function fillForm(meal) {
els.fields.name.value = meal.name
els.fields.category.value = meal.category
els.fields.price.value = meal.price
els.fields.description.value = meal.description
els.fields.available.checked = meal.available
}
function openForm() {
els.panel.hidden = false
els.panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
function closeForm() {
state.editing = null
els.form.reset()
els.fields.available.checked = true
clearFieldErrors()
els.panel.hidden = true
}
els.openCreate.addEventListener('click', () => {
closeForm()
clearNotice()
els.formTitle.textContent = 'Add a meal'
els.formSubtitle.replaceChildren(document.createTextNode('Saved through '), code('POST /api/menu'), document.createTextNode('.'))
els.submit.textContent = 'Save meal'
els.cancel.hidden = true
openForm()
els.fields.name.focus()
})
els.cancel.addEventListener('click', () => {
closeForm()
clearNotice()
})
async function startEdit(id) {
const { ok, body } = await api(`/api/menu/${id}`)
if (!ok) {
setNotice(body?.error ?? 'Could not load that meal.', 'error')
return
}
clearFieldErrors()
clearNotice()
state.editing = body
fillForm(body)
els.formTitle.textContent = `Edit ${body.name}`
els.formSubtitle.replaceChildren(
document.createTextNode('Only the fields you change are sent, through '),
code(`PATCH /api/menu/${body.id}`),
document.createTextNode('.'),
)
els.submit.textContent = 'Save changes'
els.cancel.hidden = false
openForm()
els.fields.name.focus()
}
function code(text) {
const el = document.createElement('code')
el.textContent = text
return el
}
/** Only the fields that actually changed, so PATCH stays a partial update. */
function changedFields(meal, values) {
const patch = {}
if (values.name !== meal.name) patch.name = values.name
if (values.category !== meal.category) patch.category = values.category
if (values.price !== meal.price) patch.price = values.price
if (values.description !== meal.description) patch.description = values.description
if (values.available !== meal.available) patch.available = values.available
return patch
}
els.form.addEventListener('submit', async (event) => {
event.preventDefault()
clearFieldErrors()
clearNotice()
const values = readForm()
if (state.editing) {
const patch = changedFields(state.editing, values)
if (Object.keys(patch).length === 0) {
setNotice('Nothing changed yet.')
return
}
const { ok, body } = await api(`/api/menu/${state.editing.id}`, { method: 'PATCH', body: patch })
if (!ok) {
await load()
showFieldErrors(body)
return
}
closeForm()
await load()
setNotice(`Updated “${body.name}”.`)
return
}
const { ok, body } = await api('/api/menu', { method: 'POST', body: values })
if (!ok) {
showFieldErrors(body)
return
}
closeForm()
await load()
setNotice(`Added “${body.name}” as meal #${body.id}.`)
})
// ---- delete -------------------------------------------------------------
async function remove(id) {
const { ok, body } = await api(`/api/menu/${id}`, { method: 'DELETE' })
state.armed = null
if (!ok) {
render()
setNotice(body?.error ?? 'Could not delete that meal.', 'error')
return
}
if (state.editing?.id === id) {
closeForm()
clearNotice()
}
await load()
setNotice('Meal deleted.')
}
load()
</script>
</body>
</html>