- 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.
1064 lines
28 KiB
HTML
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>
|