4bnode 4.0.2 → 4.0.4
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 +18 -0
- package/add-crud.js +1 -1
- package/add-mongo-update.js +1 -1
- package/add-serialport.js +1 -1
- package/index.js +4 -4
- package/package.json +2 -2
- package/skeleton/.4bnode/assets/js/App.js +4 -0
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +3 -1
- package/skeleton/.4bnode/assets/js/lib.js +20 -0
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +55 -36
- package/skeleton/.4bnode/assets/js/pages/LogsPage.js +205 -0
- package/skeleton/.4bnode/assets/js/pages/ReportsPage.js +667 -0
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +176 -147
- package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +131 -4
- package/skeleton/.4bnode/dev-api.js +774 -39
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef, useCallback } from "react";
|
|
2
|
+
import { motion, AnimatePresence } from "framer-motion";
|
|
3
|
+
import { LuDownload, LuFileSpreadsheet, LuDatabase, LuCheck, LuChevronRight, LuLink, LuX, LuLoader, LuGripVertical, LuArrowUp, LuArrowDown } from "react-icons/lu";
|
|
4
|
+
import { html, api, API, getStoredPasskey } from "../lib.js";
|
|
5
|
+
import { useApp } from "../context.js";
|
|
6
|
+
|
|
7
|
+
export function ReportsPage() {
|
|
8
|
+
const { toast } = useApp();
|
|
9
|
+
const [models, setModels] = useState([]);
|
|
10
|
+
const [selectedModels, setSelectedModels] = useState([]);
|
|
11
|
+
const [modelConfig, setModelConfig] = useState({});
|
|
12
|
+
const [activeModel, setActiveModel] = useState(null);
|
|
13
|
+
const [reportName, setReportName] = useState("report");
|
|
14
|
+
const [loading, setLoading] = useState(false);
|
|
15
|
+
const [schemaLoading, setSchemaLoading] = useState(true);
|
|
16
|
+
const [limit, setLimit] = useState(0);
|
|
17
|
+
const [dateFormat, setDateFormat] = useState("YYYY-MM-DD HH:mm:ss");
|
|
18
|
+
const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
|
19
|
+
const [preview, setPreview] = useState(null);
|
|
20
|
+
const [previewLoading, setPreviewLoading] = useState(false);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
api("/reports/schema-info")
|
|
24
|
+
.then((data) => { setModels(data.models || []); setSchemaLoading(false); })
|
|
25
|
+
.catch((err) => { toast(err.message, "error"); setSchemaLoading(false); });
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
function toggleModel(name) {
|
|
29
|
+
setSelectedModels((prev) => {
|
|
30
|
+
if (prev.includes(name)) {
|
|
31
|
+
const next = prev.filter((n) => n !== name);
|
|
32
|
+
if (activeModel === name) setActiveModel(next[0] || null);
|
|
33
|
+
return next;
|
|
34
|
+
}
|
|
35
|
+
if (!modelConfig[name]) {
|
|
36
|
+
const model = models.find((m) => m.name === name);
|
|
37
|
+
const allFields = (model?.fields || []).map((f) => f.name);
|
|
38
|
+
const defaultOrder = ["_id", ...allFields, "createdAt", "updatedAt"];
|
|
39
|
+
setModelConfig((prev) => ({
|
|
40
|
+
...prev,
|
|
41
|
+
[name]: { fields: [...allFields], systemFields: ["_id", "createdAt", "updatedAt"], populatedFields: {}, columnOrder: defaultOrder, headerLabels: {} },
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
if (!activeModel) setActiveModel(name);
|
|
45
|
+
return [...prev, name];
|
|
46
|
+
});
|
|
47
|
+
setPreview(null);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toggleField(modelName, fieldName) {
|
|
51
|
+
setModelConfig((prev) => {
|
|
52
|
+
const mc = prev[modelName] || { fields: [], systemFields: [], populatedFields: {}, columnOrder: [] };
|
|
53
|
+
const removing = mc.fields.includes(fieldName);
|
|
54
|
+
const fields = removing ? mc.fields.filter((f) => f !== fieldName) : [...mc.fields, fieldName];
|
|
55
|
+
const columnOrder = removing ? (mc.columnOrder || []).filter((c) => c !== fieldName) : [...(mc.columnOrder || []), fieldName];
|
|
56
|
+
return { ...prev, [modelName]: { ...mc, fields, columnOrder } };
|
|
57
|
+
});
|
|
58
|
+
setPreview(null);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function toggleAllFields(modelName) {
|
|
62
|
+
const model = models.find((m) => m.name === modelName);
|
|
63
|
+
if (!model) return;
|
|
64
|
+
const allFields = model.fields.map((f) => f.name);
|
|
65
|
+
setModelConfig((prev) => {
|
|
66
|
+
const mc = prev[modelName] || { fields: [], systemFields: [], populatedFields: {}, columnOrder: [] };
|
|
67
|
+
const allSelected = allFields.every((f) => mc.fields.includes(f));
|
|
68
|
+
if (allSelected) {
|
|
69
|
+
const columnOrder = (mc.columnOrder || []).filter((c) => !allFields.includes(c));
|
|
70
|
+
return { ...prev, [modelName]: { ...mc, fields: [], columnOrder } };
|
|
71
|
+
} else {
|
|
72
|
+
const existing = new Set(mc.columnOrder || []);
|
|
73
|
+
const columnOrder = [...(mc.columnOrder || []), ...allFields.filter((f) => !existing.has(f))];
|
|
74
|
+
return { ...prev, [modelName]: { ...mc, fields: [...allFields], columnOrder } };
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
setPreview(null);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function toggleSystemField(modelName, fieldName) {
|
|
81
|
+
setModelConfig((prev) => {
|
|
82
|
+
const mc = prev[modelName] || { fields: [], systemFields: [], populatedFields: {}, columnOrder: [] };
|
|
83
|
+
const removing = (mc.systemFields || []).includes(fieldName);
|
|
84
|
+
const systemFields = removing ? mc.systemFields.filter((f) => f !== fieldName) : [...(mc.systemFields || []), fieldName];
|
|
85
|
+
const columnOrder = removing ? (mc.columnOrder || []).filter((c) => c !== fieldName) : [...(mc.columnOrder || []), fieldName];
|
|
86
|
+
return { ...prev, [modelName]: { ...mc, systemFields, columnOrder } };
|
|
87
|
+
});
|
|
88
|
+
setPreview(null);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function toggleSubField(modelName, refField, subField) {
|
|
92
|
+
const colKey = refField + "." + subField;
|
|
93
|
+
setModelConfig((prev) => {
|
|
94
|
+
const mc = prev[modelName] || { fields: [], systemFields: [], populatedFields: {}, columnOrder: [] };
|
|
95
|
+
const pop = { ...mc.populatedFields };
|
|
96
|
+
const current = pop[refField] || [];
|
|
97
|
+
pop[refField] = current.includes(subField)
|
|
98
|
+
? current.filter((f) => f !== subField)
|
|
99
|
+
: [...current, subField];
|
|
100
|
+
const removing = current.includes(subField);
|
|
101
|
+
const columnOrder = removing ? (mc.columnOrder || []).filter((c) => c !== colKey) : [...(mc.columnOrder || []), colKey];
|
|
102
|
+
return { ...prev, [modelName]: { ...mc, populatedFields: pop, columnOrder } };
|
|
103
|
+
});
|
|
104
|
+
setPreview(null);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function toggleAllSubFields(modelName, refField, allSubFields) {
|
|
108
|
+
setModelConfig((prev) => {
|
|
109
|
+
const mc = prev[modelName] || { fields: [], systemFields: [], populatedFields: {}, columnOrder: [] };
|
|
110
|
+
const pop = { ...mc.populatedFields };
|
|
111
|
+
const current = pop[refField] || [];
|
|
112
|
+
const allSelected = allSubFields.every((f) => current.includes(f));
|
|
113
|
+
const refKeys = allSubFields.map((sf) => refField + "." + sf);
|
|
114
|
+
let columnOrder = [...(mc.columnOrder || [])];
|
|
115
|
+
if (allSelected) {
|
|
116
|
+
pop[refField] = [];
|
|
117
|
+
columnOrder = columnOrder.filter((c) => !refKeys.includes(c));
|
|
118
|
+
} else {
|
|
119
|
+
pop[refField] = [...allSubFields];
|
|
120
|
+
const existing = new Set(columnOrder);
|
|
121
|
+
columnOrder = [...columnOrder, ...refKeys.filter((k) => !existing.has(k))];
|
|
122
|
+
}
|
|
123
|
+
return { ...prev, [modelName]: { ...mc, populatedFields: pop, columnOrder } };
|
|
124
|
+
});
|
|
125
|
+
setPreview(null);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function updateHeaderLabel(modelName, colKey, label) {
|
|
129
|
+
setModelConfig((prev) => {
|
|
130
|
+
const mc = prev[modelName];
|
|
131
|
+
if (!mc) return prev;
|
|
132
|
+
const headerLabels = { ...(mc.headerLabels || {}) };
|
|
133
|
+
if (label && label !== colKey) headerLabels[colKey] = label;
|
|
134
|
+
else delete headerLabels[colKey];
|
|
135
|
+
return { ...prev, [modelName]: { ...mc, headerLabels } };
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function updateFieldPrefix(modelName, colKey, prefix) {
|
|
140
|
+
setModelConfig((prev) => {
|
|
141
|
+
const mc = prev[modelName];
|
|
142
|
+
if (!mc) return prev;
|
|
143
|
+
const fieldPrefixes = { ...(mc.fieldPrefixes || {}) };
|
|
144
|
+
if (prefix) fieldPrefixes[colKey] = prefix;
|
|
145
|
+
else delete fieldPrefixes[colKey];
|
|
146
|
+
return { ...prev, [modelName]: { ...mc, fieldPrefixes } };
|
|
147
|
+
});
|
|
148
|
+
setPreview(null);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function moveColumn(modelName, from, to) {
|
|
152
|
+
setModelConfig((prev) => {
|
|
153
|
+
const mc = prev[modelName];
|
|
154
|
+
if (!mc) return prev;
|
|
155
|
+
const order = [...(mc.columnOrder || [])];
|
|
156
|
+
const [item] = order.splice(from, 1);
|
|
157
|
+
order.splice(to, 0, item);
|
|
158
|
+
return { ...prev, [modelName]: { ...mc, columnOrder: order } };
|
|
159
|
+
});
|
|
160
|
+
setPreview(null);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Drag state
|
|
164
|
+
const dragItem = useRef(null);
|
|
165
|
+
const dragOverItem = useRef(null);
|
|
166
|
+
|
|
167
|
+
function handleDragStart(idx) { dragItem.current = idx; }
|
|
168
|
+
function handleDragEnter(idx) { dragOverItem.current = idx; }
|
|
169
|
+
function handleDragEnd() {
|
|
170
|
+
if (dragItem.current !== null && dragOverItem.current !== null && dragItem.current !== dragOverItem.current) {
|
|
171
|
+
moveColumn(activeModel, dragItem.current, dragOverItem.current);
|
|
172
|
+
}
|
|
173
|
+
dragItem.current = null;
|
|
174
|
+
dragOverItem.current = null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Get ordered columns for active model
|
|
178
|
+
function getOrderedColumns(mc) {
|
|
179
|
+
const order = mc.columnOrder || [];
|
|
180
|
+
const allSelected = new Set([
|
|
181
|
+
...(mc.systemFields || []),
|
|
182
|
+
...mc.fields,
|
|
183
|
+
...Object.entries(mc.populatedFields || {}).flatMap(([ref, subs]) => subs.map((sf) => ref + "." + sf)),
|
|
184
|
+
]);
|
|
185
|
+
return order.filter((c) => allSelected.has(c));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function loadPreview() {
|
|
189
|
+
if (!activeModel || !selectedModels.includes(activeModel)) return;
|
|
190
|
+
const mc = modelConfig[activeModel] || { fields: [], populatedFields: {} };
|
|
191
|
+
if (mc.fields.length === 0) { toast("Select at least one field", "error"); return; }
|
|
192
|
+
setPreviewLoading(true);
|
|
193
|
+
try {
|
|
194
|
+
const res = await api('/reports/preview', {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
body: { name: activeModel, fields: mc.fields, systemFields: mc.systemFields || [], populatedFields: mc.populatedFields, columnOrder: getOrderedColumns(mc), headerLabels: mc.headerLabels || {}, fieldPrefixes: mc.fieldPrefixes || {}, dateFormat, timezone },
|
|
197
|
+
});
|
|
198
|
+
setPreview(res.rows || []);
|
|
199
|
+
} catch { setPreview([]); }
|
|
200
|
+
setPreviewLoading(false);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function getSelectedFieldCount() {
|
|
204
|
+
let count = 0;
|
|
205
|
+
selectedModels.forEach((name) => {
|
|
206
|
+
const mc = modelConfig[name] || { fields: [], systemFields: [], populatedFields: {} };
|
|
207
|
+
count += mc.fields.length + (mc.systemFields || []).length;
|
|
208
|
+
Object.values(mc.populatedFields).forEach((sf) => { count += sf.length; });
|
|
209
|
+
});
|
|
210
|
+
return count;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function handleDownload() {
|
|
214
|
+
if (selectedModels.length === 0) { toast("Select at least one model", "error"); return; }
|
|
215
|
+
setLoading(true);
|
|
216
|
+
try {
|
|
217
|
+
const config = {
|
|
218
|
+
models: selectedModels.map((name) => {
|
|
219
|
+
const mc = modelConfig[name];
|
|
220
|
+
return { name, fields: mc.fields, systemFields: mc.systemFields || [], populatedFields: mc.populatedFields, columnOrder: getOrderedColumns(mc), headerLabels: mc.headerLabels || {}, fieldPrefixes: mc.fieldPrefixes || {} };
|
|
221
|
+
}),
|
|
222
|
+
filename: reportName || "report",
|
|
223
|
+
limit: limit > 0 ? limit : undefined,
|
|
224
|
+
dateFormat,
|
|
225
|
+
timezone,
|
|
226
|
+
};
|
|
227
|
+
const passkey = getStoredPasskey();
|
|
228
|
+
const headers = { "Content-Type": "application/json" };
|
|
229
|
+
if (passkey) headers["x-passkey"] = passkey;
|
|
230
|
+
const res = await fetch(API + "/reports/generate", { method: "POST", headers, body: JSON.stringify(config) });
|
|
231
|
+
if (!res.ok) { const err = await res.json(); throw new Error(err.error || "Download failed"); }
|
|
232
|
+
const blob = await res.blob();
|
|
233
|
+
const url = URL.createObjectURL(blob);
|
|
234
|
+
const a = document.createElement("a");
|
|
235
|
+
a.href = url; a.download = (reportName || "report") + ".xlsx"; a.click();
|
|
236
|
+
URL.revokeObjectURL(url);
|
|
237
|
+
toast("Report downloaded successfully", "success");
|
|
238
|
+
} catch (err) { toast(err.message, "error"); }
|
|
239
|
+
setLoading(false);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const activeModelData = models.find((m) => m.name === activeModel);
|
|
243
|
+
const activeMc = modelConfig[activeModel] || { fields: [], systemFields: [], populatedFields: {} };
|
|
244
|
+
const totalFields = getSelectedFieldCount();
|
|
245
|
+
const refFields = (activeModelData?.fields || []).filter((f) => f.type === "ObjectId" && f.ref && f.refFields);
|
|
246
|
+
|
|
247
|
+
if (schemaLoading) {
|
|
248
|
+
return html`<div style=${{ display: "flex", alignItems: "center", justifyContent: "center", height: "300px", gap: "12px", color: "var(--text-muted)" }}>
|
|
249
|
+
<${LuLoader} size=${20} className="spinner" /> Loading schema information...
|
|
250
|
+
</div>`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (models.length === 0) {
|
|
254
|
+
return html`<div style=${{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "300px", gap: "16px" }}>
|
|
255
|
+
<div style=${{ width: "64px", height: "64px", borderRadius: "16px", background: "var(--bg-surface)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
256
|
+
<${LuFileSpreadsheet} size=${28} color="var(--text-muted)" />
|
|
257
|
+
</div>
|
|
258
|
+
<div style=${{ textAlign: "center" }}>
|
|
259
|
+
<div style=${{ fontSize: "15px", fontWeight: 600, marginBottom: "4px" }}>No models found</div>
|
|
260
|
+
<div style=${{ fontSize: "12px", color: "var(--text-muted)" }}>Create models in the Schemas page to generate reports.</div>
|
|
261
|
+
</div>
|
|
262
|
+
</div>`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return html`<div style=${{ display: "flex", flexDirection: "column", height: "100%", gap: "0" }}>
|
|
266
|
+
<!-- Summary Bar -->
|
|
267
|
+
<div style=${{
|
|
268
|
+
display: "flex", alignItems: "center", gap: "16px", padding: "14px 18px",
|
|
269
|
+
background: "var(--bg-surface)", borderRadius: "10px", border: "1px solid var(--border)", marginBottom: "16px",
|
|
270
|
+
}}>
|
|
271
|
+
<div style=${{ display: "flex", alignItems: "center", gap: "12px", flex: 1 }}>
|
|
272
|
+
<div style=${{ width: "36px", height: "36px", borderRadius: "8px", background: "var(--accent-glow)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
273
|
+
<${LuFileSpreadsheet} size=${18} color="var(--accent-light)" />
|
|
274
|
+
</div>
|
|
275
|
+
<div>
|
|
276
|
+
<div style=${{ display: "flex", alignItems: "center", gap: "8px" }}>
|
|
277
|
+
<input type="text" value=${reportName} onInput=${(e) => setReportName(e.target.value)} placeholder="report"
|
|
278
|
+
style=${{ fontSize: "15px", fontWeight: 600, background: "transparent", border: "none", color: "var(--text)", outline: "none", width: "200px", padding: 0 }} />
|
|
279
|
+
<span style=${{ fontSize: "12px", color: "var(--text-muted)" }}>.xlsx</span>
|
|
280
|
+
</div>
|
|
281
|
+
<div style=${{ fontSize: "11px", color: "var(--text-muted)", marginTop: "2px" }}>
|
|
282
|
+
${selectedModels.length} collection${selectedModels.length !== 1 ? "s" : ""} · ${totalFields} field${totalFields !== 1 ? "s" : ""} selected
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<div style=${{ display: "flex", alignItems: "center", gap: "12px", flexWrap: "wrap" }}>
|
|
288
|
+
<div style=${{ display: "flex", alignItems: "center", gap: "4px" }}>
|
|
289
|
+
<label style=${{ fontSize: "11px", color: "var(--text-muted)" }}>Limit</label>
|
|
290
|
+
<select value=${limit} onChange=${(e) => setLimit(Number(e.target.value))}
|
|
291
|
+
style=${{ padding: "5px 8px", fontSize: "11px", background: "var(--bg-input)", border: "1px solid var(--border)", borderRadius: "5px", color: "var(--text)", outline: "none" }}>
|
|
292
|
+
<option value=${0}>All rows</option>
|
|
293
|
+
<option value=${100}>100</option>
|
|
294
|
+
<option value=${500}>500</option>
|
|
295
|
+
<option value=${1000}>1,000</option>
|
|
296
|
+
<option value=${5000}>5,000</option>
|
|
297
|
+
<option value=${10000}>10,000</option>
|
|
298
|
+
</select>
|
|
299
|
+
</div>
|
|
300
|
+
<div style=${{ display: "flex", alignItems: "center", gap: "4px" }}>
|
|
301
|
+
<label style=${{ fontSize: "11px", color: "var(--text-muted)" }}>Date</label>
|
|
302
|
+
<select value=${dateFormat} onChange=${(e) => { setDateFormat(e.target.value); setPreview(null); }}
|
|
303
|
+
style=${{ padding: "5px 8px", fontSize: "11px", background: "var(--bg-input)", border: "1px solid var(--border)", borderRadius: "5px", color: "var(--text)", outline: "none" }}>
|
|
304
|
+
<option value="YYYY-MM-DD HH:mm:ss">YYYY-MM-DD HH:mm:ss (24h)</option>
|
|
305
|
+
<option value="DD/MM/YYYY HH:mm:ss">DD/MM/YYYY HH:mm:ss (24h)</option>
|
|
306
|
+
<option value="MM/DD/YYYY HH:mm:ss">MM/DD/YYYY HH:mm:ss (24h)</option>
|
|
307
|
+
<option value="YYYY-MM-DD hh:mm:ss A">YYYY-MM-DD hh:mm:ss AM/PM</option>
|
|
308
|
+
<option value="DD/MM/YYYY hh:mm:ss A">DD/MM/YYYY hh:mm:ss AM/PM</option>
|
|
309
|
+
<option value="MM/DD/YYYY hh:mm:ss A">MM/DD/YYYY hh:mm:ss AM/PM</option>
|
|
310
|
+
<option value="YYYY-MM-DD">YYYY-MM-DD</option>
|
|
311
|
+
<option value="DD/MM/YYYY">DD/MM/YYYY</option>
|
|
312
|
+
<option value="MM/DD/YYYY">MM/DD/YYYY</option>
|
|
313
|
+
<option value="ISO">ISO 8601</option>
|
|
314
|
+
<option value="UNIX">Unix Timestamp</option>
|
|
315
|
+
</select>
|
|
316
|
+
</div>
|
|
317
|
+
<div style=${{ display: "flex", alignItems: "center", gap: "4px" }}>
|
|
318
|
+
<label style=${{ fontSize: "11px", color: "var(--text-muted)" }}>TZ</label>
|
|
319
|
+
<select value=${timezone} onChange=${(e) => { setTimezone(e.target.value); setPreview(null); }}
|
|
320
|
+
style=${{ padding: "5px 8px", fontSize: "11px", background: "var(--bg-input)", border: "1px solid var(--border)", borderRadius: "5px", color: "var(--text)", outline: "none", maxWidth: "150px" }}>
|
|
321
|
+
<option value="UTC">UTC</option>
|
|
322
|
+
<option value="Asia/Kolkata">Asia/Kolkata (IST)</option>
|
|
323
|
+
<option value="America/New_York">America/New_York (EST)</option>
|
|
324
|
+
<option value="America/Chicago">America/Chicago (CST)</option>
|
|
325
|
+
<option value="America/Denver">America/Denver (MST)</option>
|
|
326
|
+
<option value="America/Los_Angeles">America/Los_Angeles (PST)</option>
|
|
327
|
+
<option value="Europe/London">Europe/London (GMT)</option>
|
|
328
|
+
<option value="Europe/Paris">Europe/Paris (CET)</option>
|
|
329
|
+
<option value="Europe/Berlin">Europe/Berlin (CET)</option>
|
|
330
|
+
<option value="Asia/Dubai">Asia/Dubai (GST)</option>
|
|
331
|
+
<option value="Asia/Shanghai">Asia/Shanghai (CST)</option>
|
|
332
|
+
<option value="Asia/Tokyo">Asia/Tokyo (JST)</option>
|
|
333
|
+
<option value="Australia/Sydney">Australia/Sydney (AEST)</option>
|
|
334
|
+
<option value=${Intl.DateTimeFormat().resolvedOptions().timeZone}>Local (${Intl.DateTimeFormat().resolvedOptions().timeZone})</option>
|
|
335
|
+
</select>
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
|
|
339
|
+
<button onClick=${handleDownload} disabled=${loading || selectedModels.length === 0}
|
|
340
|
+
style=${{
|
|
341
|
+
padding: "9px 24px", fontSize: "13px", fontWeight: 600, borderRadius: "8px", border: "none", cursor: loading || selectedModels.length === 0 ? "not-allowed" : "pointer",
|
|
342
|
+
background: loading || selectedModels.length === 0 ? "var(--bg-input)" : "linear-gradient(135deg, var(--accent), #7c3aed)",
|
|
343
|
+
color: loading || selectedModels.length === 0 ? "var(--text-muted)" : "#fff",
|
|
344
|
+
display: "flex", alignItems: "center", gap: "8px", transition: "all 0.2s", boxShadow: loading || selectedModels.length === 0 ? "none" : "0 2px 12px rgba(99,102,241,0.3)",
|
|
345
|
+
}}>
|
|
346
|
+
${loading ? html`<${LuLoader} size=${14} className="spinner" />` : html`<${LuDownload} size=${14} />`}
|
|
347
|
+
${loading ? "Generating..." : "Download"}
|
|
348
|
+
</button>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
<!-- Main Layout -->
|
|
352
|
+
<div style=${{ display: "flex", gap: "16px", flex: 1, minHeight: 0 }}>
|
|
353
|
+
<!-- Left Panel: Collections -->
|
|
354
|
+
<div style=${{ width: "260px", flexShrink: 0, display: "flex", flexDirection: "column", gap: "0", background: "var(--bg-surface)", borderRadius: "10px", border: "1px solid var(--border)", overflow: "hidden" }}>
|
|
355
|
+
<div style=${{ padding: "12px 16px", borderBottom: "1px solid var(--border)", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
|
356
|
+
<span style=${{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px", color: "var(--text-muted)" }}>Collections</span>
|
|
357
|
+
<span style=${{ fontSize: "10px", padding: "2px 8px", borderRadius: "10px", background: "var(--accent-glow)", color: "var(--accent-light)", fontWeight: 600 }}>${models.length}</span>
|
|
358
|
+
</div>
|
|
359
|
+
<div style=${{ flex: 1, overflowY: "auto", padding: "6px" }}>
|
|
360
|
+
${models.map((m) => {
|
|
361
|
+
const isSelected = selectedModels.includes(m.name);
|
|
362
|
+
const isActive = activeModel === m.name;
|
|
363
|
+
const mc = modelConfig[m.name] || { fields: [], populatedFields: {} };
|
|
364
|
+
const fieldCount = mc.fields.length + Object.values(mc.populatedFields).reduce((a, b) => a + b.length, 0);
|
|
365
|
+
return html`<div key=${m.name}
|
|
366
|
+
onClick=${() => { if (!isSelected) toggleModel(m.name); setActiveModel(m.name); }}
|
|
367
|
+
style=${{
|
|
368
|
+
display: "flex", alignItems: "center", gap: "10px", padding: "10px 12px", borderRadius: "8px", cursor: "pointer",
|
|
369
|
+
background: isActive ? "rgba(99,102,241,0.1)" : "transparent",
|
|
370
|
+
border: isActive ? "1px solid rgba(99,102,241,0.2)" : "1px solid transparent",
|
|
371
|
+
transition: "all 0.15s", marginBottom: "2px",
|
|
372
|
+
}}>
|
|
373
|
+
<div onClick=${(e) => { e.stopPropagation(); toggleModel(m.name); }}
|
|
374
|
+
style=${{
|
|
375
|
+
width: "18px", height: "18px", borderRadius: "4px", flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer",
|
|
376
|
+
background: isSelected ? "var(--accent)" : "transparent",
|
|
377
|
+
border: isSelected ? "none" : "1.5px solid var(--border)", transition: "all 0.15s",
|
|
378
|
+
}}>
|
|
379
|
+
${isSelected && html`<${LuCheck} size=${12} color="#fff" />`}
|
|
380
|
+
</div>
|
|
381
|
+
<div style=${{ flex: 1, minWidth: 0 }}>
|
|
382
|
+
<div style=${{ fontSize: "13px", fontWeight: isSelected ? 600 : 400, color: isSelected ? "var(--text)" : "var(--text-muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>${m.name}</div>
|
|
383
|
+
<div style=${{ fontSize: "10px", color: "var(--text-muted)" }}>${m.fields.length} fields</div>
|
|
384
|
+
</div>
|
|
385
|
+
${isSelected && html`<span style=${{ fontSize: "10px", padding: "2px 6px", borderRadius: "4px", background: "var(--success-glow)", color: "var(--success)", fontWeight: 600 }}>${fieldCount}</span>`}
|
|
386
|
+
${isActive && html`<${LuChevronRight} size=${12} color="var(--text-muted)" />`}
|
|
387
|
+
</div>`;
|
|
388
|
+
})}
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
|
|
392
|
+
<!-- Right Panel: Field Config -->
|
|
393
|
+
<div style=${{ flex: 1, display: "flex", flexDirection: "column", background: "var(--bg-surface)", borderRadius: "10px", border: "1px solid var(--border)", overflow: "hidden" }}>
|
|
394
|
+
${!activeModel || !selectedModels.includes(activeModel)
|
|
395
|
+
? html`<div style=${{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", gap: "12px", color: "var(--text-muted)" }}>
|
|
396
|
+
<${LuDatabase} size=${32} style=${{ opacity: 0.3 }} />
|
|
397
|
+
<span style=${{ fontSize: "13px" }}>Select a collection to configure fields</span>
|
|
398
|
+
</div>`
|
|
399
|
+
: html`<${React.Fragment}>
|
|
400
|
+
<!-- Header -->
|
|
401
|
+
<div style=${{ padding: "14px 18px", borderBottom: "1px solid var(--border)", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
|
402
|
+
<div style=${{ display: "flex", alignItems: "center", gap: "10px" }}>
|
|
403
|
+
<div style=${{ width: "28px", height: "28px", borderRadius: "6px", background: "var(--accent-glow)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
404
|
+
<${LuDatabase} size=${14} color="var(--accent-light)" />
|
|
405
|
+
</div>
|
|
406
|
+
<div>
|
|
407
|
+
<div style=${{ fontSize: "14px", fontWeight: 600 }}>${activeModel}</div>
|
|
408
|
+
<div style=${{ fontSize: "10px", color: "var(--text-muted)" }}>${activeMc.fields.length} of ${activeModelData?.fields?.length || 0} fields selected</div>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
<div style=${{ display: "flex", gap: "6px" }}>
|
|
412
|
+
${preview !== null
|
|
413
|
+
? html`<button onClick=${() => setPreview(null)}
|
|
414
|
+
style=${{ padding: "5px 12px", fontSize: "11px", borderRadius: "6px", border: "1px solid var(--border)", background: "transparent", color: "var(--accent)", cursor: "pointer" }}>
|
|
415
|
+
Configure Fields
|
|
416
|
+
</button>`
|
|
417
|
+
: html`<${React.Fragment}>
|
|
418
|
+
<button onClick=${() => loadPreview()}
|
|
419
|
+
style=${{ padding: "5px 12px", fontSize: "11px", borderRadius: "6px", border: "1px solid var(--accent)", background: "var(--accent-glow)", color: "var(--accent-light)", cursor: "pointer" }}>
|
|
420
|
+
${previewLoading ? "Loading..." : "Preview"}
|
|
421
|
+
</button>
|
|
422
|
+
<button onClick=${() => toggleAllFields(activeModel)}
|
|
423
|
+
style=${{ padding: "5px 12px", fontSize: "11px", borderRadius: "6px", border: "1px solid var(--border)", background: "transparent", color: "var(--accent)", cursor: "pointer" }}>
|
|
424
|
+
${activeModelData && activeModelData.fields.every((f) => activeMc.fields.includes(f.name)) ? "Deselect All" : "Select All"}
|
|
425
|
+
</button>
|
|
426
|
+
<//>`}
|
|
427
|
+
</div>
|
|
428
|
+
</div>
|
|
429
|
+
|
|
430
|
+
<div style=${{ flex: 1, overflowY: "auto", padding: "16px 18px" }}>
|
|
431
|
+
${preview === null ? html`<${React.Fragment}>
|
|
432
|
+
<!-- Fields -->
|
|
433
|
+
<div style=${{ fontSize: "10px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.8px", color: "var(--text-muted)", marginBottom: "10px" }}>Model Fields</div>
|
|
434
|
+
<div style=${{ display: "flex", flexWrap: "wrap", gap: "8px", marginBottom: "16px" }}>
|
|
435
|
+
${(activeModelData?.fields || []).map((field) => {
|
|
436
|
+
const active = activeMc.fields.includes(field.name);
|
|
437
|
+
const isRef = field.type === "ObjectId" && field.ref;
|
|
438
|
+
return html`<button key=${field.name}
|
|
439
|
+
onClick=${() => toggleField(activeModel, field.name)}
|
|
440
|
+
style=${{
|
|
441
|
+
padding: "6px 14px", borderRadius: "8px", fontSize: "12px", cursor: "pointer", display: "flex", alignItems: "center", gap: "6px",
|
|
442
|
+
border: active ? "1.5px solid var(--accent)" : "1.5px solid var(--border)",
|
|
443
|
+
background: active ? "var(--accent-glow)" : "var(--bg-input)",
|
|
444
|
+
color: active ? "var(--accent-light)" : "var(--text-secondary)",
|
|
445
|
+
fontWeight: active ? 600 : 400, transition: "all 0.15s",
|
|
446
|
+
}}>
|
|
447
|
+
${active && html`<${LuCheck} size=${10} />`}
|
|
448
|
+
${field.name}
|
|
449
|
+
<span style=${{ fontSize: "9px", padding: "1px 5px", borderRadius: "3px", background: "rgba(255,255,255,0.05)", color: isRef ? "#f472b6" : "var(--text-muted)" }}>
|
|
450
|
+
${field.type}${isRef ? " → " + field.ref : ""}
|
|
451
|
+
</span>
|
|
452
|
+
</button>`;
|
|
453
|
+
})}
|
|
454
|
+
</div>
|
|
455
|
+
|
|
456
|
+
<!-- System Fields -->
|
|
457
|
+
<div style=${{ fontSize: "10px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.8px", color: "var(--text-muted)", marginBottom: "10px" }}>System Fields</div>
|
|
458
|
+
<div style=${{ display: "flex", flexWrap: "wrap", gap: "8px", marginBottom: "16px" }}>
|
|
459
|
+
${["_id", "createdAt", "updatedAt"].map((sf) => {
|
|
460
|
+
const active = (activeMc.systemFields || []).includes(sf);
|
|
461
|
+
const isDate = sf === "createdAt" || sf === "updatedAt";
|
|
462
|
+
return html`<button key=${sf}
|
|
463
|
+
onClick=${() => toggleSystemField(activeModel, sf)}
|
|
464
|
+
style=${{
|
|
465
|
+
padding: "6px 14px", borderRadius: "8px", fontSize: "12px", cursor: "pointer", display: "flex", alignItems: "center", gap: "6px",
|
|
466
|
+
border: active ? "1.5px solid #a78bfa" : "1.5px solid var(--border)",
|
|
467
|
+
background: active ? "rgba(167,139,250,0.1)" : "var(--bg-input)",
|
|
468
|
+
color: active ? "#a78bfa" : "var(--text-secondary)",
|
|
469
|
+
fontWeight: active ? 600 : 400, transition: "all 0.15s",
|
|
470
|
+
}}>
|
|
471
|
+
${active && html`<${LuCheck} size=${10} />`}
|
|
472
|
+
${sf}
|
|
473
|
+
<span style=${{ fontSize: "9px", padding: "1px 5px", borderRadius: "3px", background: "rgba(255,255,255,0.05)", color: isDate ? "#a78bfa" : "var(--text-muted)" }}>
|
|
474
|
+
${isDate ? "Date" : "ObjectId"}
|
|
475
|
+
</span>
|
|
476
|
+
</button>`;
|
|
477
|
+
})}
|
|
478
|
+
</div>
|
|
479
|
+
|
|
480
|
+
<!-- Column Order (drag & drop + rename headers) -->
|
|
481
|
+
${(() => {
|
|
482
|
+
const orderedCols = getOrderedColumns(activeMc);
|
|
483
|
+
if (orderedCols.length === 0) return null;
|
|
484
|
+
const sysSet = new Set(activeMc.systemFields || []);
|
|
485
|
+
const fieldSet = new Set(activeMc.fields || []);
|
|
486
|
+
const labels = activeMc.headerLabels || {};
|
|
487
|
+
return html`<div>
|
|
488
|
+
<div style=${{ fontSize: "10px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.8px", color: "var(--text-muted)", marginBottom: "10px", display: "flex", alignItems: "center", gap: "6px" }}>
|
|
489
|
+
<${LuGripVertical} size=${10} /> Column Order & Headers
|
|
490
|
+
<span style=${{ fontWeight: 400, textTransform: "none", letterSpacing: 0 }}>(drag to reorder, edit header names for excel)</span>
|
|
491
|
+
</div>
|
|
492
|
+
<div style=${{ background: "var(--bg)", borderRadius: "8px", border: "1px solid var(--border)", overflow: "hidden" }}>
|
|
493
|
+
<!-- Table header -->
|
|
494
|
+
<div style=${{ display: "flex", alignItems: "center", gap: "10px", padding: "6px 12px", borderBottom: "1px solid var(--border)", background: "rgba(255,255,255,0.02)" }}>
|
|
495
|
+
<span style=${{ width: "12px" }} />
|
|
496
|
+
<span style=${{ fontSize: "9px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px", color: "var(--text-muted)", width: "20px", textAlign: "center" }}>#</span>
|
|
497
|
+
<span style=${{ fontSize: "9px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px", color: "var(--text-muted)", width: "140px" }}>Field Key</span>
|
|
498
|
+
<span style=${{ fontSize: "9px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px", color: "var(--text-muted)", flex: 1 }}>Excel Header</span>
|
|
499
|
+
<span style=${{ fontSize: "9px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px", color: "var(--text-muted)", width: "160px" }}>Prefix</span>
|
|
500
|
+
<span style=${{ fontSize: "9px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px", color: "var(--text-muted)", width: "40px" }}>Type</span>
|
|
501
|
+
<span style=${{ width: "40px" }} />
|
|
502
|
+
</div>
|
|
503
|
+
${orderedCols.map((col, idx) => {
|
|
504
|
+
const isSys = sysSet.has(col);
|
|
505
|
+
const isRef = col.includes(".");
|
|
506
|
+
const color = isSys ? "#a78bfa" : isRef ? "var(--success)" : "var(--accent-light)";
|
|
507
|
+
const tag = isSys ? (col === "_id" ? "ID" : "SYS") : isRef ? "REF" : "FLD";
|
|
508
|
+
const customLabel = labels[col] || "";
|
|
509
|
+
const prefixes = activeMc.fieldPrefixes || {};
|
|
510
|
+
const customPrefix = prefixes[col] || "";
|
|
511
|
+
return html`<div key=${col}
|
|
512
|
+
draggable=${true}
|
|
513
|
+
onDragStart=${() => handleDragStart(idx)}
|
|
514
|
+
onDragEnter=${() => handleDragEnter(idx)}
|
|
515
|
+
onDragEnd=${handleDragEnd}
|
|
516
|
+
onDragOver=${(e) => e.preventDefault()}
|
|
517
|
+
style=${{
|
|
518
|
+
display: "flex", alignItems: "center", gap: "10px", padding: "7px 12px",
|
|
519
|
+
borderBottom: idx < orderedCols.length - 1 ? "1px solid var(--border)" : "none",
|
|
520
|
+
cursor: "grab", transition: "background 0.1s", userSelect: "none",
|
|
521
|
+
}}
|
|
522
|
+
onMouseOver=${(e) => e.currentTarget.style.background = "rgba(255,255,255,0.02)"}
|
|
523
|
+
onMouseOut=${(e) => e.currentTarget.style.background = "transparent"}>
|
|
524
|
+
<${LuGripVertical} size=${12} color="var(--text-muted)" style=${{ opacity: 0.3, flexShrink: 0 }} />
|
|
525
|
+
<span style=${{ fontSize: "11px", color: "var(--text-muted)", width: "20px", textAlign: "center", flexShrink: 0 }}>${idx + 1}</span>
|
|
526
|
+
<span style=${{ fontSize: "11px", fontWeight: 500, fontFamily: "'SF Mono', monospace", color, width: "140px", flexShrink: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>${col}</span>
|
|
527
|
+
<input
|
|
528
|
+
type="text"
|
|
529
|
+
value=${customLabel}
|
|
530
|
+
placeholder=${col}
|
|
531
|
+
onClick=${(e) => e.stopPropagation()}
|
|
532
|
+
onMouseDown=${(e) => e.stopPropagation()}
|
|
533
|
+
onInput=${(e) => updateHeaderLabel(activeModel, col, e.target.value)}
|
|
534
|
+
draggable=${false}
|
|
535
|
+
style=${{
|
|
536
|
+
flex: 1, padding: "3px 8px", fontSize: "11px", borderRadius: "4px",
|
|
537
|
+
border: customLabel ? "1px solid var(--accent)" : "1px solid var(--border)",
|
|
538
|
+
background: customLabel ? "var(--accent-glow)" : "transparent",
|
|
539
|
+
color: customLabel ? "var(--accent-light)" : "var(--text-muted)",
|
|
540
|
+
outline: "none", fontFamily: "inherit", minWidth: 0,
|
|
541
|
+
}}
|
|
542
|
+
/>
|
|
543
|
+
<input
|
|
544
|
+
type="text"
|
|
545
|
+
value=${customPrefix}
|
|
546
|
+
placeholder="prefix..."
|
|
547
|
+
onClick=${(e) => e.stopPropagation()}
|
|
548
|
+
onMouseDown=${(e) => e.stopPropagation()}
|
|
549
|
+
onInput=${(e) => updateFieldPrefix(activeModel, col, e.target.value)}
|
|
550
|
+
draggable=${false}
|
|
551
|
+
style=${{
|
|
552
|
+
width: "160px", flexShrink: 0, padding: "3px 8px", fontSize: "11px", borderRadius: "4px",
|
|
553
|
+
border: customPrefix ? "1px solid var(--warning)" : "1px solid var(--border)",
|
|
554
|
+
background: customPrefix ? "rgba(245,158,11,0.08)" : "transparent",
|
|
555
|
+
color: customPrefix ? "var(--warning)" : "var(--text-muted)",
|
|
556
|
+
outline: "none", fontFamily: "'SF Mono', monospace", minWidth: 0,
|
|
557
|
+
}}
|
|
558
|
+
/>
|
|
559
|
+
<span style=${{ fontSize: "9px", padding: "1px 5px", borderRadius: "3px", background: "rgba(255,255,255,0.04)", color: "var(--text-muted)", fontWeight: 600, width: "40px", textAlign: "center", flexShrink: 0 }}>${tag}</span>
|
|
560
|
+
<div style=${{ display: "flex", gap: "1px", flexShrink: 0, width: "40px" }}>
|
|
561
|
+
<button onClick=${(e) => { e.stopPropagation(); if (idx > 0) moveColumn(activeModel, idx, idx - 1); }}
|
|
562
|
+
disabled=${idx === 0}
|
|
563
|
+
style=${{ background: "none", border: "none", cursor: idx === 0 ? "default" : "pointer", padding: "2px", opacity: idx === 0 ? 0.15 : 0.5, color: "var(--text-muted)" }}>
|
|
564
|
+
<${LuArrowUp} size=${11} />
|
|
565
|
+
</button>
|
|
566
|
+
<button onClick=${(e) => { e.stopPropagation(); if (idx < orderedCols.length - 1) moveColumn(activeModel, idx, idx + 1); }}
|
|
567
|
+
disabled=${idx === orderedCols.length - 1}
|
|
568
|
+
style=${{ background: "none", border: "none", cursor: idx === orderedCols.length - 1 ? "default" : "pointer", padding: "2px", opacity: idx === orderedCols.length - 1 ? 0.15 : 0.5, color: "var(--text-muted)" }}>
|
|
569
|
+
<${LuArrowDown} size=${11} />
|
|
570
|
+
</button>
|
|
571
|
+
</div>
|
|
572
|
+
</div>`;
|
|
573
|
+
})}
|
|
574
|
+
</div>
|
|
575
|
+
</div>`;
|
|
576
|
+
})()}
|
|
577
|
+
|
|
578
|
+
<!-- Referenced Model Fields -->
|
|
579
|
+
${refFields.length > 0 && html`<div>
|
|
580
|
+
<div style=${{ fontSize: "10px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.8px", color: "var(--text-muted)", marginBottom: "10px", display: "flex", alignItems: "center", gap: "6px" }}>
|
|
581
|
+
<${LuLink} size=${10} /> Referenced Fields (Populate)
|
|
582
|
+
</div>
|
|
583
|
+
${refFields.map((field) => {
|
|
584
|
+
const subSelected = activeMc.populatedFields[field.name] || [];
|
|
585
|
+
const allSelected = subSelected.length === field.refFields.length;
|
|
586
|
+
return html`<div key=${"ref-" + field.name}
|
|
587
|
+
style=${{ marginBottom: "12px", background: "var(--bg)", borderRadius: "8px", border: "1px solid var(--border)", overflow: "hidden" }}>
|
|
588
|
+
<div style=${{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 14px", borderBottom: subSelected.length > 0 || true ? "1px solid var(--border)" : "none" }}>
|
|
589
|
+
<div style=${{ display: "flex", alignItems: "center", gap: "8px" }}>
|
|
590
|
+
<span style=${{ fontSize: "12px", fontWeight: 600 }}>${field.name}</span>
|
|
591
|
+
<span style=${{ fontSize: "10px", color: "var(--text-muted)" }}>→</span>
|
|
592
|
+
<span style=${{ fontSize: "11px", padding: "2px 8px", borderRadius: "4px", background: "rgba(244,114,182,0.1)", color: "#f472b6", fontWeight: 500 }}>${field.ref}</span>
|
|
593
|
+
${subSelected.length > 0 && html`<span style=${{ fontSize: "10px", padding: "1px 6px", borderRadius: "4px", background: "var(--success-glow)", color: "var(--success)", fontWeight: 600 }}>${subSelected.length}</span>`}
|
|
594
|
+
</div>
|
|
595
|
+
<button onClick=${() => toggleAllSubFields(activeModel, field.name, field.refFields)}
|
|
596
|
+
style=${{ padding: "3px 10px", fontSize: "10px", borderRadius: "4px", border: "1px solid var(--border)", background: "transparent", color: allSelected ? "var(--text-muted)" : "var(--success)", cursor: "pointer" }}>
|
|
597
|
+
${allSelected ? "Clear" : "Select All"}
|
|
598
|
+
</button>
|
|
599
|
+
</div>
|
|
600
|
+
<div style=${{ display: "flex", flexWrap: "wrap", gap: "6px", padding: "10px 14px" }}>
|
|
601
|
+
${field.refFields.map((sf) => {
|
|
602
|
+
const active = subSelected.includes(sf);
|
|
603
|
+
return html`<button key=${sf}
|
|
604
|
+
onClick=${() => toggleSubField(activeModel, field.name, sf)}
|
|
605
|
+
style=${{
|
|
606
|
+
padding: "5px 12px", borderRadius: "6px", fontSize: "11px", cursor: "pointer", display: "flex", alignItems: "center", gap: "5px",
|
|
607
|
+
border: active ? "1.5px solid var(--success)" : "1.5px solid var(--border)",
|
|
608
|
+
background: active ? "rgba(52,211,153,0.08)" : "transparent",
|
|
609
|
+
color: active ? "var(--success)" : "var(--text-muted)",
|
|
610
|
+
fontWeight: active ? 600 : 400, transition: "all 0.15s",
|
|
611
|
+
}}>
|
|
612
|
+
${active && html`<${LuCheck} size=${9} />`}
|
|
613
|
+
${field.name}.${sf}
|
|
614
|
+
</button>`;
|
|
615
|
+
})}
|
|
616
|
+
</div>
|
|
617
|
+
</div>`;
|
|
618
|
+
})}
|
|
619
|
+
</div>`}
|
|
620
|
+
|
|
621
|
+
<//>` : null}
|
|
622
|
+
<!-- Preview Table -->
|
|
623
|
+
${preview !== null && (() => {
|
|
624
|
+
const previewCols = getOrderedColumns(activeMc);
|
|
625
|
+
const previewLabels = activeMc.headerLabels || {};
|
|
626
|
+
return html`<div>
|
|
627
|
+
<div style=${{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "12px" }}>
|
|
628
|
+
<div style=${{ fontSize: "10px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.8px", color: "var(--text-muted)" }}>
|
|
629
|
+
Preview (${preview.length} rows · ${previewCols.length} columns)
|
|
630
|
+
</div>
|
|
631
|
+
<button onClick=${() => setPreview(null)}
|
|
632
|
+
style=${{ background: "none", border: "none", color: "var(--text-muted)", cursor: "pointer", padding: "2px" }}>
|
|
633
|
+
<${LuX} size=${12} />
|
|
634
|
+
</button>
|
|
635
|
+
</div>
|
|
636
|
+
${preview.length === 0
|
|
637
|
+
? html`<div style=${{ padding: "20px", textAlign: "center", color: "var(--text-muted)", fontSize: "12px", background: "var(--bg)", borderRadius: "6px" }}>No data in this collection</div>`
|
|
638
|
+
: html`<div style=${{ overflowX: "auto", borderRadius: "6px", border: "1px solid var(--border)", overflowY: "auto" }}>
|
|
639
|
+
<table style=${{ minWidth: Math.max(previewCols.length * 130, 400) + "px", fontSize: "11px", borderCollapse: "collapse" }}>
|
|
640
|
+
<thead><tr>
|
|
641
|
+
${previewCols.map((f) => {
|
|
642
|
+
const isRef = f.includes(".");
|
|
643
|
+
const label = previewLabels[f] || f;
|
|
644
|
+
return html`<th key=${f} style=${{ padding: "8px 12px", textAlign: "left", fontWeight: 600, color: isRef ? "#f472b6" : "var(--text-muted)", borderBottom: "1px solid var(--border)", background: "var(--bg)", whiteSpace: "nowrap", fontSize: "10px", textTransform: "uppercase", letterSpacing: "0.3px", position: "sticky", top: 0, zIndex: 1 }}>${label}</th>`;
|
|
645
|
+
})}
|
|
646
|
+
</tr></thead>
|
|
647
|
+
<tbody>
|
|
648
|
+
${preview.map((row, i) => html`<tr key=${i}>
|
|
649
|
+
${previewCols.map((f) => {
|
|
650
|
+
const val = row[f];
|
|
651
|
+
let display = val === null || val === undefined ? "—" : typeof val === "object" ? JSON.stringify(val) : String(val);
|
|
652
|
+
if (display.length > 40) display = display.slice(0, 37) + "...";
|
|
653
|
+
const isRef = f.includes(".");
|
|
654
|
+
return html`<td key=${f} style=${{ padding: "6px 12px", borderBottom: "1px solid var(--border)", color: val === null || val === undefined ? "var(--text-muted)" : isRef ? "var(--success)" : "var(--text-secondary)", whiteSpace: "nowrap", fontFamily: "'SF Mono', monospace", fontSize: "11px", maxWidth: "200px", overflow: "hidden", textOverflow: "ellipsis" }}>${display}</td>`;
|
|
655
|
+
})}
|
|
656
|
+
</tr>`)}
|
|
657
|
+
</tbody>
|
|
658
|
+
</table>
|
|
659
|
+
</div>`}
|
|
660
|
+
</div>`;
|
|
661
|
+
})()}
|
|
662
|
+
</div>
|
|
663
|
+
<//>`}
|
|
664
|
+
</div>
|
|
665
|
+
</div>
|
|
666
|
+
</div>`;
|
|
667
|
+
}
|