@betterportal/config-manager 10.1.43 → 10.1.45

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.
Files changed (111) hide show
  1. package/lib/.bsb/clients/service-betterportal-config-manager.d.ts +1 -1
  2. package/lib/.bsb/clients/service-betterportal-config-manager.js +1 -1
  3. package/lib/bp-contracts/org.betterportal.config-manager.json +69 -55
  4. package/lib/plugins/service-betterportal-config-manager/.bp-generated/registry.d.ts.map +1 -1
  5. package/lib/plugins/service-betterportal-config-manager/.bp-generated/registry.js +42 -41
  6. package/lib/plugins/service-betterportal-config-manager/.bp-generated/registry.js.map +1 -1
  7. package/lib/plugins/service-betterportal-config-manager/adminApi.js +3 -3
  8. package/lib/plugins/service-betterportal-config-manager/adminApi.js.map +1 -1
  9. package/lib/plugins/service-betterportal-config-manager/bootstrapEndpoint.js +1 -1
  10. package/lib/plugins/service-betterportal-config-manager/bootstrapEndpoint.js.map +1 -1
  11. package/lib/plugins/service-betterportal-config-manager/bootstrapWizardHtml.js +1 -1
  12. package/lib/plugins/service-betterportal-config-manager/bp-routes/{tenants/_theme.bootstrap1 → auth/_renderer.bootstrap5}/GET.d.ts.map +1 -1
  13. package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/_renderer.bootstrap5/GET.js.map +1 -0
  14. package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_renderer.bootstrap5/GET.d.ts.map +1 -0
  15. package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_renderer.bootstrap5/GET.js.map +1 -0
  16. package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_renderer.embedded/GET.d.ts.map +1 -0
  17. package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_renderer.embedded/GET.js.map +1 -0
  18. package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/_renderer.bootstrap5/GET.d.ts.map +1 -0
  19. package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/_renderer.bootstrap5/GET.js.map +1 -0
  20. package/lib/plugins/service-betterportal-config-manager/bp-routes/{preview/_theme.bootstrap1 → menu/_renderer.bootstrap5}/GET.d.ts.map +1 -1
  21. package/lib/plugins/service-betterportal-config-manager/bp-routes/menu/_renderer.bootstrap5/GET.js.map +1 -0
  22. package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/_renderer.bootstrap5/GET.d.ts.map +1 -0
  23. package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/_renderer.bootstrap5/GET.js.map +1 -0
  24. package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/route.impl.d.ts +2 -2
  25. package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/route.impl.js +1 -1
  26. package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/route.impl.js.map +1 -1
  27. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_renderer.bootstrap5/GET.d.ts.map +1 -0
  28. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/{_theme.bootstrap1 → _renderer.bootstrap5}/GET.js +2 -2
  29. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_renderer.bootstrap5/GET.js.map +1 -0
  30. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/_renderer.bootstrap5/GET.d.ts.map +1 -0
  31. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/{_theme.bootstrap1 → _renderer.bootstrap5}/GET.js +2 -2
  32. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/_renderer.bootstrap5/GET.js.map +1 -0
  33. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/route.impl.d.ts +0 -2
  34. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/route.impl.d.ts.map +1 -1
  35. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/route.impl.js +1 -2
  36. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/route.impl.js.map +1 -1
  37. package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/_renderer.bootstrap5/GET.d.ts.map +1 -0
  38. package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/_renderer.bootstrap5/GET.js.map +1 -0
  39. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/{_theme.bootstrap1 → _renderer.bootstrap5}/DELETE.d.ts.map +1 -1
  40. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/{_theme.bootstrap1 → _renderer.bootstrap5}/DELETE.js.map +1 -1
  41. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/_renderer.bootstrap5/GET.d.ts.map +1 -0
  42. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/_renderer.bootstrap5/GET.js +72 -0
  43. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/_renderer.bootstrap5/GET.js.map +1 -0
  44. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/{_theme.bootstrap1 → _renderer.bootstrap5}/POST.d.ts.map +1 -1
  45. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/{_theme.bootstrap1 → _renderer.bootstrap5}/POST.js.map +1 -1
  46. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/{_theme.bootstrap1 → _renderer.bootstrap5}/PUT.d.ts.map +1 -1
  47. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/{_theme.bootstrap1 → _renderer.bootstrap5}/PUT.js.map +1 -1
  48. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/route.impl.d.ts +20 -0
  49. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/route.impl.d.ts.map +1 -1
  50. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/route.impl.js +50 -32
  51. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/route.impl.js.map +1 -1
  52. package/lib/plugins/service-betterportal-config-manager/fragmentsEditor.d.ts +1 -1
  53. package/lib/plugins/service-betterportal-config-manager/fragmentsEditor.d.ts.map +1 -1
  54. package/lib/plugins/service-betterportal-config-manager/fragmentsEditor.js +207 -341
  55. package/lib/plugins/service-betterportal-config-manager/fragmentsEditor.js.map +1 -1
  56. package/lib/plugins/service-betterportal-config-manager/index.d.ts.map +1 -1
  57. package/lib/plugins/service-betterportal-config-manager/index.js +5 -6
  58. package/lib/plugins/service-betterportal-config-manager/index.js.map +1 -1
  59. package/lib/plugins/service-betterportal-config-manager/storage/core.d.ts +0 -1
  60. package/lib/plugins/service-betterportal-config-manager/storage/core.d.ts.map +1 -1
  61. package/lib/plugins/service-betterportal-config-manager/storage/core.js +27 -33
  62. package/lib/plugins/service-betterportal-config-manager/storage/core.js.map +1 -1
  63. package/lib/plugins/service-betterportal-config-manager/syncApi.d.ts +11 -4
  64. package/lib/plugins/service-betterportal-config-manager/syncApi.d.ts.map +1 -1
  65. package/lib/plugins/service-betterportal-config-manager/syncApi.js +40 -10
  66. package/lib/plugins/service-betterportal-config-manager/syncApi.js.map +1 -1
  67. package/lib/schemas/service-betterportal-config-manager.json +1 -1
  68. package/lib/schemas/service-betterportal-config-manager.plugin.json +1 -1
  69. package/package.json +3 -3
  70. package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/_theme.bootstrap1/GET.d.ts.map +0 -1
  71. package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/_theme.bootstrap1/GET.js.map +0 -1
  72. package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_theme.bootstrap1/GET.d.ts.map +0 -1
  73. package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_theme.bootstrap1/GET.js.map +0 -1
  74. package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_theme.embedded/GET.d.ts.map +0 -1
  75. package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_theme.embedded/GET.js.map +0 -1
  76. package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/_theme.bootstrap1/GET.d.ts.map +0 -1
  77. package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/_theme.bootstrap1/GET.js.map +0 -1
  78. package/lib/plugins/service-betterportal-config-manager/bp-routes/menu/_theme.bootstrap1/GET.d.ts.map +0 -1
  79. package/lib/plugins/service-betterportal-config-manager/bp-routes/menu/_theme.bootstrap1/GET.js.map +0 -1
  80. package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/_theme.bootstrap1/GET.js.map +0 -1
  81. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_theme.bootstrap1/GET.d.ts.map +0 -1
  82. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_theme.bootstrap1/GET.js.map +0 -1
  83. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/_theme.bootstrap1/GET.d.ts.map +0 -1
  84. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/_theme.bootstrap1/GET.js.map +0 -1
  85. package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/_theme.bootstrap1/GET.d.ts.map +0 -1
  86. package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/_theme.bootstrap1/GET.js.map +0 -1
  87. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/_theme.bootstrap1/GET.js +0 -72
  88. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/_theme.bootstrap1/GET.js.map +0 -1
  89. /package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/{_theme.bootstrap1 → _renderer.bootstrap5}/GET.d.ts +0 -0
  90. /package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/{_theme.bootstrap1 → _renderer.bootstrap5}/GET.js +0 -0
  91. /package/lib/plugins/service-betterportal-config-manager/bp-routes/config/{_theme.bootstrap1 → _renderer.bootstrap5}/GET.d.ts +0 -0
  92. /package/lib/plugins/service-betterportal-config-manager/bp-routes/config/{_theme.bootstrap1 → _renderer.bootstrap5}/GET.js +0 -0
  93. /package/lib/plugins/service-betterportal-config-manager/bp-routes/config/{_theme.embedded → _renderer.embedded}/GET.d.ts +0 -0
  94. /package/lib/plugins/service-betterportal-config-manager/bp-routes/config/{_theme.embedded → _renderer.embedded}/GET.js +0 -0
  95. /package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/{_theme.bootstrap1 → _renderer.bootstrap5}/GET.d.ts +0 -0
  96. /package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/{_theme.bootstrap1 → _renderer.bootstrap5}/GET.js +0 -0
  97. /package/lib/plugins/service-betterportal-config-manager/bp-routes/menu/{_theme.bootstrap1 → _renderer.bootstrap5}/GET.d.ts +0 -0
  98. /package/lib/plugins/service-betterportal-config-manager/bp-routes/menu/{_theme.bootstrap1 → _renderer.bootstrap5}/GET.js +0 -0
  99. /package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/{_theme.bootstrap1 → _renderer.bootstrap5}/GET.d.ts +0 -0
  100. /package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/{_theme.bootstrap1 → _renderer.bootstrap5}/GET.js +0 -0
  101. /package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/{_theme.bootstrap1 → _renderer.bootstrap5}/GET.d.ts +0 -0
  102. /package/lib/plugins/service-betterportal-config-manager/bp-routes/services/{_theme.bootstrap1 → _renderer.bootstrap5}/GET.d.ts +0 -0
  103. /package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/{_theme.bootstrap1 → _renderer.bootstrap5}/GET.d.ts +0 -0
  104. /package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/{_theme.bootstrap1 → _renderer.bootstrap5}/GET.js +0 -0
  105. /package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/{_theme.bootstrap1 → _renderer.bootstrap5}/DELETE.d.ts +0 -0
  106. /package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/{_theme.bootstrap1 → _renderer.bootstrap5}/DELETE.js +0 -0
  107. /package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/{_theme.bootstrap1 → _renderer.bootstrap5}/GET.d.ts +0 -0
  108. /package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/{_theme.bootstrap1 → _renderer.bootstrap5}/POST.d.ts +0 -0
  109. /package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/{_theme.bootstrap1 → _renderer.bootstrap5}/POST.js +0 -0
  110. /package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/{_theme.bootstrap1 → _renderer.bootstrap5}/PUT.d.ts +0 -0
  111. /package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/{_theme.bootstrap1 → _renderer.bootstrap5}/PUT.js +0 -0
@@ -1,367 +1,233 @@
1
1
  import { htmlResponse, jsonResponse } from "@betterportal/framework";
2
- const API_BASE = "/.well-known/bp/admin";
3
- // Parse-only base for relative request URLs. Never emit this origin.
2
+ import { getCachedManifestForService } from "./syncApi.js";
3
+ const API_BASE = "/.well-known/bp/admin/fragments-editor";
4
4
  const RELATIVE_URL_PARSE_BASE = "http://betterportal.invalid";
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;
5
+ async function readForm(event) {
6
+ const data = await event.req.formData().catch(() => null);
7
+ const result = {};
8
+ data?.forEach((value, key) => { if (typeof value === "string")
9
+ result[key] = value; });
10
+ return result;
13
11
  }
14
- function escapeHtml(s) {
15
- return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
12
+ function escapeHtml(value) {
13
+ return value.replace(/[&<>"']/g, (character) => ({
14
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
15
+ })[character]);
16
16
  }
17
- function getApp(config, appId) {
18
- return config.apps.find((a) => a.id === appId) ?? null;
17
+ function appFor(config, appId) {
18
+ return config.apps.find((app) => app.id === appId) ?? null;
19
19
  }
20
- function getServicesForApp(config, appDef) {
21
- const tenant = (config.tenants ?? []).find((t) => t.id === appDef.tenantId);
22
- if (!tenant)
20
+ function servicesFor(config, app) {
21
+ const tenant = config.tenants.find((candidate) => candidate.id === app.tenantId);
22
+ const shared = (config.sharedServiceActivations ?? []).flatMap((activation) => {
23
+ if (!activation.enabled || activation.tenantId !== app.tenantId || (activation.appId && activation.appId !== app.id))
24
+ return [];
25
+ const service = config.sharedServiceCatalog.find((candidate) => candidate.enabled && candidate.id === activation.sharedServiceId);
26
+ return service ? [{ ...service, id: activation.id, hostname: service.baseUrl }] : [];
27
+ });
28
+ return [
29
+ ...(tenant?.services ?? []),
30
+ ...(config.platformServices ?? []).filter((service) => tenant?.activatedPlatformServices?.includes(service.id)),
31
+ ...shared
32
+ ].filter((service) => service.enabled);
33
+ }
34
+ function serviceFor(config, app, serviceId) {
35
+ return servicesFor(config, app).find((service) => service.id === serviceId) ?? null;
36
+ }
37
+ function serviceLabel(config, app, serviceId) {
38
+ const service = serviceFor(config, app, serviceId);
39
+ return service ? `${service.title || service.serviceId || service.id} · ${service.serviceId || service.id}` : serviceId;
40
+ }
41
+ function settingsFor(app, shellServiceId) {
42
+ app.shellFragments = app.shellFragments ?? {};
43
+ app.shellFragments[shellServiceId] = app.shellFragments[shellServiceId] ?? {};
44
+ return app.shellFragments[shellServiceId];
45
+ }
46
+ function effectiveBlockItems(app, shellServiceId, definition) {
47
+ const setting = app.shellFragments?.[shellServiceId]?.[definition.id];
48
+ if (setting?.mode === "none")
23
49
  return [];
24
- const tenantSvcs = (tenant.services ?? []).filter((s) => s.enabled).map((s) => ({
25
- id: s.id, title: s.title || s.serviceId || s.id, hostname: s.hostname
26
- }));
27
- const platformSvcs = (tenant.activatedPlatformServices ?? [])
28
- .map((psId) => (config.platformServices ?? []).find((p) => p.id === psId && p.enabled))
29
- .filter(Boolean)
30
- .map((p) => ({ id: p.id, title: `${p.title || p.id} (platform)`, hostname: p.hostname }));
31
- const sharedSvcs = (config.sharedServiceActivations ?? [])
32
- .filter((activation) => activation.enabled
33
- && activation.tenantId === appDef.tenantId
34
- && (!activation.appId || activation.appId === appDef.id))
35
- .map((activation) => {
36
- const shared = (config.sharedServiceCatalog ?? []).find((s) => s.id === activation.sharedServiceId && s.enabled);
37
- return shared ? {
38
- id: activation.id,
39
- title: `${shared.title || shared.id} (shared)`,
40
- hostname: shared.baseUrl
41
- } : null;
42
- })
43
- .filter(Boolean);
44
- return [...tenantSvcs, ...platformSvcs, ...sharedSvcs];
50
+ if (setting?.mode === "items")
51
+ return [...setting.items];
52
+ if (setting?.mode === "override")
53
+ return [setting.item];
54
+ const legacy = app.fragments?.[definition.id] ?? [];
55
+ if (legacy.length)
56
+ return legacy.filter((item) => item.enabled).map((item) => ({
57
+ source: "service",
58
+ ...item,
59
+ fragmentId: item.fragmentId.includes(".") ? item.fragmentId : `${definition.id}.${item.fragmentId}`
60
+ }));
61
+ const legacySlots = (app.slots ?? []).flatMap((slot) => {
62
+ if (!slot.enabled || !slot.slotId.startsWith(`${definition.id}.`))
63
+ return [];
64
+ const route = app.routes.find((candidate) => candidate.enabled !== false
65
+ && candidate.serviceId === slot.serviceId
66
+ && candidate.viewId === slot.viewId);
67
+ const targetPath = route?.resolvedServicePath ?? route?.targetPath;
68
+ return targetPath ? [{ source: "service", serviceId: slot.serviceId, fragmentId: slot.slotId, targetPath }] : [];
69
+ });
70
+ if (legacySlots.length)
71
+ return legacySlots;
72
+ return definition.defaultItems.map((fragmentId) => ({ source: "shell", fragmentId }));
45
73
  }
46
- function getServiceTitle(config, serviceId) {
47
- for (const t of config.tenants ?? []) {
48
- const s = (t.services ?? []).find((x) => x.id === serviceId);
49
- if (s)
50
- return s.title || s.serviceId || s.id;
51
- }
52
- const activation = (config.sharedServiceActivations ?? []).find((x) => x.id === serviceId);
53
- if (activation) {
54
- const shared = (config.sharedServiceCatalog ?? []).find((x) => x.id === activation.sharedServiceId);
55
- if (shared)
56
- return `${shared.title || shared.id} (shared)`;
57
- }
58
- const ps = (config.platformServices ?? []).find((x) => x.id === serviceId);
59
- return ps ? (ps.title || ps.id) : serviceId;
74
+ function encodeItem(item) {
75
+ return item.source === "shell"
76
+ ? `h:${encodeURIComponent(item.fragmentId)}`
77
+ : `s:${item.serviceId}:${encodeURIComponent(item.fragmentId)}:${encodeURIComponent(item.targetPath)}`;
60
78
  }
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;
79
+ function decodeItem(value) {
80
+ try {
81
+ const parts = value.split(":");
82
+ if (parts[0] === "h" && parts[1])
83
+ return { source: "shell", fragmentId: decodeURIComponent(parts[1]) };
84
+ if (parts[0] === "s" && parts.length === 4)
85
+ return {
86
+ source: "service",
87
+ serviceId: parts[1],
88
+ fragmentId: decodeURIComponent(parts[2]),
89
+ targetPath: decodeURIComponent(parts[3])
90
+ };
66
91
  }
67
- const activation = (config.sharedServiceActivations ?? []).find((x) => x.id === serviceId);
68
- if (activation) {
69
- const shared = (config.sharedServiceCatalog ?? []).find((x) => x.id === activation.sharedServiceId);
70
- if (shared)
71
- return shared.baseUrl ?? null;
92
+ catch {
93
+ return null;
72
94
  }
73
- const ps = (config.platformServices ?? []).find((x) => x.id === serviceId);
74
- return ps?.hostname ?? null;
95
+ return null;
75
96
  }
76
- function getFragments(appDef, location) {
77
- const all = (appDef.fragments ?? {});
78
- return all[location] ?? [];
97
+ function availableItems(config, app, definitions) {
98
+ const builtIns = definitions
99
+ .filter((definition) => definition.kind === "fragment")
100
+ .map((definition) => ({ item: { source: "shell", fragmentId: definition.id }, label: `Shell · ${definition.title}` }));
101
+ const services = servicesFor(config, app);
102
+ const serviceItems = services.flatMap((service) => {
103
+ const manifest = getCachedManifestForService(config, service.id);
104
+ return Object.values(manifest?.viewIndex ?? {}).flatMap((view) => view.fragments
105
+ .filter((fragment) => app.routes.some((route) => route.enabled !== false
106
+ && route.serviceId === service.id
107
+ && (route.resolvedServicePath ?? route.targetPath) === fragment.targetPath))
108
+ .map((fragment) => ({
109
+ item: { source: "service", serviceId: service.id, fragmentId: fragment.fragmentId, targetPath: fragment.targetPath },
110
+ label: `${serviceLabel(config, app, service.id)} · ${fragment.fragmentId} (${fragment.targetPath})`
111
+ })));
112
+ });
113
+ return [...builtIns, ...serviceItems];
79
114
  }
80
- function setFragments(appDef, location, frags) {
81
- appDef.fragments = appDef.fragments ?? {};
82
- appDef.fragments[location] = frags;
115
+ function hidden(appId, shellServiceId, fragmentId) {
116
+ return `<input type="hidden" name="appId" value="${escapeHtml(appId)}"><input type="hidden" name="shellServiceId" value="${escapeHtml(shellServiceId)}"><input type="hidden" name="fragmentId" value="${escapeHtml(fragmentId)}">`;
83
117
  }
84
- const LOCATIONS = [
85
- { id: "nav", label: "Topbar (right side)" },
86
- { id: "footer", label: "Footer" }
87
- ];
88
- function renderFragmentRow(frag, idx, location, appId, config) {
89
- const serviceTitle = getServiceTitle(config, frag.serviceId);
90
- const btn = (action, label, btnClass, title) => `<form hx-post="${API_BASE}/fragments-editor/${action}" hx-target="#bp-fragments-editor" hx-swap="outerHTML" class="d-inline m-0">
91
- <input type="hidden" name="appId" value="${escapeHtml(appId)}" />
92
- <input type="hidden" name="location" value="${escapeHtml(location)}" />
93
- <input type="hidden" name="index" value="${idx}" />
94
- <button type="submit" class="btn btn-sm ${btnClass}" title="${escapeHtml(title)}">${label}</button>
95
- </form>`;
96
- return `<li class="list-group-item d-flex justify-content-between align-items-center">
97
- <div class="d-flex flex-column gap-1 flex-grow-1 min-width-0">
98
- <div class="d-flex align-items-center gap-2">
99
- <span class="badge text-bg-secondary">${escapeHtml(serviceTitle)}</span>
100
- <span class="font-monospace">${escapeHtml(frag.fragmentId)}</span>
101
- <span class="text-secondary">-></span>
102
- <span class="font-monospace text-secondary small">${escapeHtml(frag.targetPath)}</span>
103
- </div>
104
- </div>
105
- <div class="btn-group btn-group-sm" role="group">
106
- ${btn("move-up", "^", "btn-outline-secondary", "Move up")}
107
- ${btn("move-down", "v", "btn-outline-secondary", "Move down")}
108
- ${btn("toggle", frag.enabled ? "on" : "off", frag.enabled ? "btn-success" : "btn-outline-secondary", frag.enabled ? "Disable" : "Enable")}
109
- ${btn("remove", "x", "btn-outline-danger", "Remove")}
110
- </div>
111
- </li>`;
118
+ function modeButtons(appId, shellServiceId, definition) {
119
+ const button = (mode, label, style) => `<form class="d-inline" hx-post="${API_BASE}/set-mode" hx-target="#bp-fragments-editor" hx-swap="outerHTML">${hidden(appId, shellServiceId, definition.id)}<input type="hidden" name="mode" value="${mode}"><button class="btn btn-sm ${style}" type="submit">${label}</button></form>`;
120
+ return `<div class="btn-group">${button("default", "Use shell default", "btn-outline-secondary")}${button("none", "Empty", "btn-outline-danger")}</div>`;
112
121
  }
113
- function renderLocationSection(location, label, frags, appId, config, appDef) {
114
- const services = getServicesForApp(config, appDef);
115
- const serviceOpts = ["<option value=\"\">Select service...</option>",
116
- ...services.map((s) => {
117
- const schemaUrl = `${s.hostname.replace(/\/+$/, "")}/.well-known/bp/schema.json`;
118
- return `<option value="${escapeHtml(s.id)}" data-schema-url="${escapeHtml(schemaUrl)}">${escapeHtml(s.title)}</option>`;
119
- })
120
- ].join("");
121
- const rows = frags.length === 0
122
- ? `<li class="list-group-item text-secondary">No fragments in this location.</li>`
123
- : frags.map((f, i) => renderFragmentRow(f, i, location, appId, config)).join("");
124
- return `<div class="card mb-3">
125
- <div class="card-header"><strong>${escapeHtml(label)}</strong> <span class="small text-secondary">(${frags.length})</span></div>
126
- <ul class="list-group list-group-flush">${rows}</ul>
127
- <div class="card-body">
128
- <form hx-post="${API_BASE}/fragments-editor/add" hx-target="#bp-fragments-editor" hx-swap="outerHTML"
129
- class="row g-2 align-items-end">
130
- <input type="hidden" name="appId" value="${escapeHtml(appId)}" />
131
- <input type="hidden" name="location" value="${escapeHtml(location)}" />
132
- <div class="col-md-3">
133
- <label class="form-label small mb-0">Service</label>
134
- <select name="serviceId" class="form-select form-select-sm" required
135
- hx-get="${API_BASE}/fragments-editor/fragments"
136
- hx-target="#bp-frag-select-${location}"
137
- hx-swap="outerHTML"
138
- hx-trigger="change"
139
- hx-include="closest form">${serviceOpts}</select>
140
- </div>
141
- <div class="col-md-3">
142
- <label class="form-label small mb-0">Fragment</label>
143
- <select id="bp-frag-select-${location}" name="fragmentId" class="form-select form-select-sm" required
144
- data-bp-frag-select
145
- data-location="${escapeHtml(location)}">
146
- <option value="">Pick service first...</option>
147
- </select>
148
- <div id="bp-frag-status-${location}" class="form-text small text-secondary"></div>
149
- </div>
150
- <div class="col-md-4">
151
- <label class="form-label small mb-0">Target Path</label>
152
- <input id="bp-frag-target-${location}" type="text" name="targetPath" class="form-control form-control-sm font-monospace" placeholder="auto-filled" required />
153
- </div>
154
- <div class="col-md-2">
155
- <button type="submit" class="btn btn-primary btn-sm w-100">Add</button>
156
- </div>
157
- </form>
158
- </div>
159
- </div>`;
122
+ function itemLabel(item, config, app, definitions) {
123
+ if (item.source === "shell")
124
+ return `Shell · ${definitions.find((definition) => definition.id === item.fragmentId)?.title ?? item.fragmentId}`;
125
+ return `${serviceLabel(config, app, item.serviceId)} · ${item.fragmentId} (${item.targetPath})`;
160
126
  }
161
- function renderFragmentEditorScript() {
162
- return `<script>
163
- (() => {
164
- const root = document.getElementById("bp-fragments-editor");
165
- if (!root) return;
166
-
167
- const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (ch) => ({
168
- "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
169
- })[ch]);
170
-
171
- const loadFragments = async (fragmentSelect) => {
172
- const location = fragmentSelect.dataset.location || "";
173
- const schemaUrl = fragmentSelect.dataset.schemaUrl || "";
174
- const targetInput = root.querySelector("#bp-frag-target-" + CSS.escape(location));
175
- const status = root.querySelector("#bp-frag-status-" + CSS.escape(location));
176
- if (targetInput) targetInput.value = "";
177
- try {
178
- if (status) status.textContent = "Loading service schema from " + schemaUrl;
179
- const response = await fetch(schemaUrl, {
180
- method: "GET",
181
- headers: { Accept: "application/json" },
182
- cache: "no-store"
183
- });
184
- if (!response.ok) throw new Error(String(response.status));
185
- const schema = (response.headers.get("content-type") || "").includes("application/json") ? await response.json() : null;
186
- if (!schema) throw new Error("service schema returned non-JSON");
187
- const fragments = [];
188
- for (const route of schema.routes || []) {
189
- for (const fragment of route.fragments || []) {
190
- if (location && fragment.fragmentLocation !== location) continue;
191
- fragments.push({
192
- fragmentId: fragment.fragmentId,
193
- viewPath: route.path || ""
194
- });
195
- }
196
- }
197
-
198
- if (fragments.length === 0) {
199
- fragmentSelect.innerHTML = '<option value="">No fragments at "' + escapeHtml(location) + '"</option>';
200
- return;
201
- }
202
-
203
- fragmentSelect.innerHTML = '<option value="">Select fragment...</option>' + fragments
204
- .map((fragment) => '<option value="' + escapeHtml(fragment.fragmentId) + '" data-view-path="' + escapeHtml(fragment.viewPath) + '">' + escapeHtml(fragment.fragmentId) + ' (' + escapeHtml(fragment.viewPath) + ')</option>')
205
- .join("");
206
- if (status) status.textContent = "Loaded " + fragments.length + " fragment" + (fragments.length === 1 ? "" : "s") + " from service.";
207
- } catch {
208
- fragmentSelect.innerHTML = '<option value="">Unable to load fragments from browser</option>';
209
- if (status) status.textContent = "Failed to load service schema from " + schemaUrl;
210
- }
211
- };
212
-
213
- if (root.dataset.bpFragmentsBound !== "1") {
214
- root.dataset.bpFragmentsBound = "1";
215
- root.addEventListener("change", (event) => {
216
- const target = event.target;
217
- if (!(target instanceof HTMLSelectElement)) return;
218
-
219
- if (target.matches("[data-bp-frag-select]")) {
220
- const location = target.dataset.location || "";
221
- const targetInput = root.querySelector("#bp-frag-target-" + CSS.escape(location));
222
- const selected = target.selectedOptions && target.selectedOptions[0];
223
- if (targetInput) targetInput.value = selected ? (selected.dataset.viewPath || "") : "";
224
- }
225
- });
226
- }
227
-
228
- root.querySelectorAll("[data-bp-frag-select][data-schema-url]").forEach((select) => {
229
- if (select.dataset.loaded === "1") return;
230
- select.dataset.loaded = "1";
231
- loadFragments(select);
232
- });
233
- })();
234
- </script>`;
127
+ function sourceSelect(items) {
128
+ return `<select name="source" class="form-select form-select-sm" required><option value="">Select fragment...</option>${items.map(({ item, label }) => `<option value="${escapeHtml(encodeItem(item))}">${escapeHtml(label)}</option>`).join("")}</select>`;
235
129
  }
236
- function renderFragmentSelect(location, schemaUrl) {
237
- return `<select id="bp-frag-select-${escapeHtml(location)}" name="fragmentId" class="form-select form-select-sm" required
238
- data-bp-frag-select
239
- data-location="${escapeHtml(location)}"
240
- data-schema-url="${escapeHtml(schemaUrl)}">
241
- <option value="">Loading fragments...</option>
242
- </select>
243
- ${renderFragmentEditorScript()}`;
130
+ function renderDefinition(config, app, shellServiceId, definition, definitions, choices) {
131
+ const setting = app.shellFragments?.[shellServiceId]?.[definition.id];
132
+ const status = setting?.mode ?? (app.fragments?.[definition.id]?.length ? "legacy default" : "shell default");
133
+ if (definition.kind === "fragment") {
134
+ return `<section class="card mb-3"><div class="card-header d-flex justify-content-between align-items-center"><div><strong>${escapeHtml(definition.title)}</strong><div class="small text-secondary font-monospace">_${escapeHtml(definition.id)}.tsx · ${escapeHtml(status)}</div></div>${modeButtons(app.id, shellServiceId, definition)}</div><div class="card-body"><p class="small text-secondary">${escapeHtml(definition.description)}</p><form class="row g-2" hx-post="${API_BASE}/set-override" hx-target="#bp-fragments-editor" hx-swap="outerHTML">${hidden(app.id, shellServiceId, definition.id)}<div class="col">${sourceSelect(choices.filter(({ item }) => item.source === "service"))}</div><div class="col-auto"><button class="btn btn-sm btn-primary" type="submit">Override</button></div></form></div></section>`;
135
+ }
136
+ const items = effectiveBlockItems(app, shellServiceId, definition);
137
+ const rows = items.length ? items.map((item, index) => `<li class="list-group-item d-flex justify-content-between align-items-center"><span>${escapeHtml(itemLabel(item, config, app, definitions))}</span><div class="btn-group">${["move-up", "move-down", "remove"].map((action) => `<form hx-post="${API_BASE}/${action}" hx-target="#bp-fragments-editor" hx-swap="outerHTML">${hidden(app.id, shellServiceId, definition.id)}<input type="hidden" name="index" value="${index}"><button class="btn btn-sm ${action === "remove" ? "btn-outline-danger" : "btn-outline-secondary"}" type="submit"${(action === "move-up" && index === 0) || (action === "move-down" && index === items.length - 1) ? " disabled" : ""}>${action === "move-up" ? "Move up" : action === "move-down" ? "Move down" : "Remove"}</button></form>`).join("")}</div></li>`).join("") : `<li class="list-group-item text-secondary">Empty block.</li>`;
138
+ return `<section class="card mb-3"><div class="card-header d-flex justify-content-between align-items-center"><div><strong>${escapeHtml(definition.title)}</strong><div class="small text-secondary font-monospace">_${escapeHtml(definition.id)}/index.tsx · ${escapeHtml(status)}</div></div>${modeButtons(app.id, shellServiceId, definition)}</div><ul class="list-group list-group-flush">${rows}</ul><div class="card-body"><p class="small text-secondary">${escapeHtml(definition.description)}</p><form class="row g-2" hx-post="${API_BASE}/add" hx-target="#bp-fragments-editor" hx-swap="outerHTML">${hidden(app.id, shellServiceId, definition.id)}<div class="col">${sourceSelect(choices)}</div><div class="col-auto"><button class="btn btn-sm btn-primary" type="submit">Add</button></div></form></div></section>`;
244
139
  }
245
- function renderEditor(config, appDef, appId) {
246
- const sections = LOCATIONS.map((loc) => renderLocationSection(loc.id, loc.label, getFragments(appDef, loc.id), appId, config, appDef)).join("");
247
- return `<div id="bp-fragments-editor">${sections}${renderFragmentEditorScript()}</div>`;
140
+ function renderEditor(config, app) {
141
+ const shellServiceId = app.shell?.serviceId;
142
+ if (!shellServiceId)
143
+ return `<div id="bp-fragments-editor" class="alert alert-secondary">This app has no shell, so it has no shell fragments.</div>`;
144
+ const manifest = getCachedManifestForService(config, shellServiceId);
145
+ const definitions = manifest?.shell?.fragments ?? [];
146
+ if (!definitions.length)
147
+ return `<div id="bp-fragments-editor" class="alert alert-secondary">The active shell exposes no fragments or has not synced its manifest yet.</div>`;
148
+ const choices = availableItems(config, app, definitions);
149
+ return `<div id="bp-fragments-editor"><div class="mb-3"><strong>${escapeHtml(serviceLabel(config, app, shellServiceId))}</strong><div class="small text-secondary">Shell defaults are used until an explicit override, list, or empty value is saved.</div></div>${definitions.map((definition) => renderDefinition(config, app, shellServiceId, definition, definitions, choices)).join("")}</div>`;
248
150
  }
249
- export function registerFragmentsEditorRoutes(app, store) {
250
- const respondEditor = async (appId) => {
151
+ export function registerFragmentsEditorRoutes(router, store) {
152
+ const respond = async (appId) => {
251
153
  const config = await store.loadConfig();
252
- const appDef = getApp(config, appId);
253
- if (!appDef)
254
- return htmlResponse(`<div class="alert alert-danger">App not found</div>`, 200, "text/html; mode=fragment");
255
- return htmlResponse(renderEditor(config, appDef, appId), 200, "text/html; mode=fragment", {
256
- "HX-Trigger": "bp:fragments-changed"
257
- });
154
+ const app = appFor(config, appId);
155
+ return app
156
+ ? htmlResponse(renderEditor(config, app), 200, "text/html; mode=fragment", { "HX-Trigger": "bp:fragments-changed" })
157
+ : htmlResponse(`<div id="bp-fragments-editor" class="alert alert-danger">App not found.</div>`, 200, "text/html; mode=fragment");
258
158
  };
259
- app.get(`${API_BASE}/fragments-editor`, async (event) => {
260
- const url = new URL(event.req.url ?? "", RELATIVE_URL_PARSE_BASE);
261
- const appId = url.searchParams.get("appId") ?? "";
262
- if (!appId)
263
- return htmlResponse(`<div class="alert alert-secondary">Select an app</div>`, 200, "text/html; mode=fragment");
264
- return respondEditor(appId);
265
- });
266
- app.post(`${API_BASE}/fragments-editor/add`, async (event) => {
267
- const f = await readFormBody(event);
268
- const config = await store.loadConfig();
269
- const appDef = getApp(config, f.appId);
270
- if (!appDef)
271
- return jsonResponse({ error: "App not found" }, 404);
272
- if (!f.serviceId || !f.fragmentId || !f.targetPath)
273
- return respondEditor(f.appId);
274
- const frags = getFragments(appDef, f.location);
275
- frags.push({
276
- serviceId: f.serviceId,
277
- fragmentId: f.fragmentId,
278
- targetPath: f.targetPath,
279
- enabled: true
280
- });
281
- setFragments(appDef, f.location, frags);
282
- await store.saveConfig(config);
283
- return respondEditor(f.appId);
284
- });
285
- app.post(`${API_BASE}/fragments-editor/remove`, async (event) => {
286
- const f = await readFormBody(event);
159
+ const mutate = (handler) => async (event) => {
160
+ const form = await readForm(event);
287
161
  const config = await store.loadConfig();
288
- const appDef = getApp(config, f.appId);
289
- if (!appDef)
290
- return jsonResponse({ error: "App not found" }, 404);
291
- const idx = parseInt(f.index, 10);
292
- const frags = getFragments(appDef, f.location);
293
- if (idx >= 0 && idx < frags.length)
294
- frags.splice(idx, 1);
295
- setFragments(appDef, f.location, frags);
162
+ const app = appFor(config, form.appId);
163
+ if (!app || app.shell?.serviceId !== form.shellServiceId)
164
+ return jsonResponse({ error: "Active shell changed; reload the editor" }, 409);
165
+ const definitions = getCachedManifestForService(config, form.shellServiceId)?.shell?.fragments ?? [];
166
+ const definition = definitions.find((candidate) => candidate.id === form.fragmentId);
167
+ if (!definition)
168
+ return jsonResponse({ error: "Shell fragment is unavailable; reload the editor" }, 409);
169
+ const choices = availableItems(config, app, definitions);
170
+ if (!handler(form, app, settingsFor(app, form.shellServiceId), definition, choices))
171
+ return jsonResponse({ error: "Invalid fragment editor operation" }, 400);
296
172
  await store.saveConfig(config);
297
- return respondEditor(f.appId);
298
- });
299
- app.post(`${API_BASE}/fragments-editor/toggle`, async (event) => {
300
- const f = await readFormBody(event);
301
- const config = await store.loadConfig();
302
- const appDef = getApp(config, f.appId);
303
- if (!appDef)
304
- return jsonResponse({ error: "App not found" }, 404);
305
- const idx = parseInt(f.index, 10);
306
- const frags = getFragments(appDef, f.location);
307
- if (idx >= 0 && idx < frags.length)
308
- frags[idx].enabled = !frags[idx].enabled;
309
- setFragments(appDef, f.location, frags);
310
- await store.saveConfig(config);
311
- return respondEditor(f.appId);
312
- });
313
- app.post(`${API_BASE}/fragments-editor/move-up`, async (event) => {
314
- const f = await readFormBody(event);
315
- const config = await store.loadConfig();
316
- const appDef = getApp(config, f.appId);
317
- if (!appDef)
318
- return jsonResponse({ error: "App not found" }, 404);
319
- const idx = parseInt(f.index, 10);
320
- const frags = getFragments(appDef, f.location);
321
- if (idx > 0 && idx < frags.length) {
322
- const [it] = frags.splice(idx, 1);
323
- frags.splice(idx - 1, 0, it);
324
- }
325
- setFragments(appDef, f.location, frags);
326
- await store.saveConfig(config);
327
- return respondEditor(f.appId);
328
- });
329
- app.get(`${API_BASE}/fragments-editor/fragments`, async (event) => {
330
- const url = new URL(event.req.url ?? "", RELATIVE_URL_PARSE_BASE);
331
- const serviceId = url.searchParams.get("serviceId") ?? "";
332
- const location = url.searchParams.get("location") ?? "";
333
- if (!serviceId)
334
- return htmlResponse(`<option value="">Pick service first...</option>`, 200, "text/html; mode=fragment");
335
- const config = await store.loadConfig();
336
- const hostname = getServiceHostname(config, serviceId);
337
- if (!hostname) {
338
- return htmlResponse(`<select id="bp-frag-select-${escapeHtml(location)}" name="fragmentId" class="form-select form-select-sm" required><option value="">No service</option></select>`, 200, "text/html; mode=fragment");
339
- }
340
- return htmlResponse(renderFragmentSelect(location, `${hostname.replace(/\/+$/, "")}/.well-known/bp/schema.json`), 200, "text/html; mode=fragment");
341
- });
342
- app.get(`${API_BASE}/fragments-editor/fragment-target`, async (event) => {
343
- const url = new URL(event.req.url ?? "", RELATIVE_URL_PARSE_BASE);
344
- const serviceId = url.searchParams.get("serviceId") ?? "";
345
- const fragmentId = url.searchParams.get("fragmentId") ?? "";
346
- const location = url.searchParams.get("location") ?? "";
347
- const targetPath = url.searchParams.get("targetPath") ?? "";
348
- return htmlResponse(`<input id="bp-frag-target-${escapeHtml(location)}" type="text" name="targetPath" class="form-control form-control-sm font-monospace" value="${escapeHtml(targetPath)}" placeholder="auto-filled" required />`, 200, "text/html; mode=fragment");
173
+ return respond(form.appId);
174
+ };
175
+ router.get(API_BASE, async (event) => {
176
+ const appId = new URL(event.req.url ?? "", RELATIVE_URL_PARSE_BASE).searchParams.get("appId") ?? "";
177
+ return appId ? respond(appId) : htmlResponse(`<div id="bp-fragments-editor" class="alert alert-secondary">Select an app.</div>`, 200, "text/html; mode=fragment");
349
178
  });
350
- app.post(`${API_BASE}/fragments-editor/move-down`, async (event) => {
351
- const f = await readFormBody(event);
352
- const config = await store.loadConfig();
353
- const appDef = getApp(config, f.appId);
354
- if (!appDef)
355
- return jsonResponse({ error: "App not found" }, 404);
356
- const idx = parseInt(f.index, 10);
357
- const frags = getFragments(appDef, f.location);
358
- if (idx >= 0 && idx < frags.length - 1) {
359
- const [it] = frags.splice(idx, 1);
360
- frags.splice(idx + 1, 0, it);
361
- }
362
- setFragments(appDef, f.location, frags);
363
- await store.saveConfig(config);
364
- return respondEditor(f.appId);
179
+ router.post(`${API_BASE}/set-mode`, mutate((form, _app, settings) => {
180
+ if (form.mode === "default")
181
+ delete settings[form.fragmentId];
182
+ else if (form.mode === "none")
183
+ settings[form.fragmentId] = { mode: "none" };
184
+ else
185
+ return false;
186
+ return true;
187
+ }));
188
+ router.post(`${API_BASE}/set-override`, mutate((form, _app, settings, definition, choices) => {
189
+ const item = decodeItem(form.source);
190
+ if (definition.kind !== "fragment" || item?.source !== "service" || !choices.some((choice) => encodeItem(choice.item) === form.source))
191
+ return false;
192
+ settings[form.fragmentId] = { mode: "override", item };
193
+ return true;
194
+ }));
195
+ router.post(`${API_BASE}/add`, mutate((form, app, settings, definition, choices) => {
196
+ const item = decodeItem(form.source);
197
+ if (definition.kind !== "block" || !item || !choices.some((choice) => encodeItem(choice.item) === form.source))
198
+ return false;
199
+ const current = settings[form.fragmentId]?.mode === "items" ? [...settings[form.fragmentId].items] : effectiveBlockItems(app, form.shellServiceId, definition);
200
+ settings[form.fragmentId] = { mode: "items", items: [...current, item] };
201
+ return true;
202
+ }));
203
+ const editItems = (operation) => mutate((form, app, settings, definition) => {
204
+ if (definition.kind !== "block")
205
+ return false;
206
+ const current = settings[form.fragmentId]?.mode === "items"
207
+ ? [...settings[form.fragmentId].items]
208
+ : effectiveBlockItems(app, form.shellServiceId, definition);
209
+ if (!operation(current, Number(form.index)))
210
+ return false;
211
+ settings[form.fragmentId] = { mode: "items", items: current };
212
+ return true;
365
213
  });
214
+ router.post(`${API_BASE}/remove`, editItems((items, index) => {
215
+ if (index < 0 || index >= items.length)
216
+ return false;
217
+ items.splice(index, 1);
218
+ return true;
219
+ }));
220
+ router.post(`${API_BASE}/move-up`, editItems((items, index) => {
221
+ if (index <= 0 || index >= items.length)
222
+ return false;
223
+ items.splice(index - 1, 0, ...items.splice(index, 1));
224
+ return true;
225
+ }));
226
+ router.post(`${API_BASE}/move-down`, editItems((items, index) => {
227
+ if (index < 0 || index >= items.length - 1)
228
+ return false;
229
+ items.splice(index + 1, 0, ...items.splice(index, 1));
230
+ return true;
231
+ }));
366
232
  }
367
233
  //# sourceMappingURL=fragmentsEditor.js.map