feat(cms): q-tree menu builder

Replace the flat sidebar + dead-subcategory-modal with a real
arbitrary-depth tree builder using Quasar's q-tree.

templates/restaurant/menu.html:
  Three-pane layout (sidebar / tree / items).
  q-tree binds to the hydrated tree returned by
  GET /api/v1/restaurants/{id}/menu. Custom default-header slot
  renders the node name + an item-count badge + a child-count
  hint, with inline buttons:
    add (disabled at depth 3),
    edit, drive_file_move, delete (with cascade prompt).
  Top-level button above the tree adds root nodes.
  Items pane filters to the selected node, with a 'New item' that
  opens the item dialog with node_id pre-selected. The item
  dialog's node_id picker is a flat-indented q-select of every
  node in the restaurant (em-space indentation per depth level).

  A dedicated Move dialog uses the same flat-indented picker, but
  filters out the moved node + its descendants and any depth-3
  candidate (cycle / depth pre-checks; server enforces both too).

static/js/menu.js:
  Vue 3 + Quasar 2 UMD. Loads {tree, items} once, builds a
  flatNodes index for the option lists, and refetches after every
  mutation (≤50 nodes per restaurant — trivial; SSE/Nostr push is
  v2). Helpers:
    _findNode    — recursive lookup by id
    _flatten     — depth-first walk producing the option list
    selectedNode / filteredItems / allNodeOptions /
    moveTargetOptions / adminkey computeds.
  Delete prompts surface child-count + item-count and pass
  cascade=true when needed.

CMS now lets the operator build menus like
    Drinks
      ├─ Hot Beverages
      │   ├─ Coffee-based
      │   └─ Cacao-based
      └─ Cold (with its own items)
including items at any non-leaf level, satisfying the design
constraint.
This commit is contained in:
Padreug 2026-05-02 09:10:41 +02:00
commit 4827f5e10f
2 changed files with 358 additions and 96 deletions

View file

@ -1,16 +1,46 @@
/*
* Menu builder q-tree + items panel.
*
* The server's GET /api/v1/restaurants/{id}/menu returns:
* { restaurant, tree: [<root MenuNode>], items: [<enriched item>] }
* `tree` is a hydrated tree (each node has children + items already
* attached, plus depth + path). `items` is the flat enriched list
* (with modifier groups, modifier options, availability windows
* pre-joined) used here to populate the items panel by node_id.
*
* Tree mutations (create / rename / move / delete) hit the
* /api/v1/menu_nodes/* endpoints. We refetch the whole menu after
* each mutation; for 50 nodes per restaurant this is trivial and
* keeps state simple. SSE/Nostr push refresh is a v2.
*/
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
data() {
return {
restaurant: window.RESTAURANT_BOOTSTRAP || {},
categories: [],
items: [],
selectedCategoryId: null,
categoryDialog: {
tree: [], // hydrated root nodes
flatNodes: [], // flat list of every node (id + name + depth + path)
enrichedItems: [], // flat list of items with modifier groups attached
selectedNodeId: null,
expandedNodeIds: [],
maxDepth: 3, // 0..3 = 4 levels; mirrors models.MAX_MENU_DEPTH
nodeDialog: {
show: false,
data: {restaurant_id: '', name: '', description: ''}
editing: false,
parentId: null,
parentName: '',
data: this._blankNode()
},
moveDialog: {
show: false,
nodeId: null,
nodeName: '',
newParentId: null
},
itemDialog: {
show: false,
data: this._blankItem(),
@ -19,6 +49,7 @@ window.app = Vue.createApp({
allergensText: '',
ingredientsText: ''
},
modifiersDialog: {
show: false,
itemId: null,
@ -27,24 +58,62 @@ window.app = Vue.createApp({
}
}
},
computed: {
selectedCategory() {
return this.categories.find((c) => c.id === this.selectedCategoryId)
selectedNode() {
return this._findNode(this.tree, this.selectedNodeId)
},
filteredItems() {
if (!this.selectedCategoryId) return this.items
return this.items.filter((i) => i.node_id === this.selectedCategoryId)
if (!this.selectedNodeId) return this.enrichedItems
return this.enrichedItems.filter(
(i) => i.node_id === this.selectedNodeId
)
},
categoryOptions() {
return this.categories.map((c) => ({label: c.name, value: c.id}))
/** All nodes as a flat indented q-select option list, used for
* the item dialog's node_id picker and the move dialog. */
allNodeOptions() {
return this.flatNodes.map((n) => ({
label: `${'\u2003'.repeat(n.depth)}${n.name}`,
value: n.id
}))
},
/** Move-dialog targets exclude the node being moved + its
* descendants (cycle prevention is enforced server-side too,
* but we don't show the user options that would be rejected). */
moveTargetOptions() {
if (!this.moveDialog.nodeId) return this.allNodeOptions
const moving = this.flatNodes.find((n) => n.id === this.moveDialog.nodeId)
if (!moving) return this.allNodeOptions
const prefix = moving.path
return this.flatNodes
.filter(
(n) =>
n.id !== moving.id &&
n.path !== prefix &&
!n.path.startsWith(prefix + '/') &&
n.depth < this.maxDepth // can't add a child to a depth-3 node
)
.map((n) => ({
label: `${'\u2003'.repeat(n.depth)}${n.name}`,
value: n.id
}))
},
adminkey() {
// The wallet that owns this restaurant.
const w = this.g.user.wallets.find((w) => w.id === this.restaurant.wallet)
return (w && w.adminkey) || (this.g.user.wallets[0] && this.g.user.wallets[0].adminkey)
}
},
methods: {
_blankNode() {
return {
id: null,
name: '',
description: '',
image_url: '',
sort_order: 0
}
},
_blankItem() {
return {
restaurant_id: '',
@ -76,41 +145,124 @@ window.app = Vue.createApp({
.map((x) => x.trim())
.filter(Boolean)
},
_findNode(nodes, id) {
if (!id) return null
for (const n of nodes) {
if (n.id === id) return n
const inChild = this._findNode(n.children || [], id)
if (inChild) return inChild
}
return null
},
_flatten(nodes, out) {
for (const n of nodes) {
out.push({id: n.id, name: n.name, depth: n.depth, path: n.path})
this._flatten(n.children || [], out)
}
return out
},
// -------- categories --------
// -------- fetch --------
async fetchMenu() {
try {
const {data} = await RestaurantAPI.getMenu(this.restaurant.id)
this.categories = data.categories
this.items = data.items
if (!this.selectedCategoryId && this.categories.length) {
this.selectedCategoryId = this.categories[0].id
this.tree = data.tree || []
this.enrichedItems = data.items || []
this.flatNodes = this._flatten(this.tree, [])
// Auto-expand all on first load so the operator sees structure.
if (!this.expandedNodeIds.length && this.flatNodes.length) {
this.expandedNodeIds = this.flatNodes.map((n) => n.id)
}
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
openCategoryDialog() {
this.categoryDialog.data = {
restaurant_id: this.restaurant.id,
name: '',
description: ''
// -------- nodes --------
openNodeDialog(existing, parent) {
if (existing) {
this.nodeDialog.editing = true
this.nodeDialog.parentId = null
this.nodeDialog.parentName = ''
this.nodeDialog.data = {
id: existing.id,
name: existing.name,
description: existing.description || '',
image_url: existing.image_url || '',
sort_order: existing.sort_order || 0
}
} else {
this.nodeDialog.editing = false
this.nodeDialog.parentId = parent ? parent.id : null
this.nodeDialog.parentName = parent ? parent.name : ''
this.nodeDialog.data = this._blankNode()
}
this.categoryDialog.show = true
this.nodeDialog.show = true
},
async saveCategory() {
async saveNode() {
const payload = {
restaurant_id: this.restaurant.id,
parent_id: this.nodeDialog.parentId,
name: this.nodeDialog.data.name,
description: this.nodeDialog.data.description || null,
image_url: this.nodeDialog.data.image_url || null,
sort_order: this.nodeDialog.data.sort_order || 0
}
try {
await RestaurantAPI.createCategory(this.adminkey, this.categoryDialog.data)
this.categoryDialog.show = false
if (this.nodeDialog.editing) {
await RestaurantAPI.updateMenuNode(
this.adminkey,
this.nodeDialog.data.id,
payload
)
} else {
await RestaurantAPI.createMenuNode(this.adminkey, payload)
}
this.nodeDialog.show = false
await this.fetchMenu()
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
async deleteCategory(cat) {
if (!confirm(`Delete category ${cat.name}?`)) return
async deleteNode(node) {
const hasChildren = (node.children || []).length > 0
const hasItems = (node.items || []).length > 0
let cascade = false
if (hasChildren || hasItems) {
const msg = hasChildren && hasItems
? `${node.name} has child nodes AND items. Delete the whole subtree (items will be detached, not destroyed)?`
: hasChildren
? `${node.name} has child nodes. Delete the whole subtree?`
: `${node.name} has items. Detach them and delete the node?`
if (!confirm(msg)) return
cascade = true
} else {
if (!confirm(`Delete ${node.name}?`)) return
}
try {
await RestaurantAPI.deleteCategory(this.adminkey, cat.id)
await RestaurantAPI.deleteMenuNode(this.adminkey, node.id, cascade)
if (this.selectedNodeId === node.id) this.selectedNodeId = null
await this.fetchMenu()
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
// -------- move --------
openMoveDialog(node) {
this.moveDialog.nodeId = node.id
this.moveDialog.nodeName = node.name
this.moveDialog.newParentId = null
this.moveDialog.show = true
},
async confirmMove() {
try {
await RestaurantAPI.moveMenuNode(
this.adminkey,
this.moveDialog.nodeId,
this.moveDialog.newParentId || null
)
this.moveDialog.show = false
await this.fetchMenu()
} catch (err) {
LNbits.utils.notifyApiError(err)
@ -122,8 +274,8 @@ window.app = Vue.createApp({
const item = existing
? {...existing}
: {...this._blankItem(), restaurant_id: this.restaurant.id}
if (!item.node_id && this.selectedCategoryId) {
item.node_id = this.selectedCategoryId
if (!item.node_id && this.selectedNodeId) {
item.node_id = this.selectedNodeId
}
this.itemDialog.data = item
this.itemDialog.imagesText = (item.images || []).join(', ')
@ -140,6 +292,7 @@ window.app = Vue.createApp({
allergens: this.parseCsv(this.itemDialog.allergensText),
ingredients: this.parseCsv(this.itemDialog.ingredientsText)
}
// Strip the synthetic UI-only id when creating; the server sets it.
try {
if (this.itemDialog.data.id) {
await RestaurantAPI.updateMenuItem(
@ -172,7 +325,6 @@ window.app = Vue.createApp({
this.modifiersDialog.itemName = item.name
try {
const {data: groups} = await RestaurantAPI.listModifierGroups(item.id)
// Hydrate each group with its modifiers.
for (const g of groups) {
const {data: mods} = await RestaurantAPI.listModifiers(g.id)
g._modifiers = mods
@ -183,6 +335,16 @@ window.app = Vue.createApp({
LNbits.utils.notifyApiError(err)
}
},
async _refreshModifiers() {
const {data: groups} = await RestaurantAPI.listModifierGroups(
this.modifiersDialog.itemId
)
for (const g of groups) {
const {data: mods} = await RestaurantAPI.listModifiers(g.id)
g._modifiers = mods
}
this.modifiersDialog.groups = groups
},
async addModifierGroup() {
const name = prompt('Group name (e.g. "Choose your protein")')
if (!name) return
@ -199,10 +361,7 @@ window.app = Vue.createApp({
kind,
selection
})
await this.openModifiersDialog({
id: this.modifiersDialog.itemId,
name: this.modifiersDialog.itemName
})
await this._refreshModifiers()
} catch (err) {
LNbits.utils.notifyApiError(err)
}
@ -211,10 +370,7 @@ window.app = Vue.createApp({
if (!confirm(`Delete group ${grp.name}?`)) return
try {
await RestaurantAPI.deleteModifierGroup(this.adminkey, grp.id)
await this.openModifiersDialog({
id: this.modifiersDialog.itemId,
name: this.modifiersDialog.itemName
})
await this._refreshModifiers()
} catch (err) {
LNbits.utils.notifyApiError(err)
}
@ -231,10 +387,7 @@ window.app = Vue.createApp({
name,
price_delta
})
await this.openModifiersDialog({
id: this.modifiersDialog.itemId,
name: this.modifiersDialog.itemName
})
await this._refreshModifiers()
} catch (err) {
LNbits.utils.notifyApiError(err)
}
@ -243,15 +396,13 @@ window.app = Vue.createApp({
if (!confirm(`Delete ${mod.name}?`)) return
try {
await RestaurantAPI.deleteModifier(this.adminkey, mod.id)
await this.openModifiersDialog({
id: this.modifiersDialog.itemId,
name: this.modifiersDialog.itemName
})
await this._refreshModifiers()
} catch (err) {
LNbits.utils.notifyApiError(err)
}
}
},
async created() {
await this.fetchMenu()
}

View file

@ -24,66 +24,127 @@
</q-item>
</q-list>
</q-card>
</div>
<!-- ============================ Tree pane ============================ -->
<div class="col-12 col-md-4 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row items-center justify-between q-mb-sm">
<span class="text-subtitle2">Categories</span>
<q-btn flat dense icon="add" @click="openCategoryDialog"></q-btn>
</div>
<q-list>
<q-item
v-for="cat in categories"
:key="cat.id"
clickable
:active="selectedCategoryId === cat.id"
@click="selectedCategoryId = cat.id"
>
<q-item-section v-text="cat.name"></q-item-section>
<q-item-section side>
<q-btn
flat
dense
icon="delete"
size="sm"
@click.stop="deleteCategory(cat)"
></q-btn>
</q-item-section>
</q-item>
<q-item v-if="categories.length === 0">
<q-item-section class="text-grey-6 text-italic">
No categories
</q-item-section>
</q-item>
</q-list>
<q-card-section class="row items-center justify-between">
<span class="text-h6">Menu tree</span>
<q-btn
unelevated
dense
color="primary"
icon="add"
label="Top-level"
@click="openNodeDialog(null, null)"
></q-btn>
</q-card-section>
<q-separator></q-separator>
<q-card-section v-if="tree.length === 0" class="text-grey-6 text-italic">
No nodes yet. Click "Top-level" to add a root category.
</q-card-section>
<q-card-section v-else>
<q-tree
:nodes="tree"
node-key="id"
label-key="name"
children-key="children"
v-model:selected="selectedNodeId"
v-model:expanded="expandedNodeIds"
default-expand-all
>
<template v-slot:default-header="prop">
<div class="row items-center full-width no-wrap">
<div class="col">
<span v-text="prop.node.name"></span>
<q-badge
v-if="(prop.node.items || []).length"
color="primary"
class="q-ml-sm"
:label="prop.node.items.length"
></q-badge>
<q-badge
v-if="(prop.node.children || []).length"
color="grey"
outline
class="q-ml-xs"
:label="`+${prop.node.children.length}`"
title="child nodes"
></q-badge>
</div>
<div class="col-auto" @click.stop>
<q-btn
flat
dense
size="sm"
icon="add"
:disable="prop.node.depth >= maxDepth"
title="Add child"
@click="openNodeDialog(null, prop.node)"
></q-btn>
<q-btn
flat
dense
size="sm"
icon="edit"
title="Edit"
@click="openNodeDialog(prop.node, null)"
></q-btn>
<q-btn
flat
dense
size="sm"
icon="drive_file_move"
title="Move"
@click="openMoveDialog(prop.node)"
></q-btn>
<q-btn
flat
dense
size="sm"
icon="delete"
color="negative"
title="Delete"
@click="deleteNode(prop.node)"
></q-btn>
</div>
</div>
</template>
</q-tree>
</q-card-section>
</q-card>
</div>
<!-- ============================ Items ============================ -->
<div class="col-12 col-md-9 q-gutter-y-md">
<!-- ============================ Items pane ============================ -->
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section class="row items-center justify-between">
<div>
<span class="text-h6">Items</span>
<q-badge v-if="selectedCategory" class="q-ml-sm" color="primary">
<span v-text="selectedCategory.name"></span>
<q-badge v-if="selectedNode" color="primary" class="q-ml-sm">
<span v-text="selectedNode.name"></span>
</q-badge>
<q-badge v-else color="grey" class="q-ml-sm" label="all"></q-badge>
</div>
<q-btn
unelevated
color="primary"
icon="add"
label="New item"
:disable="!categories.length"
@click="openItemDialog()"
:disable="!selectedNode"
@click="openItemDialog(null)"
></q-btn>
</q-card-section>
<q-separator></q-separator>
<q-card-section v-if="filteredItems.length === 0" class="text-grey-6 text-italic">
No items in this category yet.
<span v-if="selectedNode">
No items at this node. Use "New item" to add one — items can
live on any node, not just leaves.
</span>
<span v-else>Select a node on the left to see its items.</span>
</q-card-section>
<q-list separator v-else>
@ -146,29 +207,75 @@
</div>
</div>
<!-- ===================== Category dialog ===================== -->
<q-dialog v-model="categoryDialog.show" persistent>
<q-card class="q-pa-md" style="width: 400px; max-width: 95vw">
<h6 class="q-my-none">New category</h6>
<q-form @submit="saveCategory" class="q-gutter-md q-mt-sm">
<!-- ===================== Node dialog (create / edit) ===================== -->
<q-dialog v-model="nodeDialog.show" persistent>
<q-card class="q-pa-md" style="width: 500px; max-width: 95vw">
<h6 class="q-my-none">
<span v-if="nodeDialog.editing">Edit node</span>
<span v-else>New node</span>
</h6>
<q-form @submit="saveNode" class="q-gutter-md q-mt-sm">
<q-input
filled
dense
v-model.trim="categoryDialog.data.name"
v-model.trim="nodeDialog.data.name"
label="Name"
:rules="[v => !!v || 'Required']"
autofocus
></q-input>
<q-input
filled
dense
v-model="categoryDialog.data.description"
v-model="nodeDialog.data.description"
label="Description"
type="textarea"
rows="2"
></q-input>
<q-input
filled
dense
v-model.trim="nodeDialog.data.image_url"
label="Image URL"
></q-input>
<q-input
filled
dense
type="number"
v-model.number="nodeDialog.data.sort_order"
label="Sort order"
></q-input>
<div v-if="!nodeDialog.editing && nodeDialog.parentName" class="text-caption text-grey-6">
Adding under: <strong v-text="nodeDialog.parentName"></strong>
</div>
<div class="row justify-end q-gutter-sm">
<q-btn flat label="Cancel" @click="categoryDialog.show = false"></q-btn>
<q-btn unelevated color="primary" type="submit" label="Create"></q-btn>
<q-btn flat label="Cancel" @click="nodeDialog.show = false"></q-btn>
<q-btn unelevated color="primary" type="submit" label="Save"></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<!-- ===================== Move dialog ===================== -->
<q-dialog v-model="moveDialog.show" persistent>
<q-card class="q-pa-md" style="width: 500px; max-width: 95vw">
<h6 class="q-my-none">
Move <span v-text="moveDialog.nodeName"></span>
</h6>
<q-form @submit="confirmMove" class="q-gutter-md q-mt-sm">
<q-select
filled
dense
v-model="moveDialog.newParentId"
:options="moveTargetOptions"
emit-value
map-options
label="New parent"
hint="Pick a parent node, or leave blank to make this a top-level node."
clearable
></q-select>
<div class="row justify-end q-gutter-sm">
<q-btn flat label="Cancel" @click="moveDialog.show = false"></q-btn>
<q-btn unelevated color="primary" type="submit" label="Move"></q-btn>
</div>
</q-form>
</q-card>
@ -177,16 +284,20 @@
<!-- ===================== Item dialog ===================== -->
<q-dialog v-model="itemDialog.show" persistent>
<q-card class="q-pa-md" style="width: 600px; max-width: 95vw">
<h6 class="q-my-none">{{ '{{ itemDialog.data.id ? "Edit" : "New" }}' }} item</h6>
<h6 class="q-my-none">
<span v-if="itemDialog.data.id">Edit item</span>
<span v-else>New item</span>
</h6>
<q-form @submit="saveItem" class="q-gutter-md q-mt-sm">
<q-select
filled
dense
v-model="itemDialog.data.node_id"
:options="categoryOptions"
:options="allNodeOptions"
emit-value
map-options
label="Category"
label="Menu node"
:rules="[v => !!v || 'Required']"
></q-select>
<q-input
filled