4bnode 4.0.9 → 4.1.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.
Files changed (45) hide show
  1. package/README.md +25 -206
  2. package/add-crud.js +6 -106
  3. package/add-docs.js +91 -0
  4. package/add-email.js +63 -0
  5. package/add-security.js +143 -0
  6. package/index.js +76 -8
  7. package/lib/codegen.js +1223 -0
  8. package/lib/codegen.test.js +480 -0
  9. package/lib/indexFile.js +10 -12
  10. package/lib/names.js +4 -21
  11. package/lib/routeFile.js +9 -27
  12. package/lib/sync-skeleton.js +47 -0
  13. package/lib/ui.js +15 -2
  14. package/package.json +4 -2
  15. package/scripts/audit-dev-surface.mjs +153 -0
  16. package/scripts/reveal-passkey.mjs +35 -0
  17. package/skeleton/.4bnode/assets/js/App.js +31 -9
  18. package/skeleton/.4bnode/assets/js/Root.js +1 -5
  19. package/skeleton/.4bnode/assets/js/components/Sidebar.js +9 -5
  20. package/skeleton/.4bnode/assets/js/lib.js +18 -5
  21. package/skeleton/.4bnode/assets/js/pages/AiBuilderPage.js +268 -0
  22. package/skeleton/.4bnode/assets/js/pages/ApiKeysPage.js +164 -0
  23. package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +275 -9
  24. package/skeleton/.4bnode/assets/js/pages/DatabasePage.js +44 -205
  25. package/skeleton/.4bnode/assets/js/pages/DocsPage.js +142 -0
  26. package/skeleton/.4bnode/assets/js/pages/MailPage.js +134 -0
  27. package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +5 -266
  28. package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +23 -213
  29. package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +74 -0
  30. package/skeleton/.4bnode/assets/js/screens/PasskeyScreen.js +61 -61
  31. package/skeleton/.4bnode/codegen.js +1223 -0
  32. package/skeleton/.4bnode/dev-api.js +634 -728
  33. package/skeleton/.4bnode/ui.html +62 -40
  34. package/skeleton/_gitignore +7 -0
  35. package/skeleton/index.js +41 -3
  36. package/skeleton/package.json +4 -1
  37. package/skeleton/src/middleware/apiKey.js +36 -0
  38. package/skeleton/src/middleware/errorHandler.js +15 -0
  39. package/skeleton/src/middleware/roles.js +14 -0
  40. package/skeleton/src/middleware/validate.js +20 -0
  41. package/skeleton/.4bnode/assets/images/mysql.png +0 -0
  42. package/skeleton/.4bnode/assets/images/postgresql.png +0 -0
  43. package/skeleton/.4bnode/assets/images/sqlite.png +0 -0
  44. package/skeleton/.4bnode/assets/js/pages/ReportsPage.js +0 -667
  45. package/skeleton/.4bnode/assets/js/screens/SplashScreen.js +0 -66
@@ -0,0 +1,268 @@
1
+ import React, { useState, useEffect, useCallback } from "react";
2
+ import { LuSparkles, LuCheck, LuChevronDown, LuChevronRight, LuKey } from "react-icons/lu";
3
+ import { html, api } from "../lib.js";
4
+ import { useApp } from "../context.js";
5
+
6
+ const EXAMPLES = [
7
+ "A blog with posts (title, body, author) and full CRUD",
8
+ "A todo app: users and tasks linked to a user, with create/read/update/delete",
9
+ "An e-commerce backend: products, categories, and orders",
10
+ ];
11
+
12
+ export function AiBuilderPage() {
13
+ const { status, toast, refresh } = useApp();
14
+
15
+ const [config, setConfig] = useState(null);
16
+ const [loadError, setLoadError] = useState(null);
17
+ const [selectedTab, setSelectedTab] = useState(null); // provider id currently viewed
18
+ const [editingKey, setEditingKey] = useState(false);
19
+ const [keyInput, setKeyInput] = useState("");
20
+ const [savingKey, setSavingKey] = useState(false);
21
+
22
+ const [prompt, setPrompt] = useState("");
23
+ const [loading, setLoading] = useState(false);
24
+ const [preview, setPreview] = useState(null);
25
+ const [plan, setPlan] = useState(null);
26
+ const [applying, setApplying] = useState(false);
27
+
28
+ const loadConfig = useCallback(async () => {
29
+ setLoadError(null);
30
+ try {
31
+ const c = await api("/ai/config");
32
+ setConfig(c);
33
+ setSelectedTab((cur) => cur || c.provider);
34
+ } catch (err) {
35
+ setLoadError(err.message);
36
+ toast(err.message, "error");
37
+ }
38
+ }, [toast]);
39
+
40
+ useEffect(() => { loadConfig(); }, [loadConfig]);
41
+
42
+ const activeProvider = config?.provider;
43
+ const providerById = (id) => (config?.providers || []).find((p) => p.id === id);
44
+ const activeConfigured = !!providerById(activeProvider)?.configured;
45
+ const sel = providerById(selectedTab);
46
+ const showKeyForm = sel && (!sel.configured || editingKey);
47
+
48
+ function selectTab(id) {
49
+ setSelectedTab(id);
50
+ setEditingKey(false);
51
+ setKeyInput("");
52
+ }
53
+
54
+ async function saveKey(providerId) {
55
+ if (!keyInput.trim()) return;
56
+ setSavingKey(true);
57
+ try {
58
+ const r = await api("/ai/key", { method: "POST", body: { provider: providerId, key: keyInput.trim() } });
59
+ toast(r.message, "success");
60
+ setKeyInput("");
61
+ setEditingKey(false);
62
+ await loadConfig();
63
+ refresh().catch(() => {});
64
+ } catch (err) {
65
+ toast(err.message, "error");
66
+ }
67
+ setSavingKey(false);
68
+ }
69
+
70
+ async function switchTo(providerId) {
71
+ try {
72
+ const r = await api("/ai/provider", { method: "POST", body: { provider: providerId } });
73
+ toast(r.message, "success");
74
+ await loadConfig();
75
+ refresh().catch(() => {});
76
+ } catch (err) {
77
+ toast(err.message, "error");
78
+ }
79
+ }
80
+
81
+ async function generate() {
82
+ if (!prompt.trim()) { toast("Describe what you want to build", "error"); return; }
83
+ setLoading(true);
84
+ setPreview(null);
85
+ setPlan(null);
86
+ try {
87
+ const r = await api("/ai/plan", { method: "POST", body: { prompt: prompt.trim() } });
88
+ setPreview(r.preview);
89
+ setPlan(r.plan);
90
+ } catch (err) {
91
+ toast(err.message, "error");
92
+ }
93
+ setLoading(false);
94
+ }
95
+
96
+ async function apply() {
97
+ if (!plan) return;
98
+ setApplying(true);
99
+ try {
100
+ const r = await api("/ai/apply", { method: "POST", body: { plan } });
101
+ toast(r.message, "success");
102
+ if (r.skipped && r.skipped.length) toast(`Skipped existing: ${r.skipped.join(", ")}`, "info");
103
+ setPreview(null);
104
+ setPlan(null);
105
+ setPrompt("");
106
+ refresh().catch(() => {});
107
+ } catch (err) {
108
+ toast(err.message, "error");
109
+ }
110
+ setApplying(false);
111
+ }
112
+
113
+ if (!config) {
114
+ if (loadError) {
115
+ return html`<div>
116
+ <div className="card-title mb-1">AI Builder</div>
117
+ <div className="card" style=${{ maxWidth: "560px", borderColor: "rgba(239,68,68,0.35)" }}>
118
+ <div style=${{ fontWeight: 600, marginBottom: "6px" }}>Couldn't load AI settings</div>
119
+ <div className="text-sm text-muted mb-4">${loadError}</div>
120
+ <div className="text-sm text-muted mb-4">
121
+ If you just updated 4bnode, restart the dev server — <span style=${{ fontFamily: "monospace" }}>npm run dev</span>
122
+ does not hot-reload the <span style=${{ fontFamily: "monospace" }}>.4bnode/</span> dashboard.
123
+ </div>
124
+ <button className="btn btn-primary" onClick=${loadConfig}>Retry</button>
125
+ </div>
126
+ </div>`;
127
+ }
128
+ return html`<div className="text-muted" style=${{ padding: "16px" }}>Loading…</div>`;
129
+ }
130
+
131
+ function statusChip(p) {
132
+ if (p.configured && p.id === activeProvider)
133
+ return html`<span className="badge badge-success" style=${{ fontSize: "9px", marginLeft: "6px" }}>Active</span>`;
134
+ if (p.configured)
135
+ return html`<span className="badge" style=${{ fontSize: "9px", marginLeft: "6px" }}>Connected</span>`;
136
+ return html`<span className="badge" style=${{ fontSize: "9px", marginLeft: "6px", opacity: 0.6 }}>No key</span>`;
137
+ }
138
+
139
+ return html`<div>
140
+ <div className="flex items-center justify-between mb-4">
141
+ <div>
142
+ <div className="card-title">AI Builder</div>
143
+ <div className="card-subtitle">Describe your backend — review the plan — generate</div>
144
+ </div>
145
+ </div>
146
+
147
+ <div className="card mb-4" style=${{ padding: 0, overflow: "hidden" }}>
148
+ <div className="api-tester-tabs">
149
+ ${config.providers.map((p) => html`<button key=${p.id}
150
+ className=${`api-tester-tab${selectedTab === p.id ? " active" : ""}`}
151
+ onClick=${() => selectTab(p.id)}>
152
+ ${p.label}${statusChip(p)}
153
+ </button>`)}
154
+ </div>
155
+
156
+ ${sel && html`<div style=${{ padding: "16px" }}>
157
+ ${showKeyForm
158
+ ? html`<div>
159
+ <div className="flex items-center gap-3 mb-3">
160
+ <${LuKey} size=${16} color="var(--accent)" />
161
+ <div className="text-sm text-muted">
162
+ ${editingKey ? "Replace" : "Add"} the ${sel.label} API key — stored in your .env
163
+ </div>
164
+ </div>
165
+ <div className="form-group">
166
+ <input className="form-input" type="password" value=${keyInput}
167
+ onInput=${(e) => setKeyInput(e.target.value)} placeholder=${sel.keyPlaceholder}
168
+ style=${{ maxWidth: "420px" }} />
169
+ </div>
170
+ <div style=${{ display: "flex", gap: "8px", alignItems: "center" }}>
171
+ <button className="btn btn-primary" onClick=${() => saveKey(sel.id)} disabled=${savingKey || !keyInput.trim()}>
172
+ ${savingKey ? html`<span className="spinner" />` : html`<${LuCheck} size=${16} />`}
173
+ ${savingKey ? "Saving..." : "Save key & use"}
174
+ </button>
175
+ ${editingKey && html`<button className="btn" onClick=${() => { setEditingKey(false); setKeyInput(""); }}>Cancel</button>`}
176
+ </div>
177
+ <div className="text-sm text-muted mt-4">Get a key at <span style=${{ color: "var(--accent)" }}>${sel.keysUrl}</span>.</div>
178
+ </div>`
179
+ : html`<div className="flex items-center gap-3" style=${{ flexWrap: "wrap" }}>
180
+ <${LuCheck} size=${18} color="var(--success)" />
181
+ <div style=${{ flex: 1, minWidth: "180px" }}>
182
+ <div style=${{ fontWeight: 600 }}>
183
+ ${sel.id === activeProvider ? "In use" : "Connected"}
184
+ </div>
185
+ <div className="text-sm text-muted">
186
+ ${sel.id === activeProvider
187
+ ? html`model <span style=${{ fontFamily: "monospace" }}>${config.model}</span>`
188
+ : "Not the active provider"}
189
+ </div>
190
+ </div>
191
+ ${sel.id !== activeProvider &&
192
+ html`<button className="btn btn-primary" onClick=${() => switchTo(sel.id)}>Use this provider</button>`}
193
+ <button className="btn" onClick=${() => { setEditingKey(true); setKeyInput(""); }}>Replace key</button>
194
+ </div>`}
195
+ </div>`}
196
+ </div>
197
+
198
+ ${!activeConfigured
199
+ ? html`<div className="card text-sm text-muted">Add an API key above to start generating.</div>`
200
+ : html`<div>
201
+ ${!status?.mongoConfigured &&
202
+ html`<div className="card mb-4 text-sm" style=${{ borderColor: "rgba(245,158,11,0.3)" }}>
203
+ Configure MongoDB first — models and routes need a database to run.
204
+ </div>`}
205
+
206
+ <div className="card mb-4">
207
+ <textarea className="form-input" rows=${3} value=${prompt}
208
+ onInput=${(e) => setPrompt(e.target.value)}
209
+ placeholder="e.g. A blog with posts and comments, authored by users, with full CRUD"
210
+ style=${{ resize: "vertical", fontFamily: "inherit" }} />
211
+ <div style=${{ display: "flex", flexWrap: "wrap", gap: "6px", margin: "10px 0" }}>
212
+ ${EXAMPLES.map((ex) => html`<button key=${ex} className="badge"
213
+ onClick=${() => setPrompt(ex)}
214
+ style=${{ cursor: "pointer", border: "1px solid var(--border)", background: "var(--bg-input)", color: "var(--text-secondary)", fontWeight: 400 }}>${ex}</button>`)}
215
+ </div>
216
+ <button className="btn btn-primary" onClick=${generate} disabled=${loading || !prompt.trim()}>
217
+ ${loading ? html`<span className="spinner" />` : html`<${LuSparkles} size=${16} />`}
218
+ ${loading ? "Designing..." : "Generate plan"}
219
+ </button>
220
+ </div>
221
+
222
+ ${preview &&
223
+ html`<div className="card">
224
+ <div className="flex items-center justify-between mb-2">
225
+ <div className="card-title">Plan</div>
226
+ <button className="btn btn-primary" onClick=${apply} disabled=${applying}>
227
+ ${applying ? html`<span className="spinner" />` : html`<${LuCheck} size=${16} />`}
228
+ ${applying ? "Generating..." : "Generate files"}
229
+ </button>
230
+ </div>
231
+ ${preview.summary && html`<div className="text-sm text-muted mb-4">${preview.summary}</div>`}
232
+
233
+ ${preview.models.length > 0 &&
234
+ html`<div className="mb-4">
235
+ <div style=${{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px", color: "var(--text-muted)", marginBottom: "8px" }}>
236
+ Models (${preview.models.length})
237
+ </div>
238
+ ${preview.models.map((m) => html`<${CodeBlock} key=${m.name}
239
+ title=${`src/models/${m.name}.js`} subtitle=${m.fields.map((f) => f.name).join(", ")} code=${m.code} />`)}
240
+ </div>`}
241
+
242
+ ${preview.routes.length > 0 &&
243
+ html`<div>
244
+ <div style=${{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px", color: "var(--text-muted)", marginBottom: "8px" }}>
245
+ Routes (${preview.routes.length})
246
+ </div>
247
+ ${preview.routes.map((r) => html`<${CodeBlock} key=${r.name}
248
+ title=${`/api/${r.name}`} subtitle=${`${r.model} · ${r.operations.join(", ")}`} code=${r.code} />`)}
249
+ </div>`}
250
+ </div>`}
251
+ </div>`}
252
+ </div>`;
253
+ }
254
+
255
+ function CodeBlock({ title, subtitle, code }) {
256
+ const [open, setOpen] = useState(false);
257
+ return html`<div style=${{ border: "1px solid var(--border)", borderRadius: "8px", marginBottom: "8px", overflow: "hidden" }}>
258
+ <div onClick=${() => setOpen((o) => !o)}
259
+ style=${{ display: "flex", alignItems: "center", gap: "8px", padding: "10px 12px", cursor: "pointer" }}>
260
+ ${open ? html`<${LuChevronDown} size=${14} />` : html`<${LuChevronRight} size=${14} />`}
261
+ <div style=${{ flex: 1, minWidth: 0 }}>
262
+ <div style=${{ fontWeight: 600, fontSize: "13px" }}>${title}</div>
263
+ <div className="text-sm text-muted" style=${{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>${subtitle}</div>
264
+ </div>
265
+ </div>
266
+ ${open && html`<pre style=${{ margin: 0, padding: "12px", background: "var(--bg)", borderTop: "1px solid var(--border)", overflowX: "auto", fontSize: "12px", lineHeight: 1.5 }}>${code}</pre>`}
267
+ </div>`;
268
+ }
@@ -0,0 +1,164 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { LuKeyRound, LuPlus, LuTrash2, LuCopy, LuEye, LuEyeOff, LuPencil, LuCheck, LuX } from "react-icons/lu";
3
+ import { html, api, copyToClipboard } from "../lib.js";
4
+ import { useApp } from "../context.js";
5
+
6
+ export function ApiKeysPage() {
7
+ const { toast, showConfirm } = useApp();
8
+ const [keys, setKeys] = useState(null);
9
+ const [label, setLabel] = useState("");
10
+ const [busy, setBusy] = useState(false);
11
+ const [revealed, setRevealed] = useState({});
12
+ const [editId, setEditId] = useState(null);
13
+ const [editLabel, setEditLabel] = useState("");
14
+
15
+ async function load() {
16
+ try {
17
+ const r = await api("/security/api-keys");
18
+ setKeys(r.keys || []);
19
+ } catch (err) {
20
+ toast(err.message, "error");
21
+ setKeys([]);
22
+ }
23
+ }
24
+ useEffect(() => { load(); }, []);
25
+
26
+ async function generate() {
27
+ setBusy(true);
28
+ try {
29
+ const r = await api("/security/api-keys", { method: "POST", body: { label: label.trim() } });
30
+ setLabel("");
31
+ setRevealed((s) => ({ ...s, [r.key.id]: true }));
32
+ toast("API key generated", "success");
33
+ await load();
34
+ } catch (err) {
35
+ toast(err.message, "error");
36
+ }
37
+ setBusy(false);
38
+ }
39
+
40
+ async function revoke(k) {
41
+ const ok = await showConfirm(`Revoke API key "${k.label}"? Any client using it will stop working immediately.`, { danger: true, confirmLabel: "Revoke" });
42
+ if (!ok) return;
43
+ try {
44
+ await api("/security/api-keys/" + k.id, { method: "DELETE" });
45
+ toast("API key revoked", "success");
46
+ await load();
47
+ } catch (err) {
48
+ toast(err.message, "error");
49
+ }
50
+ }
51
+
52
+ function startRename(k) {
53
+ setEditId(k.id);
54
+ setEditLabel(k.label);
55
+ }
56
+ function cancelRename() {
57
+ setEditId(null);
58
+ setEditLabel("");
59
+ }
60
+ async function saveRename(k) {
61
+ const next = editLabel.trim();
62
+ if (!next || next === k.label) { cancelRename(); return; }
63
+ try {
64
+ await api("/security/api-keys/" + k.id, { method: "PATCH", body: { label: next } });
65
+ toast("API key renamed", "success");
66
+ cancelRename();
67
+ await load();
68
+ } catch (err) {
69
+ toast(err.message, "error");
70
+ }
71
+ }
72
+
73
+ function copy(k) {
74
+ copyToClipboard(k.key);
75
+ toast("Key copied to clipboard", "success");
76
+ }
77
+
78
+ function mask(key) {
79
+ if (key.length <= 12) return key;
80
+ return key.slice(0, 7) + "…" + key.slice(-4);
81
+ }
82
+
83
+ function fmtDate(iso) {
84
+ if (!iso) return "";
85
+ try { return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); }
86
+ catch { return ""; }
87
+ }
88
+
89
+ return html`<div>
90
+ <div className="flex items-center gap-2 mb-1">
91
+ <${LuKeyRound} size=${20} />
92
+ <div className="card-title" style=${{ margin: 0 }}>API Keys</div>
93
+ </div>
94
+ <div className="card-subtitle mb-4">
95
+ Named keys for machine/service access. A caller sends one in the
96
+ <span style=${{ fontFamily: "monospace" }}> x-api-key</span> header.
97
+ Protect a route with <span style=${{ fontFamily: "monospace" }}>apiKey</span> middleware:
98
+ <span style=${{ fontFamily: "monospace" }}>app.use('/api/private', apiKey)</span>.
99
+ Keys live in <span style=${{ fontFamily: "monospace" }}>src/api-keys.json</span> and take effect immediately.
100
+ </div>
101
+
102
+ <div className="card">
103
+ <div style=${{ display: "flex", gap: "8px", marginBottom: "16px", flexWrap: "wrap" }}>
104
+ <input className="form-input" placeholder="Key name (e.g. Mobile app, Partner X)"
105
+ value=${label} maxLength=${60}
106
+ onInput=${(e) => setLabel(e.target.value)}
107
+ onKeyDown=${(e) => { if (e.key === "Enter" && !busy) generate(); }}
108
+ style=${{ flex: 1, minWidth: "220px" }} />
109
+ <button className="btn btn-primary" onClick=${generate} disabled=${busy}>
110
+ ${busy ? html`<span className="spinner" />` : html`<${LuPlus} size=${15} />`} Generate key
111
+ </button>
112
+ </div>
113
+
114
+ ${keys === null
115
+ ? html`<div className="text-muted text-sm">Loading…</div>`
116
+ : keys.length === 0
117
+ ? html`<div className="text-muted text-sm">No keys yet — generate one above.</div>`
118
+ : html`<div style=${{ display: "flex", flexDirection: "column", gap: "8px" }}>
119
+ ${keys.map((k) => html`<div key=${k.id}
120
+ style=${{ display: "flex", alignItems: "center", gap: "10px", padding: "10px 12px",
121
+ border: "1px solid var(--border)", borderRadius: "10px", background: "var(--bg-surface)" }}>
122
+ <div style=${{ flex: 1, minWidth: 0 }}>
123
+ ${editId === k.id
124
+ ? html`<input className="form-input" autoFocus value=${editLabel} maxLength=${60}
125
+ onInput=${(e) => setEditLabel(e.target.value)}
126
+ onKeyDown=${(e) => { if (e.key === "Enter") saveRename(k); if (e.key === "Escape") cancelRename(); }}
127
+ style=${{ fontSize: "13px", padding: "4px 8px", marginBottom: "2px", maxWidth: "260px" }} />`
128
+ : html`<div style=${{ fontWeight: 600, fontSize: "13px" }}>${k.label}</div>`}
129
+ <div style=${{ fontFamily: "monospace", fontSize: "12px", color: "var(--text-muted)" }}>
130
+ ${revealed[k.id] ? k.key : mask(k.key)}
131
+ ${k.createdAt && html`<span style=${{ marginLeft: "8px" }}>· ${fmtDate(k.createdAt)}</span>`}
132
+ </div>
133
+ </div>
134
+ ${editId === k.id
135
+ ? html`<${React.Fragment}>
136
+ <button className="btn btn-sm btn-ghost" title="Save" onClick=${() => saveRename(k)}
137
+ style=${{ color: "var(--success, #22c55e)" }}>
138
+ <${LuCheck} size=${15} />
139
+ </button>
140
+ <button className="btn btn-sm btn-ghost" title="Cancel" onClick=${cancelRename}>
141
+ <${LuX} size=${15} />
142
+ </button>
143
+ <//>`
144
+ : html`<${React.Fragment}>
145
+ <button className="btn btn-sm btn-ghost" title="Rename" onClick=${() => startRename(k)}>
146
+ <${LuPencil} size=${15} />
147
+ </button>
148
+ <button className="btn btn-sm btn-ghost" title=${revealed[k.id] ? "Hide" : "Reveal"}
149
+ onClick=${() => setRevealed((s) => ({ ...s, [k.id]: !s[k.id] }))}>
150
+ ${revealed[k.id] ? html`<${LuEyeOff} size=${15} />` : html`<${LuEye} size=${15} />`}
151
+ </button>
152
+ <button className="btn btn-sm btn-ghost" title="Copy" onClick=${() => copy(k)}>
153
+ <${LuCopy} size=${15} />
154
+ </button>
155
+ <button className="btn btn-sm btn-ghost" title="Revoke" onClick=${() => revoke(k)}
156
+ style=${{ color: "var(--danger, #ef4444)" }}>
157
+ <${LuTrash2} size=${15} />
158
+ </button>
159
+ <//>`}
160
+ </div>`)}
161
+ </div>`}
162
+ </div>
163
+ </div>`;
164
+ }