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