When a card hits `accepted` the items section bumps base font to
1.25rem and modifier/note lines to 1.15rem + medium weight; the
muted grey on modifiers drops to inherited color. All via Vue
`:style` bindings — class-based CSS rules lose to lnbits' upstream
`!important` on Quasar typography utilities (even with our own
`!important`), so inline wins without an arms race.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The age-escalation highlights (bg-amber-1 / bg-orange-1 /
bg-red-1) are very pale Quasar shades. On LNbits dark mode the
q-card inherits a near-white text color from the theme — paired
with the pale background that's white-on-cream, which is what
the user reported: the '1x Coffee' on a ready-card was barely
visible.
Pin an explicit text-grey-9 alongside each pale bg so dark text
on light background renders in both themes. The 'no highlight'
branch returns '' unchanged, so non-aged orders still use the
q-card's theme-aware default text color.
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.
Remove the transitional layer added in commits 1+2:
models.py
- Drop Category, Subcategory, CreateCategory, CreateSubcategory.
crud.py
- Drop create_category / update_category / get_category /
get_categories / delete_category and the subcategory variants
along with the _node_row_to_category / _node_row_to_subcategory
helpers. Tree state is owned exclusively by menu_node CRUD now.
views_api.py
- Remove old endpoints:
GET /api/v1/restaurants/{id}/categories
POST /api/v1/categories
DELETE /api/v1/categories/{id}
GET /api/v1/categories/{id}/subcategories
POST /api/v1/subcategories
DELETE /api/v1/subcategories/{id}
Hits return 404 now.
- GET /api/v1/restaurants/{id}/menu loses the synthetic
'categories' projection. Response is {restaurant, tree, items}.
static/js/api.js
- Drop listCategories / createCategory / deleteCategory and the
subcategory wrappers.
The CMS menu builder is broken between this commit and commit 4.
The plan acknowledged this trade-off: keeping commits revertible
beats the cost of an unshipped UI page rendering a stale empty
sidebar for one commit's lifetime.
views_api.py:
- New endpoints (admin-key-gated, ownership-checked):
* GET /api/v1/restaurants/{id}/menu_nodes flat list of nodes
* GET /api/v1/menu_nodes/{id} single node
* POST /api/v1/menu_nodes create
* PUT /api/v1/menu_nodes/{id} edit name / desc /
sort_order /
image_url
* PUT /api/v1/menu_nodes/{id}/move body
{new_parent_id}
* DELETE /api/v1/menu_nodes/{id}?cascade=true|false
- ValueError from CRUD (depth, cycle, has-children-without-cascade)
surfaces as 400 (creates / moves) or 409 (delete blocked).
- GET /api/v1/restaurants/{id}/menu now returns three views in
one round trip:
tree: hydrated tree (root nodes -> children + items)
items: flat enriched list (modifiers + availability)
categories: transitional projection of depth-0 nodes with
their immediate items, in the legacy shape — kept
for one commit's lifetime so the existing CMS
keeps rendering. Drops in commit 3.
static/js/api.js:
- listMenuNodes / getMenuNode / createMenuNode / updateMenuNode /
moveMenuNode / deleteMenuNode added.
- Old category/subcategory methods marked transitional in
comments (drop in commit 3).
No JS / template churn — the CMS still reads from menu.categories
which is now produced from menu_nodes via the synthetic projection.
Commit 4 replaces the CMS with q-tree.
Replace the fixed two-level (categories + subcategories) menu shape
with an arbitrary-depth tree, capped at 4 levels. The legacy
Atitlan.io project this carries forward already used a self-FK tree;
real menus need the depth (Drinks -> Hot Beverages -> Coffee-based
-> Espressos). As a side benefit, the current CMS has no UI for
subcategories at all, so this refactor incidentally fixes that gap.
Pattern: adjacency list (parent_id self-FK) + denormalized
materialized path (TEXT, '/'-separated) + denormalized depth.
Rejected closure table (overkill at n=5..50) and Postgres ltree
(not portable to SQLite). Subtree queries become
'WHERE path LIKE :p || '%''; subtree moves are a single
SUBSTR + concat UPDATE; max-depth and cycle checks are O(1).
migrations.py
m002_menu_tree:
- CREATE TABLE menu_nodes (id, restaurant_id, parent_id,
name, description, sort_order, image_url, depth, path, time)
with indexes on (restaurant_id), (parent_id), (path).
- Backfill depth-0 from categories; depth-1 from subcategories
with path = parent.id || '/' || own.id.
- ALTER menu_items ADD COLUMN node_id; backfill via
COALESCE(subcategory_id, category_id). Index on node_id.
- DROP subcategory_id, category_id; DROP TABLE subcategories,
categories.
models.py
- New MAX_MENU_DEPTH = 3 (zero-indexed; 4 levels total).
- New MenuNodeRow (DB I/O shape) + MenuNode (extends with
children + items for hydrated tree responses; never persisted).
- New CreateMenuNode.
- MenuItem.node_id is Optional (orphans allowed when a parent
is deleted with cascade=False); CreateMenuItem.node_id is
required (newly created items must land somewhere).
- Category / Subcategory / Create* kept temporarily as
transitional shim shapes for the old endpoints; dropped in
commit 3.
crud.py
- New: create/update/get/get_all/move/delete_menu_node and
get_menu_tree. move_menu_node uses single-statement subtree
rewrite (path = new_prefix || SUBSTR(path, old_len + 1)).
Cycle check: new_parent's path must not contain node_id.
Depth check: max descendant depth + delta_depth <= MAX_MENU_DEPTH.
delete_menu_node default cascade=False (block on children/items);
cascade=True detaches items (sets node_id NULL) rather than
hard-deletes, since items carry nostr_event_ids and are
revenue-bearing.
- get_menu_tree fetches nodes + items in two queries and assembles
the tree in O(n+m) Python — no recursive CTEs, identical on
SQLite + Postgres.
- Old create_category / get_categories / create_subcategory etc.
rewritten as thin shims that translate to/from menu_nodes.
Old endpoints keep working.
- delete_restaurant cascade now deletes from menu_nodes
(single statement) instead of categories + subcategories.
views_api.py
- GET /restaurants/{id}/menu temporarily sources from menu_nodes
via the shims; surfaces items only at depth-0 nodes for now
(commit 2 replaces the whole block with a real tree response).
static/js/menu.js + templates/restaurant/menu.html
- Rename category_id -> node_id in the item dialog payload so
POST /menu_items satisfies the new CreateMenuItem schema.
The CMS still renders against the depth-0 'categories'
projection; full q-tree rewrite lands in commit 4.
LNbits convention: extends base.html, declares window.app =
Vue.createApp({mixins: [windowMixin], data, methods, created}); the
LNbits init-app.js loads after extension scripts and finishes the
mount with app.use(Quasar) + app.mount('#vue').
Pages
- index.html restaurant list / dashboard with create dialog;
scoped to the logged-in user's wallets.
- menu.html category sidebar + items grid; full item dialog
with price/currency/images/dietary/allergens/
ingredients/calories/stock/availability/featured.
Modifier groups managed in a separate dialog
with required|optional + one|many semantics.
- orders.html filterable q-table with status colors and inline
state-machine actions (accept/ready/complete/
cancel). Polls every 8s.
- kds.html kitchen display: card-per-order, items + selected
modifiers + notes, age-based color escalation
(>5min orange, >15min red), polls every 5s. The
poll loop is a stand-in until SSE/Nostr push
lands.
- settings.html restaurant profile editor + delete + per-instance
ext settings panel (Nostr publish toggle, auto-
accept, invoice expiry).
Static
- js/api.js single REST client (LNbits.api.request wrapper)
used by all pages.
- js/index.js dashboard logic.
- js/menu.js menu CRUD.
- js/orders.js order monitor.
- js/kds.js kitchen display.
- js/settings.js settings persistence.
Customer kiosk UI lives in ~/dev/webapp; this extension only ships
the operator console.