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:
parent
b7fa1aec4a
commit
4827f5e10f
2 changed files with 358 additions and 96 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
this.categoryDialog.show = true
|
} else {
|
||||||
|
this.nodeDialog.editing = false
|
||||||
|
this.nodeDialog.parentId = parent ? parent.id : null
|
||||||
|
this.nodeDialog.parentName = parent ? parent.name : ''
|
||||||
|
this.nodeDialog.data = this._blankNode()
|
||||||
|
}
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,66 +24,127 @@
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
<q-list>
|
|
||||||
<q-item
|
<!-- ============================ Tree pane ============================ -->
|
||||||
v-for="cat in categories"
|
<div class="col-12 col-md-4 q-gutter-y-md">
|
||||||
:key="cat.id"
|
<q-card>
|
||||||
clickable
|
<q-card-section class="row items-center justify-between">
|
||||||
:active="selectedCategoryId === cat.id"
|
<span class="text-h6">Menu tree</span>
|
||||||
@click="selectedCategoryId = cat.id"
|
<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
|
||||||
>
|
>
|
||||||
<q-item-section v-text="cat.name"></q-item-section>
|
<template v-slot:default-header="prop">
|
||||||
<q-item-section side>
|
<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
|
<q-btn
|
||||||
flat
|
flat
|
||||||
dense
|
dense
|
||||||
icon="delete"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
@click.stop="deleteCategory(cat)"
|
icon="add"
|
||||||
|
:disable="prop.node.depth >= maxDepth"
|
||||||
|
title="Add child"
|
||||||
|
@click="openNodeDialog(null, prop.node)"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
</q-item-section>
|
<q-btn
|
||||||
</q-item>
|
flat
|
||||||
<q-item v-if="categories.length === 0">
|
dense
|
||||||
<q-item-section class="text-grey-6 text-italic">
|
size="sm"
|
||||||
No categories
|
icon="edit"
|
||||||
</q-item-section>
|
title="Edit"
|
||||||
</q-item>
|
@click="openNodeDialog(prop.node, null)"
|
||||||
</q-list>
|
></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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue