4bnode 4.1.5 → 4.1.7
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/add-api.js +5 -3
- package/add-email.js +118 -28
- package/add-login.js +35 -16
- package/add-mongo-schema.js +13 -5
- package/lib/codegen.js +191 -57
- package/lib/codegen.test.js +87 -15
- package/lib/names.js +1 -1
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +54 -9
- package/skeleton/.4bnode/assets/js/pages/LogsPage.js +49 -16
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +110 -50
- package/skeleton/.4bnode/codegen.js +191 -57
- package/skeleton/.4bnode/dev-api.js +284 -56
- package/skeleton/.env.example +17 -3
- package/skeleton/_gitignore +1 -0
- package/skeleton/index.js +27 -4
- package/skeleton/src/middleware/apiKey.js +14 -1
- package/skeleton/src/middleware/errorHandler.js +7 -2
- package/app-1/.4bnode/.passkey +0 -1
- package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
- package/app-1/.4bnode/assets/images/mongo.png +0 -0
- package/app-1/.4bnode/assets/js/App.js +0 -139
- package/app-1/.4bnode/assets/js/Root.js +0 -40
- package/app-1/.4bnode/assets/js/components/Modal.js +0 -52
- package/app-1/.4bnode/assets/js/components/Sidebar.js +0 -73
- package/app-1/.4bnode/assets/js/components/ToastContainer.js +0 -15
- package/app-1/.4bnode/assets/js/context.js +0 -7
- package/app-1/.4bnode/assets/js/lib.js +0 -113
- package/app-1/.4bnode/assets/js/main.js +0 -5
- package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +0 -268
- package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +0 -1879
- package/app-1/.4bnode/assets/js/pages/DashboardPage.js +0 -435
- package/app-1/.4bnode/assets/js/pages/DatabasePage.js +0 -300
- package/app-1/.4bnode/assets/js/pages/DocsPage.js +0 -150
- package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +0 -103
- package/app-1/.4bnode/assets/js/pages/LogsPage.js +0 -205
- package/app-1/.4bnode/assets/js/pages/MailPage.js +0 -224
- package/app-1/.4bnode/assets/js/pages/RealtimePage.js +0 -186
- package/app-1/.4bnode/assets/js/pages/RoutesPage.js +0 -1485
- package/app-1/.4bnode/assets/js/pages/SchemasPage.js +0 -838
- package/app-1/.4bnode/assets/js/pages/SecurityPage.js +0 -74
- package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +0 -170
- package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +0 -105
- package/app-1/.4bnode/codegen.js +0 -1440
- package/app-1/.4bnode/dev-api.js +0 -3747
- package/app-1/.4bnode/ui.html +0 -1578
- package/app-1/.env.example +0 -11
- package/app-1/README.md +0 -47
- package/app-1/ecosystem.config.cjs +0 -25
- package/app-1/index.js +0 -188
- package/app-1/package.json +0 -37
- package/app-1/public/index.html +0 -292
- package/app-1/src/db.js +0 -13
- package/app-1/src/docs.js +0 -84
- package/app-1/src/middleware/apiKey.js +0 -36
- package/app-1/src/middleware/auth.js +0 -17
- package/app-1/src/middleware/errorHandler.js +0 -15
- package/app-1/src/middleware/roles.js +0 -14
- package/app-1/src/middleware/validate.js +0 -20
- package/app-1/src/models/user.js +0 -11
- package/app-1/src/openapi.json +0 -80
- package/app-1/src/routes/upload-resume.js +0 -21
- package/app-1/src/services/mailer.js +0 -52
- package/app-1/src/validators/upload-resume.js +0 -8
|
@@ -1,838 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect, useRef } from "react";
|
|
2
|
-
import { LuFileCode, LuLock } from "react-icons/lu";
|
|
3
|
-
import { html, api, Icons } from "../lib.js";
|
|
4
|
-
import { useApp } from "../context.js";
|
|
5
|
-
|
|
6
|
-
export function SchemasPage() {
|
|
7
|
-
const { data, toast, refresh, setPage, showConfirm } = useApp();
|
|
8
|
-
const [showCreate, setShowCreate] = useState(false);
|
|
9
|
-
const [editModel, setEditModel] = useState(null);
|
|
10
|
-
const [selectedModel, setSelectedModel] = useState(null);
|
|
11
|
-
|
|
12
|
-
// Auto-select first model
|
|
13
|
-
useEffect(() => {
|
|
14
|
-
if (
|
|
15
|
-
data.models?.length > 0 &&
|
|
16
|
-
!selectedModel &&
|
|
17
|
-
!showCreate &&
|
|
18
|
-
!editModel
|
|
19
|
-
) {
|
|
20
|
-
setSelectedModel(data.models[0].name);
|
|
21
|
-
}
|
|
22
|
-
}, [data.models]);
|
|
23
|
-
|
|
24
|
-
if (!data.mongoConfigured) {
|
|
25
|
-
return html`<div className="locked-state">
|
|
26
|
-
<div className="locked-icon"><${LuLock} size=${24} /></div>
|
|
27
|
-
<h3>Database Required</h3>
|
|
28
|
-
<p>
|
|
29
|
-
You need to set up a database before creating schemas. Schemas
|
|
30
|
-
define the structure of your data.
|
|
31
|
-
</p>
|
|
32
|
-
<button
|
|
33
|
-
className="btn btn-primary"
|
|
34
|
-
onClick=${() => setPage("database")}
|
|
35
|
-
>
|
|
36
|
-
Set Up Database
|
|
37
|
-
</button>
|
|
38
|
-
</div>`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const selected = data.models?.find((m) => m.name === selectedModel);
|
|
42
|
-
const reqCount =
|
|
43
|
-
selected?.fields?.filter((f) => f.required).length || 0;
|
|
44
|
-
const uniqCount = selected?.fields?.filter((f) => f.unique).length || 0;
|
|
45
|
-
const isEditing = showCreate || !!editModel;
|
|
46
|
-
|
|
47
|
-
// Empty state + no create open
|
|
48
|
-
if (data.models?.length === 0 && !showCreate) {
|
|
49
|
-
return html`<div>
|
|
50
|
-
<div className="flex items-center justify-between mb-4">
|
|
51
|
-
<div className="card-subtitle">0 models defined</div>
|
|
52
|
-
<button
|
|
53
|
-
className="btn btn-primary"
|
|
54
|
-
onClick=${() => {
|
|
55
|
-
setShowCreate(true);
|
|
56
|
-
setSelectedModel(null);
|
|
57
|
-
setEditModel(null);
|
|
58
|
-
}}
|
|
59
|
-
>
|
|
60
|
-
<span className="sidebar-icon">${Icons.plus}</span> New Schema
|
|
61
|
-
</button>
|
|
62
|
-
</div>
|
|
63
|
-
<div className="empty-state">
|
|
64
|
-
<div className="empty-icon"><${LuFileCode} size=${36} /></div>
|
|
65
|
-
<h3>No schemas yet</h3>
|
|
66
|
-
<p>
|
|
67
|
-
Create your first model schema to define your data structure
|
|
68
|
-
</p>
|
|
69
|
-
<button
|
|
70
|
-
className="btn btn-primary"
|
|
71
|
-
onClick=${() => setShowCreate(true)}
|
|
72
|
-
>
|
|
73
|
-
Create Schema
|
|
74
|
-
</button>
|
|
75
|
-
</div>
|
|
76
|
-
</div>`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return html`<div>
|
|
80
|
-
<div className="flex items-center justify-between mb-4">
|
|
81
|
-
<div className="card-subtitle">
|
|
82
|
-
${data.models?.length || 0}
|
|
83
|
-
model${data.models?.length !== 1 ? "s" : ""} defined
|
|
84
|
-
</div>
|
|
85
|
-
<button
|
|
86
|
-
className=${`btn ${isEditing ? "btn-ghost" : "btn-primary"}`}
|
|
87
|
-
onClick=${() => {
|
|
88
|
-
if (isEditing) {
|
|
89
|
-
setShowCreate(false);
|
|
90
|
-
setEditModel(null);
|
|
91
|
-
if (data.models?.length)
|
|
92
|
-
setSelectedModel(data.models[0].name);
|
|
93
|
-
} else {
|
|
94
|
-
setShowCreate(true);
|
|
95
|
-
setSelectedModel(null);
|
|
96
|
-
setEditModel(null);
|
|
97
|
-
}
|
|
98
|
-
}}
|
|
99
|
-
>
|
|
100
|
-
${isEditing
|
|
101
|
-
? "Cancel"
|
|
102
|
-
: html`<span className="sidebar-icon">${Icons.plus}</span> New
|
|
103
|
-
Schema`}
|
|
104
|
-
</button>
|
|
105
|
-
</div>
|
|
106
|
-
|
|
107
|
-
<div className="routes-layout">
|
|
108
|
-
<div className="card" style=${{ padding: 0, overflow: "hidden" }}>
|
|
109
|
-
<div
|
|
110
|
-
style=${{
|
|
111
|
-
padding: "14px 16px 10px",
|
|
112
|
-
borderBottom: "1px solid var(--border)",
|
|
113
|
-
}}
|
|
114
|
-
>
|
|
115
|
-
<span
|
|
116
|
-
style=${{
|
|
117
|
-
fontWeight: 600,
|
|
118
|
-
fontSize: "12px",
|
|
119
|
-
textTransform: "uppercase",
|
|
120
|
-
letterSpacing: "0.5px",
|
|
121
|
-
color: "var(--text-muted)",
|
|
122
|
-
}}
|
|
123
|
-
>Models</span
|
|
124
|
-
>
|
|
125
|
-
</div>
|
|
126
|
-
<div className="routes-list">
|
|
127
|
-
${(data.models || []).map((m) => {
|
|
128
|
-
const isActive =
|
|
129
|
-
selectedModel === m.name && !showCreate && !editModel;
|
|
130
|
-
const isEditActive = editModel?.name === m.name;
|
|
131
|
-
return html`<div
|
|
132
|
-
key=${m.name}
|
|
133
|
-
className=${`route-item ${isActive || isEditActive ? "active" : ""}`}
|
|
134
|
-
onClick=${() => {
|
|
135
|
-
setShowCreate(false);
|
|
136
|
-
setEditModel(null);
|
|
137
|
-
setSelectedModel(m.name);
|
|
138
|
-
}}
|
|
139
|
-
>
|
|
140
|
-
<div
|
|
141
|
-
style=${{
|
|
142
|
-
display: "flex",
|
|
143
|
-
flexDirection: "column",
|
|
144
|
-
gap: "4px",
|
|
145
|
-
flex: 1,
|
|
146
|
-
minWidth: 0,
|
|
147
|
-
}}
|
|
148
|
-
>
|
|
149
|
-
<span className="ri-name">${m.name}</span>
|
|
150
|
-
<span className="ri-file">src/models/${m.name}.js</span>
|
|
151
|
-
</div>
|
|
152
|
-
<span className="field-count"
|
|
153
|
-
>${m.fields?.length || 0}</span
|
|
154
|
-
>
|
|
155
|
-
</div>`;
|
|
156
|
-
})}
|
|
157
|
-
<div
|
|
158
|
-
className=${`route-item ${showCreate ? "active" : ""}`}
|
|
159
|
-
onClick=${() => {
|
|
160
|
-
setShowCreate(true);
|
|
161
|
-
setSelectedModel(null);
|
|
162
|
-
setEditModel(null);
|
|
163
|
-
}}
|
|
164
|
-
style=${{
|
|
165
|
-
borderTop: data.models?.length
|
|
166
|
-
? "1px solid var(--border)"
|
|
167
|
-
: "none",
|
|
168
|
-
}}
|
|
169
|
-
>
|
|
170
|
-
<span
|
|
171
|
-
className="sidebar-icon"
|
|
172
|
-
style=${{ color: "var(--accent-light)" }}
|
|
173
|
-
>${Icons.plus}</span
|
|
174
|
-
>
|
|
175
|
-
<span
|
|
176
|
-
className="ri-name"
|
|
177
|
-
style=${{ color: "var(--accent-light)" }}
|
|
178
|
-
>New Schema</span
|
|
179
|
-
>
|
|
180
|
-
</div>
|
|
181
|
-
</div>
|
|
182
|
-
</div>
|
|
183
|
-
|
|
184
|
-
<div className="routes-detail">
|
|
185
|
-
${showCreate &&
|
|
186
|
-
html`<${SchemaBuilderPanel}
|
|
187
|
-
onDone=${() => {
|
|
188
|
-
setShowCreate(false);
|
|
189
|
-
refresh();
|
|
190
|
-
}}
|
|
191
|
-
/>`}
|
|
192
|
-
${editModel &&
|
|
193
|
-
!showCreate &&
|
|
194
|
-
html`<${SchemaBuilderPanel}
|
|
195
|
-
model=${editModel}
|
|
196
|
-
onDone=${() => {
|
|
197
|
-
setEditModel(null);
|
|
198
|
-
refresh();
|
|
199
|
-
}}
|
|
200
|
-
/>`}
|
|
201
|
-
${!showCreate &&
|
|
202
|
-
!editModel &&
|
|
203
|
-
!selected &&
|
|
204
|
-
html`<div className="card">
|
|
205
|
-
<div className="empty-state" style=${{ padding: "40px" }}>
|
|
206
|
-
<p>Select a model to view its fields</p>
|
|
207
|
-
</div>
|
|
208
|
-
</div>`}
|
|
209
|
-
${!showCreate &&
|
|
210
|
-
!editModel &&
|
|
211
|
-
selected &&
|
|
212
|
-
html`<${SchemaDetailPanel} model=${selected} reqCount=${reqCount} uniqCount=${uniqCount}
|
|
213
|
-
onEdit=${() => { setEditModel(selected); setSelectedModel(null); }}
|
|
214
|
-
onDelete=${async () => {
|
|
215
|
-
const ok = await showConfirm('Are you sure you want to delete the model "' + selected.name + '"? This will remove the schema file.', { danger: true, confirmLabel: 'Delete Model' });
|
|
216
|
-
if (!ok) return;
|
|
217
|
-
try { await api("/models/" + selected.name, { method: "DELETE" }); toast('Model "' + selected.name + '" deleted', "success"); setSelectedModel(null); refresh(); }
|
|
218
|
-
catch (err) { toast(err.message, "error"); }
|
|
219
|
-
}}
|
|
220
|
-
/>`}
|
|
221
|
-
</div>
|
|
222
|
-
</div>
|
|
223
|
-
</div>`;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function SchemaDetailPanel({ model, reqCount, uniqCount, onEdit, onDelete }) {
|
|
227
|
-
const thStyle = { padding: '10px 16px', fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: 'left', borderBottom: '1px solid var(--border)' };
|
|
228
|
-
const tdStyle = { padding: '8px 16px', fontSize: '12px', borderBottom: '1px solid var(--border)' };
|
|
229
|
-
|
|
230
|
-
return html`<div>
|
|
231
|
-
<div className="card mb-4">
|
|
232
|
-
<div className="flex items-center justify-between mb-3">
|
|
233
|
-
<div>
|
|
234
|
-
<div style=${{ fontWeight: 600, fontSize: '16px', marginBottom: '2px' }}>${model.name}</div>
|
|
235
|
-
<div className="text-sm text-muted" style=${{ fontFamily: "'SF Mono', monospace" }}>src/models/${model.name}.js</div>
|
|
236
|
-
</div>
|
|
237
|
-
<div className="flex gap-2">
|
|
238
|
-
<button className="btn btn-ghost btn-sm" onClick=${onEdit}>
|
|
239
|
-
<span className="sidebar-icon">${Icons.edit}</span> Edit
|
|
240
|
-
</button>
|
|
241
|
-
<button className="btn btn-danger btn-sm btn-icon" onClick=${onDelete}>
|
|
242
|
-
<span className="sidebar-icon">${Icons.trash}</span>
|
|
243
|
-
</button>
|
|
244
|
-
</div>
|
|
245
|
-
</div>
|
|
246
|
-
<div className="flex gap-3">
|
|
247
|
-
${[['Fields', model.fields?.length || 0, null], ['Required', reqCount, reqCount > 0 ? 'var(--success)' : null], ['Unique', uniqCount, uniqCount > 0 ? 'var(--warning)' : null], ['Indexes', (model.fields?.filter(f => f.index).length || 0) + (model.indexes?.length || 0), ((model.fields?.filter(f => f.index).length || 0) + (model.indexes?.length || 0)) > 0 ? 'var(--info)' : null]].map(([label, count, color]) =>
|
|
248
|
-
html`<div key=${label} style=${{ padding: '8px 14px', background: 'var(--bg-input)', borderRadius: 'var(--radius)', fontSize: '13px' }}>
|
|
249
|
-
<span style=${{ color: 'var(--text-muted)' }}>${label} </span>
|
|
250
|
-
<span style=${{ fontWeight: 600, color: color || 'var(--text-secondary)' }}>${count}</span>
|
|
251
|
-
</div>`
|
|
252
|
-
)}
|
|
253
|
-
</div>
|
|
254
|
-
</div>
|
|
255
|
-
|
|
256
|
-
<div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
|
|
257
|
-
<div style=${{ padding: '12px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
258
|
-
<span style=${{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Schema Fields</span>
|
|
259
|
-
</div>
|
|
260
|
-
<table style=${{ width: '100%' }}>
|
|
261
|
-
<thead><tr>
|
|
262
|
-
<th style=${thStyle}>#</th><th style=${thStyle}>Name</th><th style=${thStyle}>Type</th><th style=${thStyle}>Properties</th>
|
|
263
|
-
</tr></thead>
|
|
264
|
-
<tbody>
|
|
265
|
-
${model.fields?.map((f, i) => html`<tr key=${f.name} style=${{ borderBottom: '1px solid var(--border)' }}>
|
|
266
|
-
<td style=${{ ...tdStyle, color: 'var(--text-muted)' }}>${i + 1}</td>
|
|
267
|
-
<td style=${{ ...tdStyle, fontWeight: 500, fontSize: '13px', fontFamily: "'SF Mono', monospace" }}>${f.name}</td>
|
|
268
|
-
<td style=${tdStyle}><span style=${{ fontSize: '12px', padding: '2px 8px', borderRadius: '4px', background: 'var(--accent-glow)', color: 'var(--accent-light)' }}>${f.type || 'String'}</span></td>
|
|
269
|
-
<td style=${tdStyle}><div className="flex gap-1">
|
|
270
|
-
${f.required && html`<span className="badge badge-success" style=${{ fontSize: '10px' }}>required</span>`}
|
|
271
|
-
${f.unique && html`<span className="badge badge-warning" style=${{ fontSize: '10px' }}>unique</span>`}
|
|
272
|
-
${f.index && html`<span className="badge badge-info" style=${{ fontSize: '10px' }}>indexed</span>`}
|
|
273
|
-
${!f.required && !f.unique && !f.index && html`<span style=${{ fontSize: '12px', color: 'var(--text-muted)' }}>—</span>`}
|
|
274
|
-
</div></td>
|
|
275
|
-
</tr>`)}
|
|
276
|
-
</tbody>
|
|
277
|
-
</table>
|
|
278
|
-
</div>
|
|
279
|
-
</div>`;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function SchemaBuilderPanel({ model, onDone }) {
|
|
283
|
-
const { data, toast, refresh } = useApp();
|
|
284
|
-
const [name, setName] = useState(model?.name || "");
|
|
285
|
-
const [fields, setFields] = useState(
|
|
286
|
-
model?.fields?.map((f) => ({ ...f })) || [],
|
|
287
|
-
);
|
|
288
|
-
const [loading, setLoading] = useState(false);
|
|
289
|
-
const [saved, setSaved] = useState(false);
|
|
290
|
-
const [editIndex, setEditIndex] = useState(null);
|
|
291
|
-
const isEdit = !!model;
|
|
292
|
-
|
|
293
|
-
// Add/edit field form state
|
|
294
|
-
const [fname, setFname] = useState("");
|
|
295
|
-
const [ftype, setFtype] = useState("String");
|
|
296
|
-
const [freq, setFreq] = useState(false);
|
|
297
|
-
const [funiq, setFuniq] = useState(false);
|
|
298
|
-
const [fref, setFref] = useState("");
|
|
299
|
-
const [findex, setFindex] = useState(false);
|
|
300
|
-
const [indexes, setIndexes] = useState(model?.indexes || []);
|
|
301
|
-
const [idxFields, setIdxFields] = useState([]);
|
|
302
|
-
const [idxType, setIdxType] = useState('compound');
|
|
303
|
-
const [idxUnique, setIdxUnique] = useState(false);
|
|
304
|
-
const nameRef = useRef(null);
|
|
305
|
-
const fieldNameRef = useRef(null);
|
|
306
|
-
|
|
307
|
-
const fieldTypes = [
|
|
308
|
-
"String",
|
|
309
|
-
"Number",
|
|
310
|
-
"Boolean",
|
|
311
|
-
"Date",
|
|
312
|
-
"ObjectId",
|
|
313
|
-
"Array",
|
|
314
|
-
"Mixed",
|
|
315
|
-
"Buffer",
|
|
316
|
-
"Map",
|
|
317
|
-
];
|
|
318
|
-
|
|
319
|
-
// Get other model names for ObjectId ref
|
|
320
|
-
const otherModels = (data.models || [])
|
|
321
|
-
.map((m) => m.name)
|
|
322
|
-
.filter((n) => n !== name);
|
|
323
|
-
|
|
324
|
-
useEffect(() => {
|
|
325
|
-
if (!isEdit && nameRef.current) nameRef.current.focus();
|
|
326
|
-
else if (isEdit && fieldNameRef.current) fieldNameRef.current.focus();
|
|
327
|
-
}, []);
|
|
328
|
-
|
|
329
|
-
function resetFieldForm() {
|
|
330
|
-
setFname("");
|
|
331
|
-
setFtype("String");
|
|
332
|
-
setFreq(false);
|
|
333
|
-
setFuniq(false);
|
|
334
|
-
setFref("");
|
|
335
|
-
setFindex(false);
|
|
336
|
-
setEditIndex(null);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
function addOrUpdateField() {
|
|
340
|
-
if (!fname.trim()) return;
|
|
341
|
-
const fieldData = {
|
|
342
|
-
name: fname.trim(),
|
|
343
|
-
type: ftype,
|
|
344
|
-
required: freq,
|
|
345
|
-
unique: funiq,
|
|
346
|
-
};
|
|
347
|
-
if (ftype === "ObjectId" && fref) fieldData.ref = fref;
|
|
348
|
-
if (findex) fieldData.index = true;
|
|
349
|
-
|
|
350
|
-
if (editIndex !== null) {
|
|
351
|
-
setFields((prev) =>
|
|
352
|
-
prev.map((f, i) => (i === editIndex ? fieldData : f)),
|
|
353
|
-
);
|
|
354
|
-
setEditIndex(null);
|
|
355
|
-
} else {
|
|
356
|
-
setFields((prev) => [...prev, fieldData]);
|
|
357
|
-
}
|
|
358
|
-
resetFieldForm();
|
|
359
|
-
setSaved(false);
|
|
360
|
-
setTimeout(() => fieldNameRef.current?.focus(), 50);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
function startEdit(index) {
|
|
364
|
-
const f = fields[index];
|
|
365
|
-
setFname(f.name);
|
|
366
|
-
setFtype(f.type || "String");
|
|
367
|
-
setFreq(!!f.required);
|
|
368
|
-
setFuniq(!!f.unique);
|
|
369
|
-
setFref(f.ref || "");
|
|
370
|
-
setFindex(!!f.index);
|
|
371
|
-
setEditIndex(index);
|
|
372
|
-
setTimeout(() => fieldNameRef.current?.focus(), 50);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function removeField(index) {
|
|
376
|
-
setFields((prev) => prev.filter((_, i) => i !== index));
|
|
377
|
-
if (editIndex === index) resetFieldForm();
|
|
378
|
-
setSaved(false);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
async function handleSave() {
|
|
382
|
-
if (!name.trim()) {
|
|
383
|
-
toast("Model name is required", "error");
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
if (fields.length === 0) {
|
|
387
|
-
toast("Add at least one field", "error");
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
setLoading(true);
|
|
391
|
-
try {
|
|
392
|
-
if (isEdit) {
|
|
393
|
-
await api("/models/" + model.name, {
|
|
394
|
-
method: "PUT",
|
|
395
|
-
body: { fields, indexes },
|
|
396
|
-
});
|
|
397
|
-
toast('Model "' + model.name + '" updated', "success");
|
|
398
|
-
} else {
|
|
399
|
-
await api("/models", {
|
|
400
|
-
method: "POST",
|
|
401
|
-
body: { name: name.trim(), fields, indexes },
|
|
402
|
-
});
|
|
403
|
-
toast('Model "' + name.trim() + '" created', "success");
|
|
404
|
-
}
|
|
405
|
-
setSaved(true);
|
|
406
|
-
refresh();
|
|
407
|
-
onDone();
|
|
408
|
-
} catch (err) {
|
|
409
|
-
toast(err.message, "error");
|
|
410
|
-
}
|
|
411
|
-
setLoading(false);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
const typeColors = {
|
|
415
|
-
String: "var(--accent-light)",
|
|
416
|
-
Number: "var(--success)",
|
|
417
|
-
Boolean: "var(--warning)",
|
|
418
|
-
Date: "#a78bfa",
|
|
419
|
-
ObjectId: "#f472b6",
|
|
420
|
-
Array: "#22d3ee",
|
|
421
|
-
Mixed: "var(--text-secondary)",
|
|
422
|
-
Buffer: "#fb923c",
|
|
423
|
-
Map: "#34d399",
|
|
424
|
-
};
|
|
425
|
-
|
|
426
|
-
return html`<div>
|
|
427
|
-
<div className="card mb-4">
|
|
428
|
-
<div
|
|
429
|
-
className="flex items-center justify-between"
|
|
430
|
-
style=${{ marginBottom: "16px" }}
|
|
431
|
-
>
|
|
432
|
-
<div style=${{ fontSize: "15px", fontWeight: 600 }}>
|
|
433
|
-
${isEdit ? "Edit Schema: " + model.name : "Create New Schema"}
|
|
434
|
-
</div>
|
|
435
|
-
<button
|
|
436
|
-
className="btn btn-primary btn-sm"
|
|
437
|
-
onClick=${handleSave}
|
|
438
|
-
disabled=${loading || fields.length === 0 || !name.trim()}
|
|
439
|
-
>
|
|
440
|
-
${loading ? html`<span className="spinner" />` : null}
|
|
441
|
-
${saved ? "\u2713 Saved" : isEdit ? "Update" : "Save Schema"}
|
|
442
|
-
</button>
|
|
443
|
-
</div>
|
|
444
|
-
|
|
445
|
-
${!isEdit &&
|
|
446
|
-
html`<div className="form-group">
|
|
447
|
-
<label className="form-label">Model Name</label>
|
|
448
|
-
<input
|
|
449
|
-
ref=${nameRef}
|
|
450
|
-
className="form-input"
|
|
451
|
-
value=${name}
|
|
452
|
-
onInput=${(e) => setName(e.target.value)}
|
|
453
|
-
placeholder="e.g. user, product, post"
|
|
454
|
-
style=${{ fontSize: "14px", padding: "11px 14px" }}
|
|
455
|
-
/>
|
|
456
|
-
</div>`}
|
|
457
|
-
|
|
458
|
-
<div
|
|
459
|
-
style=${{
|
|
460
|
-
fontSize: "11px",
|
|
461
|
-
fontWeight: 600,
|
|
462
|
-
textTransform: "uppercase",
|
|
463
|
-
letterSpacing: "0.5px",
|
|
464
|
-
color:
|
|
465
|
-
editIndex !== null ? "var(--warning)" : "var(--text-muted)",
|
|
466
|
-
marginBottom: "10px",
|
|
467
|
-
marginTop: isEdit ? 0 : "8px",
|
|
468
|
-
}}
|
|
469
|
-
>
|
|
470
|
-
${editIndex !== null ? "Edit Field" : "Add Field"}
|
|
471
|
-
</div>
|
|
472
|
-
|
|
473
|
-
<div
|
|
474
|
-
className="flex items-center gap-3 mb-3"
|
|
475
|
-
style=${{ flexWrap: "wrap" }}
|
|
476
|
-
>
|
|
477
|
-
<input
|
|
478
|
-
ref=${fieldNameRef}
|
|
479
|
-
className="form-input"
|
|
480
|
-
value=${fname}
|
|
481
|
-
onInput=${(e) => setFname(e.target.value)}
|
|
482
|
-
onKeyDown=${(e) => e.key === "Enter" && addOrUpdateField()}
|
|
483
|
-
placeholder="Field name"
|
|
484
|
-
style=${{
|
|
485
|
-
fontSize: "13px",
|
|
486
|
-
flex: "1 1 120px",
|
|
487
|
-
minWidth: "100px",
|
|
488
|
-
}}
|
|
489
|
-
/>
|
|
490
|
-
<select
|
|
491
|
-
className="form-select"
|
|
492
|
-
value=${ftype}
|
|
493
|
-
onChange=${(e) => {
|
|
494
|
-
setFtype(e.target.value);
|
|
495
|
-
if (e.target.value !== "ObjectId") setFref("");
|
|
496
|
-
}}
|
|
497
|
-
style=${{ fontSize: "13px", width: "110px", flex: "0 0 110px" }}
|
|
498
|
-
>
|
|
499
|
-
${fieldTypes.map(
|
|
500
|
-
(t) => html`<option key=${t} value=${t}>${t}</option>`,
|
|
501
|
-
)}
|
|
502
|
-
</select>
|
|
503
|
-
${ftype === "ObjectId" &&
|
|
504
|
-
html`<select
|
|
505
|
-
className="form-select"
|
|
506
|
-
value=${fref}
|
|
507
|
-
onChange=${(e) => setFref(e.target.value)}
|
|
508
|
-
style=${{ fontSize: "13px", width: "110px", flex: "0 0 110px" }}
|
|
509
|
-
>
|
|
510
|
-
<option value="">Ref...</option>
|
|
511
|
-
${otherModels.map(
|
|
512
|
-
(m) => html`<option key=${m} value=${m}>${m}</option>`,
|
|
513
|
-
)}
|
|
514
|
-
</select>`}
|
|
515
|
-
<div style=${{ display: 'flex', alignItems: 'center', gap: '16px', padding: '0 4px' }}>
|
|
516
|
-
<label
|
|
517
|
-
className="flex items-center gap-2"
|
|
518
|
-
style=${{
|
|
519
|
-
cursor: "pointer",
|
|
520
|
-
fontSize: "12px",
|
|
521
|
-
whiteSpace: "nowrap",
|
|
522
|
-
color: freq ? "var(--success)" : "var(--text-muted)",
|
|
523
|
-
}}
|
|
524
|
-
>
|
|
525
|
-
<div className="form-toggle">
|
|
526
|
-
<input
|
|
527
|
-
type="checkbox"
|
|
528
|
-
checked=${freq}
|
|
529
|
-
onChange=${(e) => setFreq(e.target.checked)}
|
|
530
|
-
/>
|
|
531
|
-
<span className="slider" />
|
|
532
|
-
</div>
|
|
533
|
-
Required
|
|
534
|
-
</label>
|
|
535
|
-
<label
|
|
536
|
-
className="flex items-center gap-2"
|
|
537
|
-
style=${{
|
|
538
|
-
cursor: "pointer",
|
|
539
|
-
fontSize: "12px",
|
|
540
|
-
whiteSpace: "nowrap",
|
|
541
|
-
color: funiq ? "var(--warning)" : "var(--text-muted)",
|
|
542
|
-
}}
|
|
543
|
-
>
|
|
544
|
-
<div className="form-toggle">
|
|
545
|
-
<input
|
|
546
|
-
type="checkbox"
|
|
547
|
-
checked=${funiq}
|
|
548
|
-
onChange=${(e) => setFuniq(e.target.checked)}
|
|
549
|
-
/>
|
|
550
|
-
<span className="slider" />
|
|
551
|
-
</div>
|
|
552
|
-
Unique
|
|
553
|
-
</label>
|
|
554
|
-
<label
|
|
555
|
-
className="flex items-center gap-2"
|
|
556
|
-
style=${{
|
|
557
|
-
cursor: "pointer",
|
|
558
|
-
fontSize: "12px",
|
|
559
|
-
whiteSpace: "nowrap",
|
|
560
|
-
color: findex ? "var(--info)" : "var(--text-muted)",
|
|
561
|
-
}}
|
|
562
|
-
>
|
|
563
|
-
<div className="form-toggle">
|
|
564
|
-
<input
|
|
565
|
-
type="checkbox"
|
|
566
|
-
checked=${findex}
|
|
567
|
-
onChange=${(e) => setFindex(e.target.checked)}
|
|
568
|
-
/>
|
|
569
|
-
<span className="slider" />
|
|
570
|
-
</div>
|
|
571
|
-
Index
|
|
572
|
-
</label>
|
|
573
|
-
</div>
|
|
574
|
-
${editIndex !== null &&
|
|
575
|
-
html`<button
|
|
576
|
-
className="btn btn-ghost btn-sm"
|
|
577
|
-
onClick=${resetFieldForm}
|
|
578
|
-
style=${{ padding: "5px 8px" }}
|
|
579
|
-
>
|
|
580
|
-
Cancel
|
|
581
|
-
</button>`}
|
|
582
|
-
<button
|
|
583
|
-
className="btn ${editIndex !== null
|
|
584
|
-
? "btn-warning"
|
|
585
|
-
: "btn-primary"} btn-sm"
|
|
586
|
-
onClick=${addOrUpdateField}
|
|
587
|
-
disabled=${!fname.trim()}
|
|
588
|
-
style=${{ padding: "5px 10px", whiteSpace: "nowrap" }}
|
|
589
|
-
>
|
|
590
|
-
${editIndex !== null
|
|
591
|
-
? "Update"
|
|
592
|
-
: html`<span className="sidebar-icon">${Icons.plus}</span>
|
|
593
|
-
Add`}
|
|
594
|
-
</button>
|
|
595
|
-
</div>
|
|
596
|
-
</div>
|
|
597
|
-
|
|
598
|
-
${fields.length > 0 &&
|
|
599
|
-
html`<div
|
|
600
|
-
className="card mb-4"
|
|
601
|
-
style=${{ padding: 0, overflow: "hidden" }}
|
|
602
|
-
>
|
|
603
|
-
<div
|
|
604
|
-
style=${{
|
|
605
|
-
padding: "12px 16px",
|
|
606
|
-
borderBottom: "1px solid var(--border)",
|
|
607
|
-
display: "flex",
|
|
608
|
-
alignItems: "center",
|
|
609
|
-
justifyContent: "space-between",
|
|
610
|
-
}}
|
|
611
|
-
>
|
|
612
|
-
<span
|
|
613
|
-
style=${{
|
|
614
|
-
fontSize: "12px",
|
|
615
|
-
fontWeight: 600,
|
|
616
|
-
textTransform: "uppercase",
|
|
617
|
-
letterSpacing: "0.5px",
|
|
618
|
-
color: "var(--text-muted)",
|
|
619
|
-
}}
|
|
620
|
-
>Fields</span
|
|
621
|
-
>
|
|
622
|
-
<span className="field-count">${fields.length}</span>
|
|
623
|
-
</div>
|
|
624
|
-
<div style=${{ maxHeight: "340px", overflowY: "auto" }}>
|
|
625
|
-
${fields.map(
|
|
626
|
-
(f, i) =>
|
|
627
|
-
html`<div
|
|
628
|
-
key=${i}
|
|
629
|
-
className="flex items-center gap-3"
|
|
630
|
-
style=${{
|
|
631
|
-
padding: "8px 16px",
|
|
632
|
-
borderBottom: "1px solid rgba(255,255,255,0.03)",
|
|
633
|
-
background:
|
|
634
|
-
editIndex === i ? "var(--warning-glow)" : "transparent",
|
|
635
|
-
transition: "background var(--transition)",
|
|
636
|
-
}}
|
|
637
|
-
onMouseEnter=${(e) => {
|
|
638
|
-
if (editIndex !== i)
|
|
639
|
-
e.currentTarget.style.background =
|
|
640
|
-
"rgba(255,255,255,0.02)";
|
|
641
|
-
}}
|
|
642
|
-
onMouseLeave=${(e) => {
|
|
643
|
-
if (editIndex !== i)
|
|
644
|
-
e.currentTarget.style.background = "transparent";
|
|
645
|
-
}}
|
|
646
|
-
>
|
|
647
|
-
<span
|
|
648
|
-
style=${{
|
|
649
|
-
fontSize: "11px",
|
|
650
|
-
color: "var(--text-muted)",
|
|
651
|
-
width: "20px",
|
|
652
|
-
fontVariantNumeric: "tabular-nums",
|
|
653
|
-
}}
|
|
654
|
-
>${i + 1}</span
|
|
655
|
-
>
|
|
656
|
-
<span
|
|
657
|
-
style=${{
|
|
658
|
-
fontWeight: 500,
|
|
659
|
-
fontSize: "13px",
|
|
660
|
-
flex: 1,
|
|
661
|
-
fontFamily: "'SF Mono', 'Fira Code', monospace",
|
|
662
|
-
}}
|
|
663
|
-
>${f.name}</span
|
|
664
|
-
>
|
|
665
|
-
<span
|
|
666
|
-
style=${{
|
|
667
|
-
fontSize: "11px",
|
|
668
|
-
padding: "2px 8px",
|
|
669
|
-
borderRadius: "4px",
|
|
670
|
-
background: "rgba(255,255,255,0.04)",
|
|
671
|
-
color: typeColors[f.type] || "var(--text-secondary)",
|
|
672
|
-
}}
|
|
673
|
-
>${f.type}${f.ref ? " \u2192 " + f.ref : ""}</span
|
|
674
|
-
>
|
|
675
|
-
${f.required &&
|
|
676
|
-
html`<span
|
|
677
|
-
style=${{
|
|
678
|
-
fontSize: "9px",
|
|
679
|
-
padding: "2px 6px",
|
|
680
|
-
borderRadius: "4px",
|
|
681
|
-
background: "var(--success-glow)",
|
|
682
|
-
color: "var(--success)",
|
|
683
|
-
fontWeight: 600,
|
|
684
|
-
}}
|
|
685
|
-
>REQ</span
|
|
686
|
-
>`}
|
|
687
|
-
${f.unique &&
|
|
688
|
-
html`<span
|
|
689
|
-
style=${{
|
|
690
|
-
fontSize: "9px",
|
|
691
|
-
padding: "2px 6px",
|
|
692
|
-
borderRadius: "4px",
|
|
693
|
-
background: "var(--warning-glow)",
|
|
694
|
-
color: "var(--warning)",
|
|
695
|
-
fontWeight: 600,
|
|
696
|
-
}}
|
|
697
|
-
>UNQ</span
|
|
698
|
-
>`}
|
|
699
|
-
${f.index &&
|
|
700
|
-
html`<span
|
|
701
|
-
style=${{
|
|
702
|
-
fontSize: "9px",
|
|
703
|
-
padding: "2px 6px",
|
|
704
|
-
borderRadius: "4px",
|
|
705
|
-
background: "rgba(59,130,246,0.15)",
|
|
706
|
-
color: "var(--info)",
|
|
707
|
-
fontWeight: 600,
|
|
708
|
-
}}
|
|
709
|
-
>IDX</span
|
|
710
|
-
>`}
|
|
711
|
-
<button
|
|
712
|
-
className="btn btn-ghost btn-sm btn-icon"
|
|
713
|
-
onClick=${() => startEdit(i)}
|
|
714
|
-
style=${{
|
|
715
|
-
color: "var(--accent-light)",
|
|
716
|
-
opacity: 0.6,
|
|
717
|
-
padding: "4px",
|
|
718
|
-
}}
|
|
719
|
-
>
|
|
720
|
-
<span className="sidebar-icon">${Icons.edit}</span>
|
|
721
|
-
</button>
|
|
722
|
-
<button
|
|
723
|
-
className="btn btn-ghost btn-sm btn-icon"
|
|
724
|
-
onClick=${() => removeField(i)}
|
|
725
|
-
style=${{
|
|
726
|
-
color: "var(--error)",
|
|
727
|
-
opacity: 0.5,
|
|
728
|
-
padding: "4px",
|
|
729
|
-
}}
|
|
730
|
-
>
|
|
731
|
-
<span className="sidebar-icon">${Icons.trash}</span>
|
|
732
|
-
</button>
|
|
733
|
-
</div>`,
|
|
734
|
-
)}
|
|
735
|
-
</div>
|
|
736
|
-
</div>`}
|
|
737
|
-
${fields.length === 0 &&
|
|
738
|
-
html`<div
|
|
739
|
-
className="card mb-4"
|
|
740
|
-
style=${{
|
|
741
|
-
background: "var(--bg)",
|
|
742
|
-
padding: "30px",
|
|
743
|
-
textAlign: "center",
|
|
744
|
-
}}
|
|
745
|
-
>
|
|
746
|
-
<div style=${{ fontSize: "12px", color: "var(--text-muted)" }}>
|
|
747
|
-
Add fields above to define your schema
|
|
748
|
-
</div>
|
|
749
|
-
</div>`}
|
|
750
|
-
|
|
751
|
-
<div className="card mb-4" style=${{ padding: '16px' }}>
|
|
752
|
-
<div style=${{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: '12px' }}>
|
|
753
|
-
Compound Indexes
|
|
754
|
-
</div>
|
|
755
|
-
|
|
756
|
-
<div className="flex items-center gap-3 mb-3" style=${{ flexWrap: 'wrap' }}>
|
|
757
|
-
<select className="form-select" value=${idxType}
|
|
758
|
-
onChange=${(e) => setIdxType(e.target.value)}
|
|
759
|
-
style=${{ fontSize: '13px', width: '120px' }}>
|
|
760
|
-
<option value="compound">Compound</option>
|
|
761
|
-
<option value="text">Text</option>
|
|
762
|
-
</select>
|
|
763
|
-
|
|
764
|
-
${idxType === 'compound' && html`<label className="flex items-center gap-2" style=${{ cursor: 'pointer', fontSize: '12px', color: idxUnique ? 'var(--warning)' : 'var(--text-muted)' }}>
|
|
765
|
-
<div className="form-toggle">
|
|
766
|
-
<input type="checkbox" checked=${idxUnique} onChange=${(e) => setIdxUnique(e.target.checked)} />
|
|
767
|
-
<span className="slider" />
|
|
768
|
-
</div>
|
|
769
|
-
Unique
|
|
770
|
-
</label>`}
|
|
771
|
-
|
|
772
|
-
<button className="btn btn-primary btn-sm"
|
|
773
|
-
disabled=${idxFields.length < (idxType === 'text' ? 1 : 2)}
|
|
774
|
-
onClick=${() => {
|
|
775
|
-
const newIdx = {
|
|
776
|
-
fields: idxFields.map(f => ({ field: f.field, direction: idxType === 'text' ? 'text' : f.direction })),
|
|
777
|
-
unique: idxType === 'compound' ? idxUnique : false,
|
|
778
|
-
type: idxType,
|
|
779
|
-
};
|
|
780
|
-
setIndexes(prev => [...prev, newIdx]);
|
|
781
|
-
setIdxFields([]);
|
|
782
|
-
setIdxUnique(false);
|
|
783
|
-
setSaved(false);
|
|
784
|
-
}}>
|
|
785
|
-
Add Index
|
|
786
|
-
</button>
|
|
787
|
-
</div>
|
|
788
|
-
|
|
789
|
-
<div className="flex gap-2 mb-3" style=${{ flexWrap: 'wrap' }}>
|
|
790
|
-
${fields.map(f => {
|
|
791
|
-
const selected = idxFields.find(i => i.field === f.name);
|
|
792
|
-
return html`<button key=${f.name}
|
|
793
|
-
onClick=${() => {
|
|
794
|
-
if (selected) {
|
|
795
|
-
if (idxType !== 'text') {
|
|
796
|
-
if (selected.direction === 1) {
|
|
797
|
-
setIdxFields(prev => prev.map(i => i.field === f.name ? { ...i, direction: -1 } : i));
|
|
798
|
-
} else {
|
|
799
|
-
setIdxFields(prev => prev.filter(i => i.field !== f.name));
|
|
800
|
-
}
|
|
801
|
-
} else {
|
|
802
|
-
setIdxFields(prev => prev.filter(i => i.field !== f.name));
|
|
803
|
-
}
|
|
804
|
-
} else {
|
|
805
|
-
setIdxFields(prev => [...prev, { field: f.name, direction: 1 }]);
|
|
806
|
-
}
|
|
807
|
-
}}
|
|
808
|
-
style=${{ padding: '4px 10px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--border)', cursor: 'pointer', fontFamily: 'monospace',
|
|
809
|
-
background: selected ? 'var(--accent-glow)' : 'transparent',
|
|
810
|
-
color: selected ? 'var(--accent-light)' : 'var(--text-muted)' }}>
|
|
811
|
-
${f.name}${selected && idxType !== 'text' ? (selected.direction === 1 ? ' ASC' : ' DESC') : ''}
|
|
812
|
-
</button>`;
|
|
813
|
-
})}
|
|
814
|
-
</div>
|
|
815
|
-
${idxFields.length > 0 && html`<div style=${{ fontSize: '11px', color: 'var(--text-muted)', marginBottom: '8px' }}>
|
|
816
|
-
${idxType === 'text' ? 'Click to select/deselect fields' : 'Click once = ASC, twice = DESC, third = remove'}
|
|
817
|
-
</div>`}
|
|
818
|
-
|
|
819
|
-
${indexes.length > 0 && html`<div style=${{ marginTop: '12px' }}>
|
|
820
|
-
${indexes.map((idx, i) => html`<div key=${i} className="flex items-center gap-2" style=${{ padding: '6px 12px', background: 'var(--bg-input)', borderRadius: '6px', marginBottom: '4px', fontSize: '12px' }}>
|
|
821
|
-
<span style=${{ fontSize: '9px', padding: '2px 6px', borderRadius: '4px', fontWeight: 600,
|
|
822
|
-
background: idx.type === 'text' ? 'rgba(59,130,246,0.15)' : 'rgba(124,58,237,0.15)',
|
|
823
|
-
color: idx.type === 'text' ? 'var(--info)' : 'var(--accent-light)' }}>
|
|
824
|
-
${idx.type === 'text' ? 'TEXT' : 'COMPOUND'}
|
|
825
|
-
</span>
|
|
826
|
-
<span style=${{ fontFamily: 'monospace', flex: 1 }}>
|
|
827
|
-
{ ${idx.fields.map(f => f.field + ': ' + (f.direction === 'text' ? "'text'" : f.direction)).join(', ')} }
|
|
828
|
-
</span>
|
|
829
|
-
${idx.unique && html`<span style=${{ fontSize: '9px', padding: '2px 6px', borderRadius: '4px', background: 'var(--warning-glow)', color: 'var(--warning)', fontWeight: 600 }}>UNIQUE</span>`}
|
|
830
|
-
<button className="btn btn-ghost btn-sm btn-icon" onClick=${() => { setIndexes(prev => prev.filter((_, j) => j !== i)); setSaved(false); }}
|
|
831
|
-
style=${{ color: 'var(--error)', opacity: 0.5, padding: '4px' }}>
|
|
832
|
-
${Icons.trash}
|
|
833
|
-
</button>
|
|
834
|
-
</div>`)}
|
|
835
|
-
</div>`}
|
|
836
|
-
</div>
|
|
837
|
-
</div>`;
|
|
838
|
-
}
|