Pages Medium
Admin — Menu Editor
Menu CRUD: category sidebar with item counts, drag-orderable item list, right-side edit panel with price, description, allergens, modifier groups and visibility toggle.
Open in Lab
MCP
html css vanilla-js
Targets: JS HTML
Code
:root {
--cream: #f5f0e8;
--cream-2: #ece4d4;
--bone: #faf7f1;
--terracotta: #c1714a;
--terracotta-d: #a05a38;
--forest: #2d4a3e;
--forest-d: #1e3329;
--gold: #c9a84c;
--gold-light: #e6c97a;
--ink: #2c1a0e;
--ink-2: #4a3828;
--warm-gray: #7a6a58;
--success: #4f7a3a;
--danger: #b3432a;
--warning: #d99020;
--font-display: "Playfair Display", Georgia, serif;
--font-body: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
}
body {
font-family: var(--font-body);
background: var(--cream);
color: var(--ink);
-webkit-font-smoothing: antialiased;
}
.app {
height: 100vh;
display: grid;
grid-template-columns: 248px 1fr;
}
/* Rail (shared) */
.rail {
background: var(--forest);
color: var(--bone);
display: flex;
flex-direction: column;
border-right: 1px solid var(--forest-d);
overflow-y: auto;
}
.rail-brand {
padding: 22px 22px 18px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid rgba(250, 247, 241, 0.1);
}
.brand-mark {
width: 36px;
height: 36px;
background: var(--gold);
color: var(--ink);
font-family: var(--font-display);
font-weight: 800;
border-radius: 8px;
display: grid;
place-items: center;
}
.brand-name {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.05rem;
}
.rail-nav {
flex: 1;
padding: 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.r-link {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: 8px;
text-decoration: none;
color: rgba(250, 247, 241, 0.78);
font-size: 0.92rem;
font-weight: 500;
}
.r-link:hover {
background: rgba(250, 247, 241, 0.06);
color: var(--bone);
}
.r-link.is-active {
background: var(--bone);
color: var(--forest-d);
font-weight: 700;
}
.r-icon {
width: 22px;
text-align: center;
}
.rail-foot {
padding: 14px 16px;
border-top: 1px solid rgba(250, 247, 241, 0.1);
}
.rail-user {
display: flex;
align-items: center;
gap: 10px;
}
.user-avatar {
width: 38px;
height: 38px;
background: var(--gold);
color: var(--ink);
border-radius: 999px;
display: grid;
place-items: center;
font-family: var(--font-display);
font-weight: 800;
}
.user-name {
font-size: 0.92rem;
font-weight: 700;
color: var(--bone);
}
.user-role {
font-size: 0.74rem;
color: rgba(250, 247, 241, 0.5);
}
/* Main */
.main {
display: flex;
flex-direction: column;
overflow: hidden;
}
.top {
padding: 22px 28px 14px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
flex-shrink: 0;
}
.kicker {
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--terracotta);
font-weight: 700;
}
.top h1 {
font-family: var(--font-display);
font-weight: 800;
font-size: 1.8rem;
letter-spacing: -0.01em;
}
.top-tools {
display: flex;
gap: 8px;
}
.ghost,
.primary {
border-radius: 999px;
padding: 9px 16px;
font-family: inherit;
font-size: 0.84rem;
font-weight: 700;
cursor: pointer;
border: 1px solid transparent;
}
.ghost {
background: var(--bone);
border-color: rgba(44, 26, 14, 0.12);
color: var(--ink-2);
}
.ghost:hover {
background: var(--cream-2);
color: var(--ink);
}
.primary {
background: var(--forest);
color: var(--bone);
}
.primary:hover:not(:disabled) {
background: var(--forest-d);
}
.primary:disabled,
.ghost:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-quiet {
background: transparent;
border: 1px dashed rgba(44, 26, 14, 0.2);
color: var(--ink-2);
padding: 8px 16px;
border-radius: 999px;
font-family: inherit;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
}
.btn-quiet:hover {
border-color: var(--terracotta);
color: var(--terracotta-d);
}
/* Editor three-col */
.editor {
flex: 1;
display: grid;
grid-template-columns: 220px 1fr 380px;
min-height: 0;
padding: 0 28px 28px;
gap: 14px;
}
/* Categories */
.cats {
background: var(--bone);
border: 1px solid rgba(44, 26, 14, 0.08);
border-radius: 14px;
padding: 14px 14px 10px;
overflow-y: auto;
}
.cats-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.cats-title {
font-size: 0.7rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--warm-gray);
font-weight: 700;
}
.add-btn {
width: 28px;
height: 28px;
background: var(--cream-2);
border: none;
border-radius: 8px;
font-family: var(--font-mono);
font-size: 1.05rem;
cursor: pointer;
color: var(--ink);
}
.add-btn:hover {
background: var(--terracotta);
color: var(--bone);
}
.cat-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
}
.cat-list li {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
color: var(--ink-2);
}
.cat-list li:hover {
background: var(--cream-2);
}
.cat-list li.is-active {
background: var(--forest);
color: var(--bone);
font-weight: 700;
}
.cat-list .count {
margin-left: auto;
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--warm-gray);
background: var(--cream);
padding: 2px 7px;
border-radius: 999px;
font-weight: 700;
}
.cat-list li.is-active .count {
background: rgba(250, 247, 241, 0.16);
color: var(--gold-light);
}
/* Items */
.items {
background: var(--bone);
border: 1px solid rgba(44, 26, 14, 0.08);
border-radius: 14px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.items-head {
padding: 16px 18px 12px;
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
border-bottom: 1px solid rgba(44, 26, 14, 0.08);
flex-shrink: 0;
}
.items-head h2 {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.2rem;
margin-top: 2px;
}
.items-tools {
display: flex;
gap: 6px;
align-items: center;
}
.items-tools input {
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.1);
padding: 8px 14px;
border-radius: 999px;
font-family: inherit;
font-size: 0.82rem;
color: var(--ink);
outline: none;
width: 160px;
}
.items-tools input:focus {
border-color: var(--terracotta);
}
.items-tools .primary {
padding: 8px 14px;
font-size: 0.8rem;
}
.item-list {
list-style: none;
flex: 1;
overflow-y: auto;
padding: 10px 14px;
}
.item-row {
display: grid;
grid-template-columns: 34px 1fr auto auto;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: 10px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.item-row:hover {
background: var(--cream-2);
}
.item-row.is-active {
background: var(--cream);
border-color: var(--terracotta);
}
.item-grip {
color: var(--warm-gray);
font-family: var(--font-mono);
font-size: 0.94rem;
cursor: grab;
user-select: none;
text-align: center;
}
.item-grip::before {
content: "⋮⋮";
letter-spacing: -2px;
}
.item-info {
min-width: 0;
}
.item-name {
font-weight: 700;
}
.item-meta {
font-size: 0.74rem;
color: var(--warm-gray);
margin-top: 2px;
}
.item-price {
font-family: var(--font-mono);
font-weight: 700;
color: var(--terracotta-d);
}
.item-vis {
width: 36px;
height: 22px;
border-radius: 999px;
background: var(--cream-2);
position: relative;
border: none;
cursor: pointer;
}
.item-vis::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
background: var(--bone);
border-radius: 999px;
transition: transform 0.18s;
}
.item-vis.is-on {
background: var(--forest);
}
.item-vis.is-on::after {
transform: translateX(14px);
background: var(--gold);
}
.empty {
flex: 1;
display: grid;
place-items: center;
padding: 24px;
color: var(--warm-gray);
font-style: italic;
}
/* Edit panel */
.edit {
background: var(--bone);
border: 1px solid rgba(44, 26, 14, 0.08);
border-radius: 14px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.edit-empty {
flex: 1;
display: grid;
place-items: center;
padding: 36px 24px;
color: var(--warm-gray);
font-style: italic;
text-align: center;
}
.edit-form {
display: flex;
flex-direction: column;
}
.edit-head {
padding: 18px 20px 14px;
border-bottom: 1px solid rgba(44, 26, 14, 0.08);
}
.edit-eyebrow {
font-size: 0.7rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--terracotta);
font-weight: 700;
}
.head-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 6px;
}
.title-input {
flex: 1;
font-family: var(--font-display);
font-weight: 700;
font-size: 1.35rem;
border: none;
background: transparent;
color: var(--ink);
outline: none;
padding: 0;
}
.title-input:focus {
outline: 1px dashed var(--terracotta);
outline-offset: 4px;
}
.vis-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--cream-2);
border: 1px solid rgba(44, 26, 14, 0.12);
border-radius: 999px;
padding: 5px 12px;
font-family: inherit;
font-size: 0.76rem;
font-weight: 700;
color: var(--ink-2);
cursor: pointer;
}
.vis-toggle .dot {
width: 8px;
height: 8px;
background: var(--success);
border-radius: 999px;
}
.vis-toggle[aria-pressed="false"] .dot {
background: var(--warm-gray);
}
.vis-toggle[aria-pressed="false"] .vis-label::before {
content: "Hidden";
}
.vis-toggle[aria-pressed="false"] .vis-label {
visibility: hidden;
}
.vis-toggle[aria-pressed="false"] .vis-label::before {
visibility: visible;
position: absolute;
}
/* Cleaner: just swap text via data-attr in JS */
.vis-toggle .vis-label {
position: relative;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
padding: 14px 20px 0;
}
.f {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 20px 0;
}
.f span {
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-2);
font-weight: 700;
}
.f input,
.f textarea,
.f select {
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.12);
border-radius: 8px;
padding: 10px 12px;
font-family: inherit;
font-size: 0.94rem;
color: var(--ink);
outline: none;
resize: vertical;
}
.f input:focus,
.f textarea:focus,
.f select:focus {
border-color: var(--terracotta);
}
.grid-2 .f {
padding: 0;
}
.prefix {
display: inline-flex;
align-items: stretch;
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.12);
border-radius: 8px;
overflow: hidden;
}
.prefix span {
padding: 0 12px;
font-family: var(--font-mono);
font-weight: 700;
color: var(--warm-gray);
display: grid;
place-items: center;
background: var(--cream-2);
}
.prefix input {
border: none;
background: transparent;
padding: 10px 12px;
font-family: var(--font-mono);
font-size: 0.94rem;
font-weight: 700;
width: 100%;
outline: none;
color: var(--ink);
}
.chips-field {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 14px 20px 0;
border: none;
}
.chips-field legend {
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-2);
font-weight: 700;
margin-bottom: 8px;
width: 100%;
}
.ch {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.12);
padding: 6px 12px;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.ch input {
margin: 0;
accent-color: var(--forest);
}
.ch:has(input:checked) {
background: var(--forest);
color: var(--bone);
border-color: var(--forest-d);
}
/* Mods */
.mods {
margin: 14px 20px 0;
border: 1px solid rgba(44, 26, 14, 0.1);
border-radius: 10px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.mods legend {
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-2);
font-weight: 700;
padding: 0 6px;
}
.mod-group {
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.08);
border-radius: 8px;
padding: 10px 12px;
}
.mod-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 6px;
}
.mod-head strong {
font-weight: 700;
}
.mod-meta {
font-size: 0.7rem;
color: var(--warm-gray);
font-style: italic;
}
.mod-group ul {
list-style: none;
}
.mod-group li {
display: flex;
justify-content: space-between;
padding: 5px 0;
font-size: 0.86rem;
border-bottom: 1px dashed rgba(44, 26, 14, 0.08);
}
.mod-group li:last-child {
border-bottom: none;
}
.mod-group .m {
font-family: var(--font-mono);
font-weight: 700;
color: var(--terracotta-d);
}
/* Edit foot */
.edit-foot {
position: sticky;
bottom: 0;
background: var(--bone);
border-top: 1px solid rgba(44, 26, 14, 0.08);
padding: 12px 20px;
margin-top: 16px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.save-meta {
font-size: 0.78rem;
color: var(--warm-gray);
}
.save-meta.is-dirty {
color: var(--warning);
font-weight: 700;
}
.foot-tools {
display: flex;
gap: 8px;
}
/* Toast */
.toast {
position: fixed;
bottom: 28px;
left: 50%;
transform: translateX(-50%);
background: var(--forest-d);
color: var(--bone);
padding: 10px 18px;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 600;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18);
z-index: 10;
}
/* Responsive */
@media (max-width: 1180px) {
.editor {
grid-template-columns: 200px 1fr 340px;
}
}
@media (max-width: 980px) {
.editor {
grid-template-columns: 1fr;
grid-template-rows: auto auto 1fr;
overflow: auto;
}
.cats {
max-height: 240px;
}
.items {
max-height: 380px;
}
}
@media (max-width: 720px) {
.app {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.rail {
flex-direction: row;
overflow-x: auto;
padding: 8px 12px;
border-right: none;
border-bottom: 1px solid var(--forest-d);
}
.rail-brand,
.rail-foot {
display: none;
}
.rail-nav {
flex-direction: row;
padding: 0;
}
}
/* Visibility guard: honor the [hidden] attribute over base display */
.empty[hidden],
.edit-form[hidden] {
display: none;
}
/* Modal overlay */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 40;
}
.modal-overlay[hidden] {
display: none;
}
/* Modal dialog */
dialog.modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 420px;
max-width: 90vw;
background: var(--bone);
border-radius: 12px;
padding: 1.75rem;
z-index: 41;
border: none;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.2);
margin: 0;
}
dialog.modal::backdrop {
display: none;
}
.modal-title {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.2rem;
color: var(--ink);
margin-bottom: 1.25rem;
}
.modal-form .field-label {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 0.875rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--ink-2);
}
.field-input {
border: 1px solid rgba(44, 26, 14, 0.12);
border-radius: 6px;
padding: 0.5rem 0.75rem;
font-size: 0.9375rem;
font-family: inherit;
width: 100%;
color: var(--ink);
background: var(--cream);
outline: none;
}
.field-input:focus {
border-color: var(--terracotta);
}
.field-icon {
width: 80px;
}
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.25rem;
}
/* Modal-scoped button variants (keep specificity low) */
.btn-ghost {
border-radius: 999px;
padding: 9px 16px;
font-family: inherit;
font-size: 0.84rem;
font-weight: 700;
cursor: pointer;
border: 1px solid rgba(44, 26, 14, 0.12);
background: var(--bone);
color: var(--ink-2);
}
.btn-ghost:hover {
background: var(--cream-2);
color: var(--ink);
}
.btn-primary {
border-radius: 999px;
padding: 9px 16px;
font-family: inherit;
font-size: 0.84rem;
font-weight: 700;
cursor: pointer;
border: 1px solid transparent;
background: var(--forest);
color: var(--bone);
}
.btn-primary:hover {
background: var(--forest-d);
}const CATS = [
{ id: "ent", name: "Entradas", course: "1st course" },
{ id: "pasta", name: "Pasta & arroces", course: "2nd course" },
{ id: "fuego", name: "Del fuego", course: "2nd course" },
{ id: "post", name: "Postres", course: "3rd course" },
{ id: "bev", name: "Bebidas", course: "Drink" },
{ id: "side", name: "Sides", course: "Side" },
];
const ITEMS = [
{
id: "pan",
cat: "ent",
name: "Pan de masa madre",
desc: "Sourdough loaf, smoked olive oil, sea salt.",
price: 8,
course: "1st course",
visible: true,
chips: ["veg"],
},
{
id: "bur",
cat: "ent",
name: "Burrata huerta",
desc: "Heirloom tomato, garden basil, focaccia crumble.",
price: 16,
course: "1st course",
visible: true,
chips: ["veg", "dairy"],
},
{
id: "pul",
cat: "ent",
name: "Pulpo a la brasa",
desc: "Charred octopus, smoked paprika potato, salsa verde.",
price: 19,
course: "1st course",
visible: true,
chips: ["gf"],
},
{
id: "cro",
cat: "ent",
name: "Croquetas de jamón",
desc: "Iberico ham croquettes, six pieces, lemon aioli.",
price: 14,
course: "1st course",
visible: true,
chips: ["dairy"],
},
{
id: "ens",
cat: "ent",
name: "Ensalada huerta",
desc: "Garden lettuces, radish, soft herbs, lemon-shallot.",
price: 13,
course: "1st course",
visible: true,
chips: ["vg", "gf"],
},
{
id: "pap",
cat: "pasta",
name: "Pappardelle al ragú",
desc: "Hand-cut pasta, slow-braised lamb shoulder.",
price: 24,
course: "2nd course",
visible: true,
chips: ["dairy"],
},
{
id: "ris",
cat: "pasta",
name: "Risotto de hongos",
desc: "Carnaroli rice, wild mushrooms, parmesan.",
price: 26,
course: "2nd course",
visible: true,
chips: ["veg", "dairy"],
},
{
id: "spa",
cat: "pasta",
name: "Spaghetti alle vongole",
desc: "Clams, white wine, parsley, breadcrumbs.",
price: 28,
course: "2nd course",
visible: true,
chips: [],
},
{
id: "rib",
cat: "fuego",
name: "Ribeye 14oz",
desc: "Dry-aged 28 days, bone marrow butter, chimichurri.",
price: 48,
course: "2nd course",
visible: true,
chips: ["gf", "signature"],
},
{
id: "bra",
cat: "fuego",
name: "Branzino entero",
desc: "Whole roasted sea bass, fennel, preserved lemon.",
price: 38,
course: "2nd course",
visible: true,
chips: ["gf"],
},
{
id: "pol",
cat: "fuego",
name: "Pollo al carbón",
desc: "Half free-range chicken, garlic confit.",
price: 28,
course: "2nd course",
visible: true,
chips: ["gf"],
},
{
id: "sal",
cat: "fuego",
name: "Salmón a la plancha",
desc: "Citrus glaze, charred lemon.",
price: 32,
course: "2nd course",
visible: false,
chips: ["gf"],
},
{
id: "cor",
cat: "fuego",
name: "Costilla de cordero",
desc: "Lamb ribs, honey-mint glaze, charred onion.",
price: 42,
course: "2nd course",
visible: true,
chips: ["gf"],
},
{
id: "tar",
cat: "post",
name: "Tarta de queso quemada",
desc: "Basque burnt cheesecake, salted caramel.",
price: 11,
course: "3rd course",
visible: true,
chips: ["veg", "dairy", "signature"],
},
{
id: "oli",
cat: "post",
name: "Olive oil cake",
desc: "Citrus olive oil cake, crème fraîche.",
price: 10,
course: "3rd course",
visible: true,
chips: ["veg", "dairy"],
},
{
id: "gan",
cat: "post",
name: "Chocolate ganache",
desc: "Bittersweet dark chocolate, hazelnut praline.",
price: 12,
course: "3rd course",
visible: true,
chips: ["veg", "gf", "nuts"],
},
{
id: "sor",
cat: "post",
name: "Sorbete cítrico",
desc: "Citrus, mint, today's batch.",
price: 9,
course: "3rd course",
visible: true,
chips: ["vg", "gf"],
},
{
id: "ver",
cat: "bev",
name: "Vermut de la casa",
desc: "House vermouth on tap.",
price: 9,
course: "Drink",
visible: true,
chips: [],
},
{
id: "neg",
cat: "bev",
name: "Negroni sbagliato",
desc: "Campari, sweet vermouth, sparkling wine.",
price: 14,
course: "Drink",
visible: true,
chips: [],
},
{
id: "tin",
cat: "bev",
name: "Tinto natural (copa)",
desc: "Rotating natural red.",
price: 12,
course: "Drink",
visible: true,
chips: [],
},
{
id: "alb",
cat: "bev",
name: "Albariño (copa)",
desc: "Rías Baixas · 2023.",
price: 11,
course: "Drink",
visible: true,
chips: [],
},
{
id: "fri",
cat: "side",
name: "Truffle fries",
desc: "Parmesan, parsley.",
price: 8,
course: "Side",
visible: true,
chips: ["veg", "dairy"],
},
{
id: "asp",
cat: "side",
name: "Grilled asparagus",
desc: "Lemon, chilli oil.",
price: 7,
course: "Side",
visible: true,
chips: ["vg", "gf"],
},
];
let activeCat = "ent";
let activeItem = null;
let dirty = false;
let filter = "";
const catListEl = document.getElementById("catList");
const itemListEl = document.getElementById("itemList");
const itemEmpty = document.getElementById("itemEmpty");
const itemsKicker = document.getElementById("itemsKicker");
const itemsTitle = document.getElementById("itemsTitle");
const editEmpty = document.getElementById("editEmpty");
const editForm = document.getElementById("editForm");
const editEyebrow = document.getElementById("editEyebrow");
const editName = document.getElementById("editName");
const editPrice = document.getElementById("editPrice");
const editCourse = document.getElementById("editCourse");
const editDesc = document.getElementById("editDesc");
const editVisible = document.getElementById("editVisible");
const saveBtn = document.getElementById("saveBtn");
const discardBtn = document.getElementById("discardBtn");
const saveMeta = document.getElementById("saveMeta");
const searchEl = document.getElementById("itemSearch");
const toast = document.getElementById("toast");
// Modal refs
const modalOverlay = document.getElementById("modalOverlay");
const modalAddCat = document.getElementById("modalAddCat");
const formAddCat = document.getElementById("formAddCat");
const catNameInput = document.getElementById("catNameInput");
const catIconInput = document.getElementById("catIconInput");
const cancelAddCat = document.getElementById("cancelAddCat");
const modalAddItem = document.getElementById("modalAddItem");
const formAddItem = document.getElementById("formAddItem");
const iNameInput = document.getElementById("iNameInput");
const iPriceInput = document.getElementById("iPriceInput");
const iCatInput = document.getElementById("iCatInput");
const iDescInput = document.getElementById("iDescInput");
const cancelAddItem = document.getElementById("cancelAddItem");
function openModal(modal) {
modalOverlay.hidden = false;
modal.showModal();
const first = modal.querySelector("input, select, textarea, button");
if (first) first.focus();
}
function closeModal(modal) {
modal.close();
modalOverlay.hidden = true;
}
function countFor(catId) {
return ITEMS.filter((i) => i.cat === catId).length;
}
const DEFAULT_ICONS = { ent: "🥖", pasta: "🍝", fuego: "🔥", post: "🍰", bev: "🍷", side: "🥗" };
function renderCats() {
catListEl.innerHTML = CATS.map(
(c) => `<li class="${c.id === activeCat ? "is-active" : ""}" data-cat="${c.id}">
<span class="ic">${c.icon || DEFAULT_ICONS[c.id] || "📋"}</span>
<span>${c.name}</span>
<span class="count">${countFor(c.id)}</span>
</li>`
).join("");
const cat = CATS.find((c) => c.id === activeCat);
itemsKicker.textContent = cat ? cat.name : "—";
}
function renderItems() {
const q = filter.toLowerCase();
const list = ITEMS.filter(
(i) =>
i.cat === activeCat &&
(!q || i.name.toLowerCase().includes(q) || i.desc.toLowerCase().includes(q))
);
itemListEl.innerHTML = list
.map(
(i) => `<li class="item-row ${i.id === activeItem ? "is-active" : ""}" data-id="${i.id}">
<span class="item-grip" aria-hidden="true"></span>
<div class="item-info">
<p class="item-name">${i.name}${i.chips.includes("signature") ? ` <span style="color:var(--gold)">★</span>` : ""}</p>
<p class="item-meta">${i.course} · ${i.chips.filter((c) => c !== "signature").join(" · ") || "no tags"}</p>
</div>
<span class="item-price">$${i.price.toFixed(2)}</span>
<button class="item-vis ${i.visible ? "is-on" : ""}" data-action="vis" data-id="${i.id}" aria-label="Toggle visibility"></button>
</li>`
)
.join("");
itemEmpty.hidden = list.length > 0;
itemsTitle.textContent =
list.length === 0 ? "Empty section" : `${list.length} item${list.length === 1 ? "" : "s"}`;
}
function loadItem(id) {
activeItem = id;
const item = ITEMS.find((i) => i.id === id);
if (!item) {
editEmpty.hidden = false;
editForm.hidden = true;
return;
}
editEmpty.hidden = true;
editForm.hidden = false;
editEyebrow.textContent = `Editing · ${CATS.find((c) => c.id === item.cat)?.name}`;
editName.value = item.name;
editPrice.value = item.price;
editCourse.value = item.course;
editDesc.value = item.desc;
editVisible.setAttribute("aria-pressed", String(item.visible));
editVisible.querySelector(".vis-label").textContent = item.visible ? "Visible" : "Hidden";
editForm.querySelectorAll("[data-chip]").forEach((cb) => {
cb.checked = item.chips.includes(cb.dataset.chip);
});
dirty = false;
saveBtn.disabled = true;
discardBtn.disabled = true;
saveMeta.classList.remove("is-dirty");
saveMeta.textContent = "Last saved · just now";
renderItems(); // to mark active row
}
function markDirty() {
dirty = true;
saveBtn.disabled = false;
discardBtn.disabled = false;
saveMeta.classList.add("is-dirty");
saveMeta.textContent = "Unsaved changes";
}
catListEl.addEventListener("click", (e) => {
const li = e.target.closest("[data-cat]");
if (!li) return;
activeCat = li.dataset.cat;
activeItem = null;
editEmpty.hidden = false;
editForm.hidden = true;
renderCats();
renderItems();
});
itemListEl.addEventListener("click", (e) => {
const vis = e.target.closest("[data-action='vis']");
if (vis) {
const item = ITEMS.find((i) => i.id === vis.dataset.id);
if (item) {
item.visible = !item.visible;
renderItems();
if (activeItem === item.id) {
editVisible.setAttribute("aria-pressed", String(item.visible));
editVisible.querySelector(".vis-label").textContent = item.visible ? "Visible" : "Hidden";
}
showToast(`${item.name} is now ${item.visible ? "visible" : "hidden (86'd)"}.`);
}
e.stopPropagation();
return;
}
const row = e.target.closest("[data-id]");
if (row) loadItem(row.dataset.id);
});
editVisible.addEventListener("click", () => {
const next = editVisible.getAttribute("aria-pressed") !== "true";
editVisible.setAttribute("aria-pressed", String(next));
editVisible.querySelector(".vis-label").textContent = next ? "Visible" : "Hidden";
markDirty();
});
["input", "change"].forEach((evt) =>
editForm.addEventListener(evt, (e) => {
if (e.target === editVisible) return;
markDirty();
})
);
editForm.addEventListener("submit", (e) => {
e.preventDefault();
const item = ITEMS.find((i) => i.id === activeItem);
if (!item) return;
item.name = editName.value.trim() || item.name;
item.price = Number(editPrice.value) || item.price;
item.course = editCourse.value;
item.desc = editDesc.value.trim();
item.visible = editVisible.getAttribute("aria-pressed") === "true";
item.chips = [...editForm.querySelectorAll("[data-chip]:checked")].map((c) => c.dataset.chip);
dirty = false;
saveBtn.disabled = true;
discardBtn.disabled = true;
saveMeta.classList.remove("is-dirty");
const t = new Date();
saveMeta.textContent = `Saved · ${String(t.getHours()).padStart(2, "0")}:${String(t.getMinutes()).padStart(2, "0")}`;
renderItems();
renderCats();
showToast(`Saved · ${item.name}`);
});
discardBtn.addEventListener("click", () => {
if (activeItem) loadItem(activeItem);
});
document.getElementById("publish").addEventListener("click", () => {
showToast("Carta published · diners will see this on next refresh");
});
searchEl.addEventListener("input", (e) => {
filter = e.target.value.trim();
renderItems();
});
document.getElementById("addCat").addEventListener("click", () => {
openModal(modalAddCat);
});
document.getElementById("addItem").addEventListener("click", () => {
iCatInput.innerHTML = CATS.map(
(c) => `<option value="${c.id}">${c.name}</option>`
).join("");
openModal(modalAddItem);
});
cancelAddCat.addEventListener("click", () => closeModal(modalAddCat));
cancelAddItem.addEventListener("click", () => closeModal(modalAddItem));
modalOverlay.addEventListener("click", () => {
if (modalAddCat.open) closeModal(modalAddCat);
if (modalAddItem.open) closeModal(modalAddItem);
});
formAddCat.addEventListener("submit", (e) => {
e.preventDefault();
const name = catNameInput.value.trim();
const icon = catIconInput.value.trim() || "📋";
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
CATS.push({ id, name, icon, course: "1st course" });
renderCats();
renderItems();
closeModal(modalAddCat);
formAddCat.reset();
showToast(`Category "${name}" added`);
});
formAddItem.addEventListener("submit", (e) => {
e.preventDefault();
const name = iNameInput.value.trim();
const price = Number(iPriceInput.value) || 0;
const catId = iCatInput.value;
const desc = iDescInput.value.trim();
const newItem = {
id: Date.now().toString(36),
name,
price,
course: "mains",
desc,
cat: catId,
visible: true,
chips: [],
};
ITEMS.push(newItem);
const catName = CATS.find((c) => c.id === catId)?.name || catId;
renderCats();
renderItems();
closeModal(modalAddItem);
formAddItem.reset();
showToast(`"${name}" added to ${catName}`);
});
function showToast(msg) {
toast.textContent = msg;
toast.hidden = false;
clearTimeout(showToast._t);
showToast._t = setTimeout(() => (toast.hidden = true), 2400);
}
renderCats();
renderItems();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;800&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;600;700&display=swap"
/>
<link rel="stylesheet" href="style.css" />
<title>Menu editor · Casa Olivar Admin</title>
</head>
<body>
<div class="app">
<aside class="rail">
<header class="rail-brand">
<span class="brand-mark">CO</span>
<span class="brand-name">Casa Olivar</span>
</header>
<nav class="rail-nav">
<a class="r-link" href="#"><span class="r-icon">📊</span><span>Dashboard</span></a>
<a class="r-link" href="#"><span class="r-icon">📅</span><span>Reservations</span></a>
<a class="r-link is-active" href="#"><span class="r-icon">🍽</span><span>Menu</span></a>
<a class="r-link" href="#"><span class="r-icon">📦</span><span>Inventory</span></a>
<a class="r-link" href="#"><span class="r-icon">👥</span><span>Staff</span></a>
</nav>
<footer class="rail-foot">
<div class="rail-user">
<span class="user-avatar">L</span>
<div>
<p class="user-name">Lina Mendoza</p>
<p class="user-role">Sous-chef</p>
</div>
</div>
</footer>
</aside>
<main class="main">
<header class="top">
<div>
<p class="kicker">Menu · Spring carta · v. 12 May 2026</p>
<h1>Menu editor</h1>
</div>
<div class="top-tools">
<button class="ghost" type="button">Preview as customer</button>
<button class="primary" type="button" id="publish">Publish carta</button>
</div>
</header>
<section class="editor">
<!-- Categories -->
<aside class="cats" aria-label="Categories">
<header class="cats-head">
<p class="cats-title">Categories</p>
<button class="add-btn" type="button" id="addCat">+</button>
</header>
<ul class="cat-list" id="catList"></ul>
</aside>
<!-- Items list -->
<section class="items">
<header class="items-head">
<div>
<p class="kicker" id="itemsKicker">Entradas</p>
<h2 id="itemsTitle">Items in this section</h2>
</div>
<div class="items-tools">
<input id="itemSearch" placeholder="Filter…" type="search" />
<button class="primary" type="button" id="addItem">+ Add item</button>
</div>
</header>
<ul class="item-list" id="itemList"></ul>
<p class="empty" id="itemEmpty" hidden>No items in this section.</p>
</section>
<!-- Edit panel -->
<section class="edit" id="edit">
<p class="edit-empty" id="editEmpty">Select an item on the left to edit.</p>
<form class="edit-form" id="editForm" hidden>
<header class="edit-head">
<p class="edit-eyebrow" id="editEyebrow">Editing</p>
<div class="head-row">
<input class="title-input" id="editName" placeholder="Dish name" />
<button class="vis-toggle" type="button" id="editVisible" aria-pressed="true">
<span class="dot"></span>
<span class="vis-label">Visible</span>
</button>
</div>
</header>
<div class="grid-2">
<label class="f">
<span>Price</span>
<div class="prefix">
<span>$</span>
<input id="editPrice" type="number" min="0" step="0.5" />
</div>
</label>
<label class="f">
<span>Course label</span>
<select id="editCourse">
<option>1st course</option>
<option>2nd course</option>
<option>3rd course</option>
<option>Side</option>
<option>Drink</option>
</select>
</label>
</div>
<label class="f">
<span>Description (visible on the carta)</span>
<textarea id="editDesc" rows="2" maxlength="160"></textarea>
</label>
<fieldset class="chips-field">
<legend>Allergens & diet</legend>
<label class="ch"><input type="checkbox" data-chip="vg" /> Vegan</label>
<label class="ch"><input type="checkbox" data-chip="veg" /> Vegetarian</label>
<label class="ch"><input type="checkbox" data-chip="gf" /> Gluten-free</label>
<label class="ch"><input type="checkbox" data-chip="dairy" /> Contains dairy</label>
<label class="ch"><input type="checkbox" data-chip="nuts" /> Contains nuts</label>
<label class="ch"><input type="checkbox" data-chip="spicy" /> Spicy</label>
<label class="ch"><input type="checkbox" data-chip="signature" /> Signature ★</label>
</fieldset>
<fieldset class="mods">
<legend>Modifier groups</legend>
<div class="mod-group">
<div class="mod-head">
<strong>Doneness</strong>
<span class="mod-meta">single-select · required</span>
</div>
<ul>
<li><span>Rare</span><span class="m">$0.00</span></li>
<li><span>Medium rare</span><span class="m">$0.00</span></li>
<li><span>Medium</span><span class="m">$0.00</span></li>
<li><span>Well done</span><span class="m">$0.00</span></li>
</ul>
</div>
<div class="mod-group">
<div class="mod-head">
<strong>Extras</strong>
<span class="mod-meta">multi · max 3</span>
</div>
<ul>
<li><span>Extra chimichurri</span><span class="m">+$2.00</span></li>
<li><span>Roasted bone marrow</span><span class="m">+$6.00</span></li>
<li><span>Black truffle shave</span><span class="m">+$9.00</span></li>
</ul>
</div>
<button class="btn-quiet" type="button">+ Add modifier group</button>
</fieldset>
<footer class="edit-foot">
<p class="save-meta" id="saveMeta">Last saved · just now</p>
<div class="foot-tools">
<button class="ghost" type="button" id="discardBtn" disabled>Discard</button>
<button class="primary" type="submit" id="saveBtn" disabled>Save changes</button>
</div>
</footer>
</form>
</section>
</section>
</main>
<div class="toast" id="toast" hidden role="status" aria-live="polite"></div>
</div>
<div class="modal-overlay" id="modalOverlay" hidden></div>
<dialog class="modal" id="modalAddCat">
<h2 class="modal-title">New category</h2>
<form id="formAddCat" class="modal-form">
<label class="field-label">Category name <input class="field-input" id="catNameInput" type="text" placeholder="e.g. Sides" required maxlength="40" /></label>
<label class="field-label">Icon (emoji) <input class="field-input field-icon" id="catIconInput" type="text" placeholder="🥗" maxlength="4" /></label>
<div class="modal-actions">
<button type="button" class="btn-ghost" id="cancelAddCat">Cancel</button>
<button type="submit" class="btn-primary">Add category</button>
</div>
</form>
</dialog>
<dialog class="modal" id="modalAddItem">
<h2 class="modal-title">New item</h2>
<form id="formAddItem" class="modal-form">
<label class="field-label">Item name <input class="field-input" id="iNameInput" type="text" placeholder="e.g. Tartare" required maxlength="60" /></label>
<div class="field-row">
<label class="field-label">Price ($) <input class="field-input" id="iPriceInput" type="number" placeholder="18" min="0" step="0.01" required /></label>
<label class="field-label">Category
<select class="field-input" id="iCatInput"></select>
</label>
</div>
<label class="field-label">Description <input class="field-input" id="iDescInput" type="text" placeholder="Short description" maxlength="80" /></label>
<div class="modal-actions">
<button type="button" class="btn-ghost" id="cancelAddItem">Cancel</button>
<button type="submit" class="btn-primary">Add item</button>
</div>
</form>
</dialog>
<script src="script.js"></script>
</body>
</html>Admin · Menu Editor
The CRUD panel a chef uses to tweak tonight’s carta. Three-column layout: category sidebar with item counts on the left, item list with toggle (visible / 86’d) in the centre, edit panel on the right with price, description, allergen chips, modifier groups and per-modifier price deltas. Save bar at the bottom of the edit panel disables until something changes, and shows a “saved at HH:MM” timestamp after each save.