@betterportal/config-manager 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/bsb-plugin.json +23 -0
- package/bsb-tests.json +14 -0
- package/lib/.bsb/clients/service-betterportal-config-manager.d.ts +37 -0
- package/lib/.bsb/clients/service-betterportal-config-manager.d.ts.map +1 -0
- package/lib/.bsb/clients/service-betterportal-config-manager.js +40 -0
- package/lib/.bsb/clients/service-betterportal-config-manager.js.map +1 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +2 -0
- package/lib/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/.bp-generated/registry.d.ts +3 -0
- package/lib/plugins/service-betterportal-config-manager/.bp-generated/registry.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/.bp-generated/registry.js +235 -0
- package/lib/plugins/service-betterportal-config-manager/.bp-generated/registry.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/adminApi.d.ts +4 -0
- package/lib/plugins/service-betterportal-config-manager/adminApi.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/adminApi.js +2319 -0
- package/lib/plugins/service-betterportal-config-manager/adminApi.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bootstrapEndpoint.d.ts +21 -0
- package/lib/plugins/service-betterportal-config-manager/bootstrapEndpoint.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bootstrapEndpoint.js +269 -0
- package/lib/plugins/service-betterportal-config-manager/bootstrapEndpoint.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bootstrapWizardHtml.d.ts +19 -0
- package/lib/plugins/service-betterportal-config-manager/bootstrapWizardHtml.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bootstrapWizardHtml.js +329 -0
- package/lib/plugins/service-betterportal-config-manager/bootstrapWizardHtml.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/_theme.bootstrap1/index.d.ts +4 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/_theme.bootstrap1/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/_theme.bootstrap1/index.js +38 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/_theme.bootstrap1/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/index.d.ts +96 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/index.js +78 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_theme.bootstrap1/index.d.ts +4 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_theme.bootstrap1/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_theme.bootstrap1/index.js +62 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_theme.bootstrap1/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_theme.embedded.d.ts +5 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_theme.embedded.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_theme.embedded.js +5 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_theme.embedded.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/config/index.d.ts +43 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/config/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/config/index.js +68 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/config/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/_theme.bootstrap1/index.d.ts +5 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/_theme.bootstrap1/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/_theme.bootstrap1/index.js +11 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/_theme.bootstrap1/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/index.d.ts +32 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/index.js +32 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/menu/_theme.bootstrap1/index.d.ts +4 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/menu/_theme.bootstrap1/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/menu/_theme.bootstrap1/index.js +170 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/menu/_theme.bootstrap1/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/menu/index.d.ts +60 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/menu/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/menu/index.js +48 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/menu/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/_theme.bootstrap1/index.d.ts +4 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/_theme.bootstrap1/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/_theme.bootstrap1/index.js +28 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/_theme.bootstrap1/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/index.d.ts +48 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/index.js +40 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_theme.bootstrap1/index.d.ts +5 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_theme.bootstrap1/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_theme.bootstrap1/index.js +194 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_theme.bootstrap1/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/index.d.ts +80 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/index.js +59 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/services/_theme.bootstrap1/index.d.ts +5 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/services/_theme.bootstrap1/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/services/_theme.bootstrap1/index.js +167 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/services/_theme.bootstrap1/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/services/index.d.ts +128 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/services/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/services/index.js +89 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/services/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/_theme.bootstrap1/index.d.ts +5 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/_theme.bootstrap1/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/_theme.bootstrap1/index.js +8 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/_theme.bootstrap1/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/index.d.ts +89 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/index.js +93 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/_theme.bootstrap1/index.d.ts +4 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/_theme.bootstrap1/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/_theme.bootstrap1/index.js +61 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/_theme.bootstrap1/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/index.d.ts +180 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/index.js +405 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/cpBootstrap.d.ts +26 -0
- package/lib/plugins/service-betterportal-config-manager/cpBootstrap.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/cpBootstrap.js +58 -0
- package/lib/plugins/service-betterportal-config-manager/cpBootstrap.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/fragmentsEditor.d.ts +3 -0
- package/lib/plugins/service-betterportal-config-manager/fragmentsEditor.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/fragmentsEditor.js +365 -0
- package/lib/plugins/service-betterportal-config-manager/fragmentsEditor.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/index.d.ts +143 -0
- package/lib/plugins/service-betterportal-config-manager/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/index.js +696 -0
- package/lib/plugins/service-betterportal-config-manager/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/menuEditor.d.ts +3 -0
- package/lib/plugins/service-betterportal-config-manager/menuEditor.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/menuEditor.js +823 -0
- package/lib/plugins/service-betterportal-config-manager/menuEditor.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/routeContext.d.ts +10 -0
- package/lib/plugins/service-betterportal-config-manager/routeContext.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/routeContext.js +11 -0
- package/lib/plugins/service-betterportal-config-manager/routeContext.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/setupTokens.d.ts +18 -0
- package/lib/plugins/service-betterportal-config-manager/setupTokens.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/setupTokens.js +245 -0
- package/lib/plugins/service-betterportal-config-manager/setupTokens.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/storage/core.d.ts +41 -0
- package/lib/plugins/service-betterportal-config-manager/storage/core.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/storage/core.js +396 -0
- package/lib/plugins/service-betterportal-config-manager/storage/core.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/storage/file.d.ts +10 -0
- package/lib/plugins/service-betterportal-config-manager/storage/file.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/storage/file.js +30 -0
- package/lib/plugins/service-betterportal-config-manager/storage/file.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/storage/index.d.ts +36 -0
- package/lib/plugins/service-betterportal-config-manager/storage/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/storage/index.js +52 -0
- package/lib/plugins/service-betterportal-config-manager/storage/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/storage/postgres.d.ts +15 -0
- package/lib/plugins/service-betterportal-config-manager/storage/postgres.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/storage/postgres.js +60 -0
- package/lib/plugins/service-betterportal-config-manager/storage/postgres.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/syncApi.d.ts +44 -0
- package/lib/plugins/service-betterportal-config-manager/syncApi.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/syncApi.js +280 -0
- package/lib/plugins/service-betterportal-config-manager/syncApi.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/webhooks.d.ts +6 -0
- package/lib/plugins/service-betterportal-config-manager/webhooks.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/webhooks.js +372 -0
- package/lib/plugins/service-betterportal-config-manager/webhooks.js.map +1 -0
- package/lib/schemas/service-betterportal-config-manager.json +157 -0
- package/lib/schemas/service-betterportal-config-manager.plugin.json +135 -0
- package/package.json +69 -0
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
import { htmlResponse, jsonResponse, uuidv7 } from "@betterportal/framework";
|
|
2
|
+
import { getManifestCache } from "./syncApi.js";
|
|
3
|
+
const API_BASE = "/.well-known/bp/admin";
|
|
4
|
+
// -- Helpers ----------------------------------------------------------
|
|
5
|
+
async function readFormBody(event) {
|
|
6
|
+
const fd = await event.req.formData().catch(() => null);
|
|
7
|
+
if (!fd)
|
|
8
|
+
return {};
|
|
9
|
+
const out = {};
|
|
10
|
+
fd.forEach((v, k) => { if (typeof v === "string")
|
|
11
|
+
out[k] = v; });
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
function escapeHtml(s) {
|
|
15
|
+
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
16
|
+
}
|
|
17
|
+
function locate(items, id, parent = items) {
|
|
18
|
+
for (let i = 0; i < items.length; i++) {
|
|
19
|
+
if (items[i].id === id)
|
|
20
|
+
return { item: items[i], parent: items, index: i };
|
|
21
|
+
if (items[i].type === "group" && items[i].children) {
|
|
22
|
+
const found = locate(items[i].children, id, items[i].children);
|
|
23
|
+
if (found)
|
|
24
|
+
return found;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
function findGroupParent(items, id) {
|
|
30
|
+
for (const it of items) {
|
|
31
|
+
if (it.type === "group" && it.children) {
|
|
32
|
+
if (it.children.some((c) => c.id === id))
|
|
33
|
+
return it;
|
|
34
|
+
const deeper = findGroupParent(it.children, id);
|
|
35
|
+
if (deeper)
|
|
36
|
+
return deeper;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
function getApp(config, appId) {
|
|
42
|
+
return config.apps.find((a) => a.id === appId) ?? null;
|
|
43
|
+
}
|
|
44
|
+
function getMenu(appDef) {
|
|
45
|
+
return (appDef.menu ?? []);
|
|
46
|
+
}
|
|
47
|
+
function getRoutes(appDef) {
|
|
48
|
+
return (appDef.routes ?? []);
|
|
49
|
+
}
|
|
50
|
+
function getServiceTitle(config, serviceId) {
|
|
51
|
+
if (!serviceId)
|
|
52
|
+
return "-";
|
|
53
|
+
for (const t of config.tenants ?? []) {
|
|
54
|
+
const s = (t.services ?? []).find((x) => x.id === serviceId);
|
|
55
|
+
if (s)
|
|
56
|
+
return s.title || s.serviceId || s.id;
|
|
57
|
+
}
|
|
58
|
+
const ps = (config.platformServices ?? []).find((x) => x.id === serviceId);
|
|
59
|
+
return ps ? (ps.title || ps.id) : serviceId;
|
|
60
|
+
}
|
|
61
|
+
function getServiceHostname(config, serviceId) {
|
|
62
|
+
for (const t of config.tenants ?? []) {
|
|
63
|
+
const s = (t.services ?? []).find((x) => x.id === serviceId);
|
|
64
|
+
if (s)
|
|
65
|
+
return s.hostname ?? null;
|
|
66
|
+
}
|
|
67
|
+
const ps = (config.platformServices ?? []).find((x) => x.id === serviceId);
|
|
68
|
+
return ps?.hostname ?? null;
|
|
69
|
+
}
|
|
70
|
+
function getServicesForApp(config, appDef) {
|
|
71
|
+
const tenant = (config.tenants ?? []).find((t) => t.id === appDef.tenantId);
|
|
72
|
+
if (!tenant)
|
|
73
|
+
return [];
|
|
74
|
+
const tenantSvcs = (tenant.services ?? []).filter((s) => s.enabled).map((s) => ({
|
|
75
|
+
id: s.id, title: s.title || s.serviceId || s.id
|
|
76
|
+
}));
|
|
77
|
+
const platformSvcs = (tenant.activatedPlatformServices ?? [])
|
|
78
|
+
.map((psId) => (config.platformServices ?? []).find((p) => p.id === psId && p.enabled))
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.map((p) => ({ id: p.id, title: `${p.title || p.id} (platform)` }));
|
|
81
|
+
return [...tenantSvcs, ...platformSvcs];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Read views for a service from the local manifest cache. CM cannot reach
|
|
85
|
+
* services, so all editor dropdowns come from the cache that services pushed
|
|
86
|
+
* via /sync/poll. If a service hasn't synced yet, returns empty.
|
|
87
|
+
*/
|
|
88
|
+
function lookupServiceViews(serviceId) {
|
|
89
|
+
if (!serviceId)
|
|
90
|
+
return [];
|
|
91
|
+
const cache = getManifestCache();
|
|
92
|
+
const entry = cache.get(serviceId);
|
|
93
|
+
if (!entry)
|
|
94
|
+
return [];
|
|
95
|
+
return Object.values(entry.viewIndex)
|
|
96
|
+
.filter((v) => v.renderable)
|
|
97
|
+
.map((v) => ({
|
|
98
|
+
viewId: v.viewId,
|
|
99
|
+
title: v.viewId,
|
|
100
|
+
path: v.path
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
function rowAttrs(item, depth) {
|
|
104
|
+
return `id="bp-menu-row-${item.id}" draggable="true" data-bp-drag-item="${item.id}" data-bp-drag-type="${item.type}" data-bp-drag-depth="${depth}" style="padding-left: ${depth * 1.5 + 1}rem;"`;
|
|
105
|
+
}
|
|
106
|
+
function titleDisplayHtml(item, route, appId) {
|
|
107
|
+
const titleText = item.type === "divider" ? "- divider -" :
|
|
108
|
+
item.type === "section" ? `[${item.title || "Section"}]` :
|
|
109
|
+
item.type === "external" ? (item.title || item.href || "External") :
|
|
110
|
+
item.type === "group" ? (item.title || "Group") :
|
|
111
|
+
item.title || route?.title || route?.path || item.routeId || "(missing)";
|
|
112
|
+
const editAction = item.type === "divider" ? "" : "edit-title";
|
|
113
|
+
const strikeClass = item.enabled ? "" : "text-decoration-line-through opacity-50";
|
|
114
|
+
if (!editAction) {
|
|
115
|
+
return `<span class="text-secondary fst-italic">${escapeHtml(titleText)}</span>`;
|
|
116
|
+
}
|
|
117
|
+
return `<button type="button" class="bp-menu-title-display ${strikeClass}"
|
|
118
|
+
style="border:1px solid var(--bs-border-color); border-radius:0.375rem; padding:0.25rem 0.6rem; background:transparent; cursor:text; text-align:left; min-width:200px; font-weight:500;"
|
|
119
|
+
hx-get="${API_BASE}/menu-editor/item?appId=${encodeURIComponent(appId)}&itemId=${encodeURIComponent(item.id)}&mode=edit-title"
|
|
120
|
+
hx-target="#bp-menu-row-${item.id}"
|
|
121
|
+
hx-swap="outerHTML"
|
|
122
|
+
title="Click to rename">${escapeHtml(titleText)}</button>`;
|
|
123
|
+
}
|
|
124
|
+
function subLineHtml(item, route, config, appId) {
|
|
125
|
+
if (item.type === "group") {
|
|
126
|
+
const count = item.children?.length ?? 0;
|
|
127
|
+
return `<div class="small text-secondary">${count} item${count === 1 ? "" : "s"}</div>`;
|
|
128
|
+
}
|
|
129
|
+
if (item.type === "divider" || item.type === "section")
|
|
130
|
+
return "";
|
|
131
|
+
if (item.type === "external") {
|
|
132
|
+
return `<div class="small d-flex align-items-center gap-2">
|
|
133
|
+
<span class="text-secondary">URL:</span>
|
|
134
|
+
<span class="font-monospace text-truncate" style="max-width: 360px;">${escapeHtml(item.href ?? "")}</span>
|
|
135
|
+
<button type="button" class="btn btn-sm btn-link p-0"
|
|
136
|
+
hx-get="${API_BASE}/menu-editor/item?appId=${encodeURIComponent(appId)}&itemId=${encodeURIComponent(item.id)}&mode=edit-external"
|
|
137
|
+
hx-target="#bp-menu-row-${item.id}"
|
|
138
|
+
hx-swap="outerHTML"
|
|
139
|
+
title="Edit URL">Edit</button>
|
|
140
|
+
</div>`;
|
|
141
|
+
}
|
|
142
|
+
// type === "link" with routeId
|
|
143
|
+
if (!route) {
|
|
144
|
+
return `<div class="small text-danger">Missing route: ${escapeHtml(item.routeId ?? "(none)")}</div>`;
|
|
145
|
+
}
|
|
146
|
+
const serviceTitle = getServiceTitle(config, route.serviceId);
|
|
147
|
+
return `<div class="small d-flex align-items-center gap-2 flex-wrap">
|
|
148
|
+
<span class="badge text-bg-secondary">${escapeHtml(serviceTitle)}</span>
|
|
149
|
+
<span class="text-secondary">-</span>
|
|
150
|
+
<span class="font-monospace">${escapeHtml(route.viewId ?? "(view?)")}</span>
|
|
151
|
+
<span class="text-secondary">-></span>
|
|
152
|
+
<span class="font-monospace text-secondary">${escapeHtml(route.path)}</span>
|
|
153
|
+
${route.targetPath && route.targetPath !== route.path ? `<span class="text-secondary">(target: <span class="font-monospace">${escapeHtml(route.targetPath)}</span>)</span>` : ""}
|
|
154
|
+
<button type="button" class="btn btn-sm btn-link p-0 ms-1"
|
|
155
|
+
hx-get="${API_BASE}/menu-editor/item?appId=${encodeURIComponent(appId)}&itemId=${encodeURIComponent(item.id)}&mode=edit-link"
|
|
156
|
+
hx-target="#bp-menu-row-${item.id}"
|
|
157
|
+
hx-swap="outerHTML"
|
|
158
|
+
title="Edit URL & paths">Edit</button>
|
|
159
|
+
</div>`;
|
|
160
|
+
}
|
|
161
|
+
function actionButtons(item, appId) {
|
|
162
|
+
const btn = (action, label, btnClass, title) => `<form hx-post="${API_BASE}/menu-editor/${action}" hx-target="#bp-menu-editor" hx-swap="outerHTML" class="d-inline m-0">
|
|
163
|
+
<input type="hidden" name="appId" value="${escapeHtml(appId)}" />
|
|
164
|
+
<input type="hidden" name="itemId" value="${escapeHtml(item.id)}" />
|
|
165
|
+
<button type="submit" class="btn btn-sm ${btnClass}" title="${escapeHtml(title)}">${label}</button>
|
|
166
|
+
</form>`;
|
|
167
|
+
const expandedBtn = item.type === "group"
|
|
168
|
+
? btn("toggle-expanded", item.defaultExpanded ? "expanded" : "collapsed", item.defaultExpanded ? "btn-info" : "btn-outline-secondary", item.defaultExpanded ? "Default state: expanded (click to collapse)" : "Default state: collapsed (click to expand)")
|
|
169
|
+
: "";
|
|
170
|
+
return `<div class="btn-group btn-group-sm" role="group">
|
|
171
|
+
${expandedBtn}
|
|
172
|
+
${btn("toggle", item.enabled ? "on" : "off", item.enabled ? "btn-success" : "btn-outline-secondary", item.enabled ? "Disable" : "Enable")}
|
|
173
|
+
${btn("remove", "x", "btn-outline-danger", "Remove")}
|
|
174
|
+
</div>`;
|
|
175
|
+
}
|
|
176
|
+
function renderRow(item, depth, mode, config, appDef, appId) {
|
|
177
|
+
const routes = getRoutes(appDef);
|
|
178
|
+
const route = item.routeId ? routes.find((r) => r.id === item.routeId) ?? null : null;
|
|
179
|
+
const typeBadgeClass = item.type === "group" ? "text-bg-warning" : item.type === "external" ? "text-bg-info" : item.type === "link" ? "text-bg-primary" : "text-bg-secondary";
|
|
180
|
+
if (mode === "edit-title") {
|
|
181
|
+
return `<li ${rowAttrs(item, depth)} class="list-group-item">
|
|
182
|
+
<form hx-post="${API_BASE}/menu-editor/save-title" hx-target="#bp-menu-row-${item.id}" hx-swap="outerHTML"
|
|
183
|
+
class="d-flex align-items-center gap-2">
|
|
184
|
+
<span class="badge ${typeBadgeClass}">${escapeHtml(item.type)}</span>
|
|
185
|
+
<input type="hidden" name="appId" value="${escapeHtml(appId)}" />
|
|
186
|
+
<input type="hidden" name="itemId" value="${escapeHtml(item.id)}" />
|
|
187
|
+
<input type="text" name="title" class="form-control form-control-sm flex-grow-1" value="${escapeHtml(item.title ?? "")}" autofocus />
|
|
188
|
+
<button type="submit" class="btn btn-sm btn-success" title="Save">OK</button>
|
|
189
|
+
<button type="button" class="btn btn-sm btn-outline-secondary" title="Cancel"
|
|
190
|
+
hx-get="${API_BASE}/menu-editor/item?appId=${encodeURIComponent(appId)}&itemId=${encodeURIComponent(item.id)}&mode=display"
|
|
191
|
+
hx-target="#bp-menu-row-${item.id}" hx-swap="outerHTML">X</button>
|
|
192
|
+
</form>
|
|
193
|
+
</li>`;
|
|
194
|
+
}
|
|
195
|
+
if (mode === "edit-external" && item.type === "external") {
|
|
196
|
+
return `<li ${rowAttrs(item, depth)} class="list-group-item">
|
|
197
|
+
<form hx-post="${API_BASE}/menu-editor/save-external" hx-target="#bp-menu-row-${item.id}" hx-swap="outerHTML"
|
|
198
|
+
class="d-flex flex-column gap-2">
|
|
199
|
+
<div class="d-flex align-items-center gap-2">
|
|
200
|
+
<span class="badge ${typeBadgeClass}">${escapeHtml(item.type)}</span>
|
|
201
|
+
<strong>${escapeHtml(item.title || "External")}</strong>
|
|
202
|
+
</div>
|
|
203
|
+
<input type="hidden" name="appId" value="${escapeHtml(appId)}" />
|
|
204
|
+
<input type="hidden" name="itemId" value="${escapeHtml(item.id)}" />
|
|
205
|
+
<label class="form-label small mb-0">URL</label>
|
|
206
|
+
<input type="url" name="href" class="form-control form-control-sm" value="${escapeHtml(item.href ?? "")}" placeholder="https://..." required />
|
|
207
|
+
<div class="d-flex gap-2 justify-content-end">
|
|
208
|
+
<button type="submit" class="btn btn-sm btn-success">OK Save</button>
|
|
209
|
+
<button type="button" class="btn btn-sm btn-outline-secondary"
|
|
210
|
+
hx-get="${API_BASE}/menu-editor/item?appId=${encodeURIComponent(appId)}&itemId=${encodeURIComponent(item.id)}&mode=display"
|
|
211
|
+
hx-target="#bp-menu-row-${item.id}" hx-swap="outerHTML">X Cancel</button>
|
|
212
|
+
</div>
|
|
213
|
+
</form>
|
|
214
|
+
</li>`;
|
|
215
|
+
}
|
|
216
|
+
// display mode
|
|
217
|
+
return `<li ${rowAttrs(item, depth)} class="list-group-item">
|
|
218
|
+
<div class="d-flex align-items-start gap-3">
|
|
219
|
+
<span class="text-secondary" style="cursor:grab; padding-top:0.3rem; user-select:none; font-size:0.75rem; line-height:1;" title="Drag to reorder">drag</span>
|
|
220
|
+
<div class="d-flex flex-column gap-1 flex-grow-1 min-width-0">
|
|
221
|
+
<div class="d-flex align-items-center gap-2">
|
|
222
|
+
<span class="badge ${typeBadgeClass}">${escapeHtml(item.type)}</span>
|
|
223
|
+
${titleDisplayHtml(item, route, appId)}
|
|
224
|
+
</div>
|
|
225
|
+
${subLineHtml(item, route, config, appId)}
|
|
226
|
+
</div>
|
|
227
|
+
${actionButtons(item, appId)}
|
|
228
|
+
</div>
|
|
229
|
+
</li>`;
|
|
230
|
+
}
|
|
231
|
+
function renderViewOptions(views, selectedViewId) {
|
|
232
|
+
return [
|
|
233
|
+
`<option value="">Select view...</option>`,
|
|
234
|
+
...views.map((v) => `<option value="${escapeHtml(v.viewId)}" data-default-path="${escapeHtml(v.path)}"${v.viewId === selectedViewId ? " selected" : ""}>${escapeHtml(v.title)} (${escapeHtml(v.path)})</option>`)
|
|
235
|
+
].join("");
|
|
236
|
+
}
|
|
237
|
+
async function renderEditLink(item, route, depth, config, appDef, appId) {
|
|
238
|
+
const typeBadgeClass = "text-bg-primary";
|
|
239
|
+
const services = getServicesForApp(config, appDef);
|
|
240
|
+
// Views come from the local manifest cache, not a fetch (CM cannot reach services).
|
|
241
|
+
const views = route?.serviceId ? lookupServiceViews(route.serviceId) : [];
|
|
242
|
+
const serviceOpts = [`<option value="">Select service...</option>`,
|
|
243
|
+
...services.map((s) => `<option value="${escapeHtml(s.id)}"${s.id === route?.serviceId ? " selected" : ""}>${escapeHtml(s.title)}</option>`)
|
|
244
|
+
].join("");
|
|
245
|
+
const viewOpts = renderViewOptions(views, route?.viewId ?? "");
|
|
246
|
+
return `<li ${rowAttrs(item, depth)} class="list-group-item">
|
|
247
|
+
<form hx-post="${API_BASE}/menu-editor/save-link" hx-target="#bp-menu-row-${item.id}" hx-swap="outerHTML"
|
|
248
|
+
class="d-flex flex-column gap-2">
|
|
249
|
+
<div class="d-flex align-items-center gap-2">
|
|
250
|
+
<span class="badge ${typeBadgeClass}">link</span>
|
|
251
|
+
<strong>${escapeHtml(item.title || route?.title || "Link")}</strong>
|
|
252
|
+
<span class="small text-secondary ms-auto">Editing link binding</span>
|
|
253
|
+
</div>
|
|
254
|
+
<input type="hidden" name="appId" value="${escapeHtml(appId)}" />
|
|
255
|
+
<input type="hidden" name="itemId" value="${escapeHtml(item.id)}" />
|
|
256
|
+
<div class="row g-2">
|
|
257
|
+
<div class="col-md-6">
|
|
258
|
+
<label class="form-label small mb-0">Service</label>
|
|
259
|
+
<select name="serviceId" class="form-select form-select-sm" required
|
|
260
|
+
hx-get="${API_BASE}/menu-editor/views"
|
|
261
|
+
hx-target="#bp-views-${item.id}"
|
|
262
|
+
hx-swap="innerHTML"
|
|
263
|
+
hx-trigger="change"
|
|
264
|
+
hx-include="this">${serviceOpts}</select>
|
|
265
|
+
</div>
|
|
266
|
+
<div class="col-md-6">
|
|
267
|
+
<label class="form-label small mb-0">View</label>
|
|
268
|
+
<select id="bp-views-${item.id}" name="viewId" class="form-select form-select-sm" required
|
|
269
|
+
hx-get="${API_BASE}/menu-editor/default-target"
|
|
270
|
+
hx-target="[name=targetPath]"
|
|
271
|
+
hx-swap="outerHTML"
|
|
272
|
+
hx-trigger="change"
|
|
273
|
+
hx-include="closest form">${viewOpts}</select>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
<div class="row g-2">
|
|
277
|
+
<div class="col-md-4">
|
|
278
|
+
<label class="form-label small mb-0">Title (menu)</label>
|
|
279
|
+
<input type="text" name="title" class="form-control form-control-sm" value="${escapeHtml(item.title ?? "")}" placeholder="${escapeHtml(route?.title ?? "")}" />
|
|
280
|
+
</div>
|
|
281
|
+
<div class="col-md-4">
|
|
282
|
+
<label class="form-label small mb-0">Public Path</label>
|
|
283
|
+
<input type="text" name="path" class="form-control form-control-sm font-monospace" value="${escapeHtml(route?.path ?? "")}" required />
|
|
284
|
+
</div>
|
|
285
|
+
<div class="col-md-4">
|
|
286
|
+
<label class="form-label small mb-0">Target Path (service)</label>
|
|
287
|
+
<input type="text" name="targetPath" class="form-control form-control-sm font-monospace" value="${escapeHtml(route?.targetPath ?? "")}" placeholder="/path?param=value" />
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
<div class="d-flex gap-2 justify-content-end">
|
|
291
|
+
<button type="submit" class="btn btn-sm btn-success">OK Save</button>
|
|
292
|
+
<button type="button" class="btn btn-sm btn-outline-secondary"
|
|
293
|
+
hx-get="${API_BASE}/menu-editor/item?appId=${encodeURIComponent(appId)}&itemId=${encodeURIComponent(item.id)}&mode=display"
|
|
294
|
+
hx-target="#bp-menu-row-${item.id}" hx-swap="outerHTML">X Cancel</button>
|
|
295
|
+
</div>
|
|
296
|
+
<div class="small text-secondary">Path/target/service/view edits update the underlying route - affects any other menu items referencing it.</div>
|
|
297
|
+
</form>
|
|
298
|
+
</li>`;
|
|
299
|
+
}
|
|
300
|
+
function renderTree(items, depth, config, appDef, appId) {
|
|
301
|
+
return items.map((item) => {
|
|
302
|
+
const row = renderRow(item, depth, "display", config, appDef, appId);
|
|
303
|
+
const children = item.type === "group" && item.children && item.children.length > 0
|
|
304
|
+
? renderTree(item.children, depth + 1, config, appDef, appId)
|
|
305
|
+
: "";
|
|
306
|
+
return row + children;
|
|
307
|
+
}).join("");
|
|
308
|
+
}
|
|
309
|
+
function collectGroups(items) {
|
|
310
|
+
const out = [];
|
|
311
|
+
const walk = (xs, prefix = "") => {
|
|
312
|
+
for (const x of xs) {
|
|
313
|
+
if (x.type === "group") {
|
|
314
|
+
const label = prefix ? `${prefix} / ${x.title ?? "Group"}` : (x.title ?? "Group");
|
|
315
|
+
out.push({ id: x.id, title: label });
|
|
316
|
+
if (x.children)
|
|
317
|
+
walk(x.children, label);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
walk(items);
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
function renderAddForms(appId, routes, groups) {
|
|
325
|
+
const routeOpts = routes.map((r) => `<option value="${escapeHtml(r.id)}">${escapeHtml(r.title || r.path)} (${escapeHtml(r.path)})</option>`).join("");
|
|
326
|
+
const groupOpts = ["<option value=\"\">(root)</option>", ...groups.map((g) => `<option value="${escapeHtml(g.id)}">${escapeHtml(g.title)}</option>`)].join("");
|
|
327
|
+
return `<div class="row g-3 mt-4">
|
|
328
|
+
<div class="col-md-4">
|
|
329
|
+
<div class="card">
|
|
330
|
+
<div class="card-header"><strong>Add View Link</strong></div>
|
|
331
|
+
<div class="card-body">
|
|
332
|
+
<form hx-post="${API_BASE}/menu-editor/add" hx-target="#bp-menu-editor" hx-swap="outerHTML">
|
|
333
|
+
<input type="hidden" name="appId" value="${escapeHtml(appId)}" />
|
|
334
|
+
<input type="hidden" name="type" value="link" />
|
|
335
|
+
<div class="mb-2">
|
|
336
|
+
<label class="form-label small">Route</label>
|
|
337
|
+
<select class="form-select form-select-sm" name="routeId" required>
|
|
338
|
+
<option value="">Select route...</option>
|
|
339
|
+
${routeOpts}
|
|
340
|
+
</select>
|
|
341
|
+
</div>
|
|
342
|
+
<div class="mb-2">
|
|
343
|
+
<label class="form-label small">Title (optional)</label>
|
|
344
|
+
<input type="text" class="form-control form-control-sm" name="title" placeholder="Defaults to route title" />
|
|
345
|
+
</div>
|
|
346
|
+
<div class="mb-2">
|
|
347
|
+
<label class="form-label small">Parent Group</label>
|
|
348
|
+
<select class="form-select form-select-sm" name="parentId">${groupOpts}</select>
|
|
349
|
+
</div>
|
|
350
|
+
<button type="submit" class="btn btn-primary btn-sm w-100">Add Link</button>
|
|
351
|
+
</form>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
<div class="col-md-4">
|
|
356
|
+
<div class="card">
|
|
357
|
+
<div class="card-header"><strong>Add External Link</strong></div>
|
|
358
|
+
<div class="card-body">
|
|
359
|
+
<form hx-post="${API_BASE}/menu-editor/add" hx-target="#bp-menu-editor" hx-swap="outerHTML">
|
|
360
|
+
<input type="hidden" name="appId" value="${escapeHtml(appId)}" />
|
|
361
|
+
<input type="hidden" name="type" value="external" />
|
|
362
|
+
<div class="mb-2">
|
|
363
|
+
<label class="form-label small">Title</label>
|
|
364
|
+
<input type="text" class="form-control form-control-sm" name="title" required />
|
|
365
|
+
</div>
|
|
366
|
+
<div class="mb-2">
|
|
367
|
+
<label class="form-label small">URL</label>
|
|
368
|
+
<input type="url" class="form-control form-control-sm" name="href" placeholder="https://..." required />
|
|
369
|
+
</div>
|
|
370
|
+
<div class="mb-2">
|
|
371
|
+
<label class="form-label small">Parent Group</label>
|
|
372
|
+
<select class="form-select form-select-sm" name="parentId">${groupOpts}</select>
|
|
373
|
+
</div>
|
|
374
|
+
<button type="submit" class="btn btn-info btn-sm w-100">Add External</button>
|
|
375
|
+
</form>
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
<div class="col-md-4">
|
|
380
|
+
<div class="card">
|
|
381
|
+
<div class="card-header"><strong>Add Group</strong></div>
|
|
382
|
+
<div class="card-body">
|
|
383
|
+
<form hx-post="${API_BASE}/menu-editor/add" hx-target="#bp-menu-editor" hx-swap="outerHTML">
|
|
384
|
+
<input type="hidden" name="appId" value="${escapeHtml(appId)}" />
|
|
385
|
+
<input type="hidden" name="type" value="group" />
|
|
386
|
+
<div class="mb-2">
|
|
387
|
+
<label class="form-label small">Title</label>
|
|
388
|
+
<input type="text" class="form-control form-control-sm" name="title" placeholder="Group name" required />
|
|
389
|
+
</div>
|
|
390
|
+
<div class="mb-2">
|
|
391
|
+
<label class="form-label small">Parent Group</label>
|
|
392
|
+
<select class="form-select form-select-sm" name="parentId">${groupOpts}</select>
|
|
393
|
+
</div>
|
|
394
|
+
<button type="submit" class="btn btn-warning btn-sm w-100">Add Group</button>
|
|
395
|
+
</form>
|
|
396
|
+
</div>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
</div>`;
|
|
400
|
+
}
|
|
401
|
+
function renderEditor(config, appDef, appId) {
|
|
402
|
+
const menu = getMenu(appDef);
|
|
403
|
+
const routes = getRoutes(appDef);
|
|
404
|
+
const tree = menu.length === 0
|
|
405
|
+
? `<li class="list-group-item text-secondary">No menu items. Use forms below to add.</li>`
|
|
406
|
+
: renderTree(menu, 0, config, appDef, appId);
|
|
407
|
+
const groups = collectGroups(menu);
|
|
408
|
+
return `<div id="bp-menu-editor" data-bp-app-id="${escapeHtml(appId)}">
|
|
409
|
+
<form id="bp-drag-move-form" style="display:none"
|
|
410
|
+
hx-post="${API_BASE}/menu-editor/move-after"
|
|
411
|
+
hx-target="#bp-menu-editor"
|
|
412
|
+
hx-swap="outerHTML">
|
|
413
|
+
<input type="hidden" name="appId" value="${escapeHtml(appId)}" />
|
|
414
|
+
<input type="hidden" name="itemId" />
|
|
415
|
+
<input type="hidden" name="anchorId" />
|
|
416
|
+
<input type="hidden" name="targetDepth" />
|
|
417
|
+
</form>
|
|
418
|
+
<ul class="list-group">${tree}</ul>
|
|
419
|
+
${renderAddForms(appId, routes, groups)}
|
|
420
|
+
</div>`;
|
|
421
|
+
}
|
|
422
|
+
// -- Endpoint registration --------------------------------------------
|
|
423
|
+
export function registerMenuEditorRoutes(app, store) {
|
|
424
|
+
const respondEditor = async (appId) => {
|
|
425
|
+
const config = await store.loadConfig();
|
|
426
|
+
const appDef = getApp(config, appId);
|
|
427
|
+
if (!appDef)
|
|
428
|
+
return htmlResponse(`<div class="alert alert-danger">App not found</div>`, 200, "text/html; mode=fragment");
|
|
429
|
+
return htmlResponse(renderEditor(config, appDef, appId), 200, "text/html; mode=fragment", {
|
|
430
|
+
"HX-Trigger": "bp:menu-changed"
|
|
431
|
+
});
|
|
432
|
+
};
|
|
433
|
+
const respondRow = async (appId, itemId, mode) => {
|
|
434
|
+
const config = await store.loadConfig();
|
|
435
|
+
const appDef = getApp(config, appId);
|
|
436
|
+
if (!appDef)
|
|
437
|
+
return htmlResponse("", 200, "text/html; mode=fragment");
|
|
438
|
+
const menu = getMenu(appDef);
|
|
439
|
+
const found = locate(menu, itemId);
|
|
440
|
+
if (!found)
|
|
441
|
+
return htmlResponse("", 200, "text/html; mode=fragment");
|
|
442
|
+
// Determine depth by walking up
|
|
443
|
+
let depth = 0;
|
|
444
|
+
const computeDepth = (items, target, d) => {
|
|
445
|
+
for (const it of items) {
|
|
446
|
+
if (it.id === target)
|
|
447
|
+
return d;
|
|
448
|
+
if (it.type === "group" && it.children) {
|
|
449
|
+
const r = computeDepth(it.children, target, d + 1);
|
|
450
|
+
if (r >= 0)
|
|
451
|
+
return r;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return -1;
|
|
455
|
+
};
|
|
456
|
+
depth = computeDepth(menu, itemId, 0);
|
|
457
|
+
if (depth < 0)
|
|
458
|
+
depth = 0;
|
|
459
|
+
if (mode === "edit-link" && found.item.type === "link") {
|
|
460
|
+
const route = (appDef.routes ?? []).find((r) => r.id === found.item.routeId) ?? null;
|
|
461
|
+
const html = await renderEditLink(found.item, route, depth, config, appDef, appId);
|
|
462
|
+
return htmlResponse(html, 200, "text/html; mode=fragment");
|
|
463
|
+
}
|
|
464
|
+
return htmlResponse(renderRow(found.item, depth, mode, config, appDef, appId), 200, "text/html; mode=fragment");
|
|
465
|
+
};
|
|
466
|
+
app.get(`${API_BASE}/menu-editor`, async (event) => {
|
|
467
|
+
const url = new URL(event.req.url ?? "", `http://${event.req.headers.get("host") ?? "localhost"}`);
|
|
468
|
+
const appId = url.searchParams.get("appId") ?? "";
|
|
469
|
+
if (!appId)
|
|
470
|
+
return htmlResponse(`<div class="alert alert-secondary">Select an app</div>`, 200, "text/html; mode=fragment");
|
|
471
|
+
return respondEditor(appId);
|
|
472
|
+
});
|
|
473
|
+
app.get(`${API_BASE}/menu-editor/item`, async (event) => {
|
|
474
|
+
const url = new URL(event.req.url ?? "", `http://${event.req.headers.get("host") ?? "localhost"}`);
|
|
475
|
+
const appId = url.searchParams.get("appId") ?? "";
|
|
476
|
+
const itemId = url.searchParams.get("itemId") ?? "";
|
|
477
|
+
const mode = (url.searchParams.get("mode") ?? "display");
|
|
478
|
+
return respondRow(appId, itemId, mode);
|
|
479
|
+
});
|
|
480
|
+
app.post(`${API_BASE}/menu-editor/save-title`, async (event) => {
|
|
481
|
+
const f = await readFormBody(event);
|
|
482
|
+
const config = await store.loadConfig();
|
|
483
|
+
const appDef = getApp(config, f.appId);
|
|
484
|
+
if (!appDef)
|
|
485
|
+
return jsonResponse({ error: "App not found" }, 404);
|
|
486
|
+
const menu = getMenu(appDef);
|
|
487
|
+
const found = locate(menu, f.itemId);
|
|
488
|
+
if (found)
|
|
489
|
+
found.item.title = f.title || undefined;
|
|
490
|
+
appDef.menu = menu;
|
|
491
|
+
await store.saveConfig(config);
|
|
492
|
+
// Return single row + HX-Trigger to refresh sidebar nav
|
|
493
|
+
const config2 = await store.loadConfig();
|
|
494
|
+
const appDef2 = getApp(config2, f.appId);
|
|
495
|
+
const found2 = appDef2 ? locate(getMenu(appDef2), f.itemId) : null;
|
|
496
|
+
if (!appDef2 || !found2)
|
|
497
|
+
return htmlResponse("", 200, "text/html; mode=fragment");
|
|
498
|
+
const depth = (() => {
|
|
499
|
+
let d = -1;
|
|
500
|
+
const walk = (xs, target, cur) => {
|
|
501
|
+
for (const it of xs) {
|
|
502
|
+
if (it.id === target) {
|
|
503
|
+
d = cur;
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (it.type === "group" && it.children)
|
|
507
|
+
walk(it.children, target, cur + 1);
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
walk(getMenu(appDef2), f.itemId, 0);
|
|
511
|
+
return d < 0 ? 0 : d;
|
|
512
|
+
})();
|
|
513
|
+
return htmlResponse(renderRow(found2.item, depth, "display", config2, appDef2, f.appId), 200, "text/html; mode=fragment", {
|
|
514
|
+
"HX-Trigger": "bp:menu-changed"
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
app.post(`${API_BASE}/menu-editor/save-link`, async (event) => {
|
|
518
|
+
const f = await readFormBody(event);
|
|
519
|
+
const config = await store.loadConfig();
|
|
520
|
+
const appDef = getApp(config, f.appId);
|
|
521
|
+
if (!appDef)
|
|
522
|
+
return jsonResponse({ error: "App not found" }, 404);
|
|
523
|
+
const menu = getMenu(appDef);
|
|
524
|
+
const found = locate(menu, f.itemId);
|
|
525
|
+
if (!found || found.item.type !== "link" || !found.item.routeId) {
|
|
526
|
+
return htmlResponse(`<div class="alert alert-danger">Item not found</div>`, 200, "text/html; mode=fragment");
|
|
527
|
+
}
|
|
528
|
+
found.item.title = f.title || undefined;
|
|
529
|
+
const route = (appDef.routes ?? []).find((r) => r.id === found.item.routeId);
|
|
530
|
+
if (route) {
|
|
531
|
+
if (f.serviceId)
|
|
532
|
+
route.serviceId = f.serviceId;
|
|
533
|
+
if (f.viewId)
|
|
534
|
+
route.viewId = f.viewId;
|
|
535
|
+
if (f.path)
|
|
536
|
+
route.path = f.path;
|
|
537
|
+
if (f.targetPath !== undefined)
|
|
538
|
+
route.targetPath = f.targetPath;
|
|
539
|
+
}
|
|
540
|
+
appDef.menu = menu;
|
|
541
|
+
await store.saveConfig(config);
|
|
542
|
+
return respondRow(f.appId, f.itemId, "display").then((r) => {
|
|
543
|
+
const headers = new Headers(r.headers);
|
|
544
|
+
headers.set("HX-Trigger", "bp:menu-changed");
|
|
545
|
+
return new Response(r.body, { status: r.status, headers });
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
// Cascade: serviceId change -> view options (sourced from local manifest cache)
|
|
549
|
+
app.get(`${API_BASE}/menu-editor/views`, async (event) => {
|
|
550
|
+
const url = new URL(event.req.url ?? "", `http://${event.req.headers.get("host") ?? "localhost"}`);
|
|
551
|
+
const serviceId = url.searchParams.get("serviceId") ?? "";
|
|
552
|
+
if (!serviceId)
|
|
553
|
+
return htmlResponse(`<option value="">Select view...</option>`, 200, "text/html; mode=fragment");
|
|
554
|
+
const views = lookupServiceViews(serviceId);
|
|
555
|
+
return htmlResponse(renderViewOptions(views, ""), 200, "text/html; mode=fragment");
|
|
556
|
+
});
|
|
557
|
+
// View change -> returns new targetPath input pre-filled with view's default path
|
|
558
|
+
app.get(`${API_BASE}/menu-editor/default-target`, async (event) => {
|
|
559
|
+
const url = new URL(event.req.url ?? "", `http://${event.req.headers.get("host") ?? "localhost"}`);
|
|
560
|
+
const serviceId = url.searchParams.get("serviceId") ?? "";
|
|
561
|
+
const viewId = url.searchParams.get("viewId") ?? "";
|
|
562
|
+
let defaultPath = "";
|
|
563
|
+
if (serviceId && viewId) {
|
|
564
|
+
const views = lookupServiceViews(serviceId);
|
|
565
|
+
defaultPath = views.find((v) => v.viewId === viewId)?.path ?? "";
|
|
566
|
+
}
|
|
567
|
+
return htmlResponse(`<input type="text" name="targetPath" class="form-control form-control-sm font-monospace" value="${escapeHtml(defaultPath)}" placeholder="/path?param=value" />`, 200, "text/html; mode=fragment");
|
|
568
|
+
});
|
|
569
|
+
app.post(`${API_BASE}/menu-editor/save-external`, async (event) => {
|
|
570
|
+
const f = await readFormBody(event);
|
|
571
|
+
const config = await store.loadConfig();
|
|
572
|
+
const appDef = getApp(config, f.appId);
|
|
573
|
+
if (!appDef)
|
|
574
|
+
return jsonResponse({ error: "App not found" }, 404);
|
|
575
|
+
const menu = getMenu(appDef);
|
|
576
|
+
const found = locate(menu, f.itemId);
|
|
577
|
+
if (found) {
|
|
578
|
+
found.item.href = f.href || "";
|
|
579
|
+
if (f.title !== undefined && f.title !== "")
|
|
580
|
+
found.item.title = f.title;
|
|
581
|
+
}
|
|
582
|
+
appDef.menu = menu;
|
|
583
|
+
await store.saveConfig(config);
|
|
584
|
+
return respondRow(f.appId, f.itemId, "display").then((r) => {
|
|
585
|
+
// Add HX-Trigger to existing response
|
|
586
|
+
const headers = new Headers(r.headers);
|
|
587
|
+
headers.set("HX-Trigger", "bp:menu-changed");
|
|
588
|
+
return new Response(r.body, { status: r.status, headers });
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
app.post(`${API_BASE}/menu-editor/add`, async (event) => {
|
|
592
|
+
const f = await readFormBody(event);
|
|
593
|
+
const config = await store.loadConfig();
|
|
594
|
+
const appDef = getApp(config, f.appId);
|
|
595
|
+
if (!appDef)
|
|
596
|
+
return jsonResponse({ error: "App not found" }, 404);
|
|
597
|
+
const type = f.type ?? "link";
|
|
598
|
+
const newItem = {
|
|
599
|
+
id: uuidv7(),
|
|
600
|
+
type,
|
|
601
|
+
title: f.title || undefined,
|
|
602
|
+
routeId: f.routeId || undefined,
|
|
603
|
+
href: f.href || undefined,
|
|
604
|
+
enabled: true,
|
|
605
|
+
...(type === "group" ? { children: [] } : {})
|
|
606
|
+
};
|
|
607
|
+
const menu = getMenu(appDef);
|
|
608
|
+
if (f.parentId) {
|
|
609
|
+
const found = locate(menu, f.parentId);
|
|
610
|
+
if (found && found.item.type === "group") {
|
|
611
|
+
found.item.children = found.item.children ?? [];
|
|
612
|
+
found.item.children.push(newItem);
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
menu.push(newItem);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
menu.push(newItem);
|
|
620
|
+
}
|
|
621
|
+
appDef.menu = menu;
|
|
622
|
+
await store.saveConfig(config);
|
|
623
|
+
return respondEditor(f.appId);
|
|
624
|
+
});
|
|
625
|
+
app.post(`${API_BASE}/menu-editor/remove`, async (event) => {
|
|
626
|
+
const f = await readFormBody(event);
|
|
627
|
+
const config = await store.loadConfig();
|
|
628
|
+
const appDef = getApp(config, f.appId);
|
|
629
|
+
if (!appDef)
|
|
630
|
+
return jsonResponse({ error: "App not found" }, 404);
|
|
631
|
+
const menu = getMenu(appDef);
|
|
632
|
+
const found = locate(menu, f.itemId);
|
|
633
|
+
if (found)
|
|
634
|
+
found.parent.splice(found.index, 1);
|
|
635
|
+
appDef.menu = menu;
|
|
636
|
+
await store.saveConfig(config);
|
|
637
|
+
return respondEditor(f.appId);
|
|
638
|
+
});
|
|
639
|
+
app.post(`${API_BASE}/menu-editor/toggle`, async (event) => {
|
|
640
|
+
const f = await readFormBody(event);
|
|
641
|
+
const config = await store.loadConfig();
|
|
642
|
+
const appDef = getApp(config, f.appId);
|
|
643
|
+
if (!appDef)
|
|
644
|
+
return jsonResponse({ error: "App not found" }, 404);
|
|
645
|
+
const menu = getMenu(appDef);
|
|
646
|
+
const found = locate(menu, f.itemId);
|
|
647
|
+
if (found)
|
|
648
|
+
found.item.enabled = !found.item.enabled;
|
|
649
|
+
appDef.menu = menu;
|
|
650
|
+
await store.saveConfig(config);
|
|
651
|
+
return respondEditor(f.appId);
|
|
652
|
+
});
|
|
653
|
+
app.post(`${API_BASE}/menu-editor/toggle-expanded`, async (event) => {
|
|
654
|
+
const f = await readFormBody(event);
|
|
655
|
+
const config = await store.loadConfig();
|
|
656
|
+
const appDef = getApp(config, f.appId);
|
|
657
|
+
if (!appDef)
|
|
658
|
+
return jsonResponse({ error: "App not found" }, 404);
|
|
659
|
+
const menu = getMenu(appDef);
|
|
660
|
+
const found = locate(menu, f.itemId);
|
|
661
|
+
if (found && found.item.type === "group") {
|
|
662
|
+
found.item.defaultExpanded = !found.item.defaultExpanded;
|
|
663
|
+
}
|
|
664
|
+
appDef.menu = menu;
|
|
665
|
+
await store.saveConfig(config);
|
|
666
|
+
return respondEditor(f.appId);
|
|
667
|
+
});
|
|
668
|
+
app.post(`${API_BASE}/menu-editor/move-up`, async (event) => {
|
|
669
|
+
const f = await readFormBody(event);
|
|
670
|
+
const config = await store.loadConfig();
|
|
671
|
+
const appDef = getApp(config, f.appId);
|
|
672
|
+
if (!appDef)
|
|
673
|
+
return jsonResponse({ error: "App not found" }, 404);
|
|
674
|
+
const menu = getMenu(appDef);
|
|
675
|
+
const found = locate(menu, f.itemId);
|
|
676
|
+
if (found && found.index > 0) {
|
|
677
|
+
const [it] = found.parent.splice(found.index, 1);
|
|
678
|
+
found.parent.splice(found.index - 1, 0, it);
|
|
679
|
+
}
|
|
680
|
+
appDef.menu = menu;
|
|
681
|
+
await store.saveConfig(config);
|
|
682
|
+
return respondEditor(f.appId);
|
|
683
|
+
});
|
|
684
|
+
app.post(`${API_BASE}/menu-editor/move-down`, async (event) => {
|
|
685
|
+
const f = await readFormBody(event);
|
|
686
|
+
const config = await store.loadConfig();
|
|
687
|
+
const appDef = getApp(config, f.appId);
|
|
688
|
+
if (!appDef)
|
|
689
|
+
return jsonResponse({ error: "App not found" }, 404);
|
|
690
|
+
const menu = getMenu(appDef);
|
|
691
|
+
const found = locate(menu, f.itemId);
|
|
692
|
+
if (found && found.index < found.parent.length - 1) {
|
|
693
|
+
const [it] = found.parent.splice(found.index, 1);
|
|
694
|
+
found.parent.splice(found.index + 1, 0, it);
|
|
695
|
+
}
|
|
696
|
+
appDef.menu = menu;
|
|
697
|
+
await store.saveConfig(config);
|
|
698
|
+
return respondEditor(f.appId);
|
|
699
|
+
});
|
|
700
|
+
app.post(`${API_BASE}/menu-editor/move-in`, async (event) => {
|
|
701
|
+
const f = await readFormBody(event);
|
|
702
|
+
const config = await store.loadConfig();
|
|
703
|
+
const appDef = getApp(config, f.appId);
|
|
704
|
+
if (!appDef)
|
|
705
|
+
return jsonResponse({ error: "App not found" }, 404);
|
|
706
|
+
const menu = getMenu(appDef);
|
|
707
|
+
const found = locate(menu, f.itemId);
|
|
708
|
+
if (found && found.index > 0) {
|
|
709
|
+
const prev = found.parent[found.index - 1];
|
|
710
|
+
if (prev.type === "group") {
|
|
711
|
+
const [it] = found.parent.splice(found.index, 1);
|
|
712
|
+
prev.children = prev.children ?? [];
|
|
713
|
+
prev.children.push(it);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
appDef.menu = menu;
|
|
717
|
+
await store.saveConfig(config);
|
|
718
|
+
return respondEditor(f.appId);
|
|
719
|
+
});
|
|
720
|
+
app.post(`${API_BASE}/menu-editor/move-after`, async (event) => {
|
|
721
|
+
const f = await readFormBody(event);
|
|
722
|
+
const config = await store.loadConfig();
|
|
723
|
+
const appDef = getApp(config, f.appId);
|
|
724
|
+
if (!appDef)
|
|
725
|
+
return jsonResponse({ error: "App not found" }, 404);
|
|
726
|
+
const menu = getMenu(appDef);
|
|
727
|
+
const srcId = f.itemId;
|
|
728
|
+
const anchorId = f.anchorId || "";
|
|
729
|
+
const targetDepth = Math.max(0, parseInt(f.targetDepth ?? "0", 10) || 0);
|
|
730
|
+
if (!srcId || srcId === anchorId)
|
|
731
|
+
return respondEditor(f.appId);
|
|
732
|
+
const src = locate(menu, srcId);
|
|
733
|
+
if (!src)
|
|
734
|
+
return respondEditor(f.appId);
|
|
735
|
+
// Prevent dropping into own descendant
|
|
736
|
+
const isDescendant = (items, id) => {
|
|
737
|
+
for (const it of items) {
|
|
738
|
+
if (it.id === id)
|
|
739
|
+
return true;
|
|
740
|
+
if (it.type === "group" && it.children && isDescendant(it.children, id))
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
return false;
|
|
744
|
+
};
|
|
745
|
+
if (anchorId && src.item.type === "group" && src.item.children && isDescendant(src.item.children, anchorId)) {
|
|
746
|
+
return respondEditor(f.appId);
|
|
747
|
+
}
|
|
748
|
+
// Remove src
|
|
749
|
+
const [srcItem] = src.parent.splice(src.index, 1);
|
|
750
|
+
if (!anchorId) {
|
|
751
|
+
// Insert at start of root
|
|
752
|
+
menu.unshift(srcItem);
|
|
753
|
+
appDef.menu = menu;
|
|
754
|
+
await store.saveConfig(config);
|
|
755
|
+
return respondEditor(f.appId);
|
|
756
|
+
}
|
|
757
|
+
// Build ancestor chain to anchor
|
|
758
|
+
const ancestorChain = (items, id, chain = [], depth = 0) => {
|
|
759
|
+
for (let i = 0; i < items.length; i++) {
|
|
760
|
+
const cur = { item: items[i], parent: items, index: i, depth };
|
|
761
|
+
if (items[i].id === id)
|
|
762
|
+
return [...chain, cur];
|
|
763
|
+
if (items[i].type === "group" && items[i].children) {
|
|
764
|
+
const r = ancestorChain(items[i].children, id, [...chain, cur], depth + 1);
|
|
765
|
+
if (r)
|
|
766
|
+
return r;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return null;
|
|
770
|
+
};
|
|
771
|
+
const chain = ancestorChain(menu, anchorId);
|
|
772
|
+
if (!chain) {
|
|
773
|
+
src.parent.splice(src.index, 0, srcItem);
|
|
774
|
+
return respondEditor(f.appId);
|
|
775
|
+
}
|
|
776
|
+
const anchor = chain[chain.length - 1];
|
|
777
|
+
const anchorDepth = anchor.depth;
|
|
778
|
+
// Groups can only live at root (data model doesn't support nested groups)
|
|
779
|
+
const srcIsGroup = srcItem.type === "group";
|
|
780
|
+
const maxDepth = srcIsGroup ? 0 : (anchorDepth + (anchor.item.type === "group" ? 1 : 0));
|
|
781
|
+
const clampedDepth = Math.max(0, Math.min(targetDepth, maxDepth));
|
|
782
|
+
if (clampedDepth > anchorDepth && anchor.item.type === "group") {
|
|
783
|
+
anchor.item.children = anchor.item.children ?? [];
|
|
784
|
+
anchor.item.children.unshift(srcItem);
|
|
785
|
+
}
|
|
786
|
+
else if (clampedDepth >= anchorDepth) {
|
|
787
|
+
anchor.parent.splice(anchor.index + 1, 0, srcItem);
|
|
788
|
+
}
|
|
789
|
+
else {
|
|
790
|
+
const ancestor = chain[clampedDepth];
|
|
791
|
+
if (ancestor) {
|
|
792
|
+
ancestor.parent.splice(ancestor.index + 1, 0, srcItem);
|
|
793
|
+
}
|
|
794
|
+
else {
|
|
795
|
+
menu.push(srcItem);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
appDef.menu = menu;
|
|
799
|
+
await store.saveConfig(config);
|
|
800
|
+
return respondEditor(f.appId);
|
|
801
|
+
});
|
|
802
|
+
app.post(`${API_BASE}/menu-editor/move-out`, async (event) => {
|
|
803
|
+
const f = await readFormBody(event);
|
|
804
|
+
const config = await store.loadConfig();
|
|
805
|
+
const appDef = getApp(config, f.appId);
|
|
806
|
+
if (!appDef)
|
|
807
|
+
return jsonResponse({ error: "App not found" }, 404);
|
|
808
|
+
const menu = getMenu(appDef);
|
|
809
|
+
const parentGroup = findGroupParent(menu, f.itemId);
|
|
810
|
+
if (parentGroup) {
|
|
811
|
+
const grandFound = locate(menu, parentGroup.id);
|
|
812
|
+
const found = locate(menu, f.itemId);
|
|
813
|
+
if (grandFound && found) {
|
|
814
|
+
const [it] = found.parent.splice(found.index, 1);
|
|
815
|
+
grandFound.parent.splice(grandFound.index + 1, 0, it);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
appDef.menu = menu;
|
|
819
|
+
await store.saveConfig(config);
|
|
820
|
+
return respondEditor(f.appId);
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
//# sourceMappingURL=menuEditor.js.map
|