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({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [windowMixin],
data() { data() {
return { return {
restaurant: window.RESTAURANT_BOOTSTRAP || {}, restaurant: window.RESTAURANT_BOOTSTRAP || {},
categories: [], tree: [], // hydrated root nodes
items: [], flatNodes: [], // flat list of every node (id + name + depth + path)
selectedCategoryId: null, enrichedItems: [], // flat list of items with modifier groups attached
categoryDialog: { selectedNodeId: null,
expandedNodeIds: [],
maxDepth: 3, // 0..3 = 4 levels; mirrors models.MAX_MENU_DEPTH
nodeDialog: {
show: false, show: false,
data: {restaurant_id: '', name: '', description: ''} editing: false,
parentId: null,
parentName: '',
data: this._blankNode()
}, },
moveDialog: {
show: false,
nodeId: null,
nodeName: '',
newParentId: null
},
itemDialog: { itemDialog: {
show: false, show: false,
data: this._blankItem(), data: this._blankItem(),
@ -19,6 +49,7 @@ window.app = Vue.createApp({
allergensText: '', allergensText: '',
ingredientsText: '' ingredientsText: ''
}, },
modifiersDialog: { modifiersDialog: {
show: false, show: false,
itemId: null, itemId: null,
@ -27,24 +58,62 @@ window.app = Vue.createApp({
} }
} }
}, },
computed: { computed: {
selectedCategory() { selectedNode() {
return this.categories.find((c) => c.id === this.selectedCategoryId) return this._findNode(this.tree, this.selectedNodeId)
}, },
filteredItems() { filteredItems() {
if (!this.selectedCategoryId) return this.items if (!this.selectedNodeId) return this.enrichedItems
return this.items.filter((i) => i.node_id === this.selectedCategoryId) return this.enrichedItems.filter(
(i) => i.node_id === this.selectedNodeId
)
}, },
categoryOptions() { /** All nodes as a flat indented q-select option list, used for
return this.categories.map((c) => ({label: c.name, value: c.id})) * 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() { adminkey() {
// The wallet that owns this restaurant.
const w = this.g.user.wallets.find((w) => w.id === this.restaurant.wallet) 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) return (w && w.adminkey) || (this.g.user.wallets[0] && this.g.user.wallets[0].adminkey)
} }
}, },
methods: { methods: {
_blankNode() {
return {
id: null,
name: '',
description: '',
image_url: '',
sort_order: 0
}
},
_blankItem() { _blankItem() {
return { return {
restaurant_id: '', restaurant_id: '',
@ -76,41 +145,124 @@ window.app = Vue.createApp({
.map((x) => x.trim()) .map((x) => x.trim())
.filter(Boolean) .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() { async fetchMenu() {
try { try {
const {data} = await RestaurantAPI.getMenu(this.restaurant.id) const {data} = await RestaurantAPI.getMenu(this.restaurant.id)
this.categories = data.categories this.tree = data.tree || []
this.items = data.items this.enrichedItems = data.items || []
if (!this.selectedCategoryId && this.categories.length) { this.flatNodes = this._flatten(this.tree, [])
this.selectedCategoryId = this.categories[0].id // 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) { } catch (err) {
LNbits.utils.notifyApiError(err) LNbits.utils.notifyApiError(err)
} }
}, },
openCategoryDialog() {
this.categoryDialog.data = { // -------- nodes --------
restaurant_id: this.restaurant.id, openNodeDialog(existing, parent) {
name: '', if (existing) {
description: '' 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 { try {
await RestaurantAPI.createCategory(this.adminkey, this.categoryDialog.data) if (this.nodeDialog.editing) {
this.categoryDialog.show = false await RestaurantAPI.updateMenuNode(
this.adminkey,
this.nodeDialog.data.id,
payload
)
} else {
await RestaurantAPI.createMenuNode(this.adminkey, payload)
}
this.nodeDialog.show = false
await this.fetchMenu() await this.fetchMenu()
} catch (err) { } catch (err) {
LNbits.utils.notifyApiError(err) LNbits.utils.notifyApiError(err)
} }
}, },
async deleteCategory(cat) { async deleteNode(node) {
if (!confirm(`Delete category ${cat.name}?`)) return 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 { 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() await this.fetchMenu()
} catch (err) { } catch (err) {
LNbits.utils.notifyApiError(err) LNbits.utils.notifyApiError(err)
@ -122,8 +274,8 @@ window.app = Vue.createApp({
const item = existing const item = existing
? {...existing} ? {...existing}
: {...this._blankItem(), restaurant_id: this.restaurant.id} : {...this._blankItem(), restaurant_id: this.restaurant.id}
if (!item.node_id && this.selectedCategoryId) { if (!item.node_id && this.selectedNodeId) {
item.node_id = this.selectedCategoryId item.node_id = this.selectedNodeId
} }
this.itemDialog.data = item this.itemDialog.data = item
this.itemDialog.imagesText = (item.images || []).join(', ') this.itemDialog.imagesText = (item.images || []).join(', ')
@ -140,6 +292,7 @@ window.app = Vue.createApp({
allergens: this.parseCsv(this.itemDialog.allergensText), allergens: this.parseCsv(this.itemDialog.allergensText),
ingredients: this.parseCsv(this.itemDialog.ingredientsText) ingredients: this.parseCsv(this.itemDialog.ingredientsText)
} }
// Strip the synthetic UI-only id when creating; the server sets it.
try { try {
if (this.itemDialog.data.id) { if (this.itemDialog.data.id) {
await RestaurantAPI.updateMenuItem( await RestaurantAPI.updateMenuItem(
@ -172,7 +325,6 @@ window.app = Vue.createApp({
this.modifiersDialog.itemName = item.name this.modifiersDialog.itemName = item.name
try { try {
const {data: groups} = await RestaurantAPI.listModifierGroups(item.id) const {data: groups} = await RestaurantAPI.listModifierGroups(item.id)
// Hydrate each group with its modifiers.
for (const g of groups) { for (const g of groups) {
const {data: mods} = await RestaurantAPI.listModifiers(g.id) const {data: mods} = await RestaurantAPI.listModifiers(g.id)
g._modifiers = mods g._modifiers = mods
@ -183,6 +335,16 @@ window.app = Vue.createApp({
LNbits.utils.notifyApiError(err) 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() { async addModifierGroup() {
const name = prompt('Group name (e.g. "Choose your protein")') const name = prompt('Group name (e.g. "Choose your protein")')
if (!name) return if (!name) return
@ -199,10 +361,7 @@ window.app = Vue.createApp({
kind, kind,
selection selection
}) })
await this.openModifiersDialog({ await this._refreshModifiers()
id: this.modifiersDialog.itemId,
name: this.modifiersDialog.itemName
})
} catch (err) { } catch (err) {
LNbits.utils.notifyApiError(err) LNbits.utils.notifyApiError(err)
} }
@ -211,10 +370,7 @@ window.app = Vue.createApp({
if (!confirm(`Delete group ${grp.name}?`)) return if (!confirm(`Delete group ${grp.name}?`)) return
try { try {
await RestaurantAPI.deleteModifierGroup(this.adminkey, grp.id) await RestaurantAPI.deleteModifierGroup(this.adminkey, grp.id)
await this.openModifiersDialog({ await this._refreshModifiers()
id: this.modifiersDialog.itemId,
name: this.modifiersDialog.itemName
})
} catch (err) { } catch (err) {
LNbits.utils.notifyApiError(err) LNbits.utils.notifyApiError(err)
} }
@ -231,10 +387,7 @@ window.app = Vue.createApp({
name, name,
price_delta price_delta
}) })
await this.openModifiersDialog({ await this._refreshModifiers()
id: this.modifiersDialog.itemId,
name: this.modifiersDialog.itemName
})
} catch (err) { } catch (err) {
LNbits.utils.notifyApiError(err) LNbits.utils.notifyApiError(err)
} }
@ -243,15 +396,13 @@ window.app = Vue.createApp({
if (!confirm(`Delete ${mod.name}?`)) return if (!confirm(`Delete ${mod.name}?`)) return
try { try {
await RestaurantAPI.deleteModifier(this.adminkey, mod.id) await RestaurantAPI.deleteModifier(this.adminkey, mod.id)
await this.openModifiersDialog({ await this._refreshModifiers()
id: this.modifiersDialog.itemId,
name: this.modifiersDialog.itemName
})
} catch (err) { } catch (err) {
LNbits.utils.notifyApiError(err) LNbits.utils.notifyApiError(err)
} }
} }
}, },
async created() { async created() {
await this.fetchMenu() await this.fetchMenu()
} }

View file

@ -24,66 +24,127 @@
</q-item> </q-item>
</q-list> </q-list>
</q-card> </q-card>
</div>
<!-- ============================ Tree pane ============================ -->
<div class="col-12 col-md-4 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section class="row items-center justify-between">
<div class="row items-center justify-between q-mb-sm"> <span class="text-h6">Menu tree</span>
<span class="text-subtitle2">Categories</span> <q-btn
<q-btn flat dense icon="add" @click="openCategoryDialog"></q-btn> unelevated
</div> dense
<q-list> color="primary"
<q-item icon="add"
v-for="cat in categories" label="Top-level"
:key="cat.id" @click="openNodeDialog(null, null)"
clickable ></q-btn>
:active="selectedCategoryId === cat.id" </q-card-section>
@click="selectedCategoryId = cat.id" <q-separator></q-separator>
> <q-card-section v-if="tree.length === 0" class="text-grey-6 text-italic">
<q-item-section v-text="cat.name"></q-item-section> No nodes yet. Click "Top-level" to add a root category.
<q-item-section side> </q-card-section>
<q-btn <q-card-section v-else>
flat <q-tree
dense :nodes="tree"
icon="delete" node-key="id"
size="sm" label-key="name"
@click.stop="deleteCategory(cat)" children-key="children"
></q-btn> v-model:selected="selectedNodeId"
</q-item-section> v-model:expanded="expandedNodeIds"
</q-item> default-expand-all
<q-item v-if="categories.length === 0"> >
<q-item-section class="text-grey-6 text-italic"> <template v-slot:default-header="prop">
No categories <div class="row items-center full-width no-wrap">
</q-item-section> <div class="col">
</q-item> <span v-text="prop.node.name"></span>
</q-list> <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-section>
</q-card> </q-card>
</div> </div>
<!-- ============================ Items ============================ --> <!-- ============================ Items pane ============================ -->
<div class="col-12 col-md-9 q-gutter-y-md"> <div class="col-12 col-md-5 q-gutter-y-md">
<q-card> <q-card>
<q-card-section class="row items-center justify-between"> <q-card-section class="row items-center justify-between">
<div> <div>
<span class="text-h6">Items</span> <span class="text-h6">Items</span>
<q-badge v-if="selectedCategory" class="q-ml-sm" color="primary"> <q-badge v-if="selectedNode" color="primary" class="q-ml-sm">
<span v-text="selectedCategory.name"></span> <span v-text="selectedNode.name"></span>
</q-badge> </q-badge>
<q-badge v-else color="grey" class="q-ml-sm" label="all"></q-badge>
</div> </div>
<q-btn <q-btn
unelevated unelevated
color="primary" color="primary"
icon="add" icon="add"
label="New item" label="New item"
:disable="!categories.length" :disable="!selectedNode"
@click="openItemDialog()" @click="openItemDialog(null)"
></q-btn> ></q-btn>
</q-card-section> </q-card-section>
<q-separator></q-separator> <q-separator></q-separator>
<q-card-section v-if="filteredItems.length === 0" class="text-grey-6 text-italic"> <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-card-section>
<q-list separator v-else> <q-list separator v-else>
@ -146,29 +207,75 @@
</div> </div>
</div> </div>
<!-- ===================== Category dialog ===================== --> <!-- ===================== Node dialog (create / edit) ===================== -->
<q-dialog v-model="categoryDialog.show" persistent> <q-dialog v-model="nodeDialog.show" persistent>
<q-card class="q-pa-md" style="width: 400px; max-width: 95vw"> <q-card class="q-pa-md" style="width: 500px; max-width: 95vw">
<h6 class="q-my-none">New category</h6> <h6 class="q-my-none">
<q-form @submit="saveCategory" class="q-gutter-md q-mt-sm"> <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 <q-input
filled filled
dense dense
v-model.trim="categoryDialog.data.name" v-model.trim="nodeDialog.data.name"
label="Name" label="Name"
:rules="[v => !!v || 'Required']" :rules="[v => !!v || 'Required']"
autofocus
></q-input> ></q-input>
<q-input <q-input
filled filled
dense dense
v-model="categoryDialog.data.description" v-model="nodeDialog.data.description"
label="Description" label="Description"
type="textarea" type="textarea"
rows="2" rows="2"
></q-input> ></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"> <div class="row justify-end q-gutter-sm">
<q-btn flat label="Cancel" @click="categoryDialog.show = false"></q-btn> <q-btn flat label="Cancel" @click="nodeDialog.show = false"></q-btn>
<q-btn unelevated color="primary" type="submit" label="Create"></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> </div>
</q-form> </q-form>
</q-card> </q-card>
@ -177,16 +284,20 @@
<!-- ===================== Item dialog ===================== --> <!-- ===================== Item dialog ===================== -->
<q-dialog v-model="itemDialog.show" persistent> <q-dialog v-model="itemDialog.show" persistent>
<q-card class="q-pa-md" style="width: 600px; max-width: 95vw"> <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-form @submit="saveItem" class="q-gutter-md q-mt-sm">
<q-select <q-select
filled filled
dense dense
v-model="itemDialog.data.node_id" v-model="itemDialog.data.node_id"
:options="categoryOptions" :options="allNodeOptions"
emit-value emit-value
map-options map-options
label="Category" label="Menu node"
:rules="[v => !!v || 'Required']"
></q-select> ></q-select>
<q-input <q-input
filled filled