trackhome-react 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +59 -474
- package/dist/index.js.map +1 -1
- package/dist/widgets/categorical.d.ts +6 -0
- package/dist/widgets/categorical.d.ts.map +1 -0
- package/dist/widgets/categorical.js +37 -0
- package/dist/widgets/categorical.js.map +1 -0
- package/dist/widgets/common.d.ts +42 -0
- package/dist/widgets/common.d.ts.map +1 -0
- package/dist/widgets/common.js +39 -0
- package/dist/widgets/common.js.map +1 -0
- package/dist/widgets/funnel.d.ts +3 -0
- package/dist/widgets/funnel.d.ts.map +1 -0
- package/dist/widgets/funnel.js +83 -0
- package/dist/widgets/funnel.js.map +1 -0
- package/dist/widgets/index.d.ts +4 -0
- package/dist/widgets/index.d.ts.map +1 -0
- package/dist/widgets/index.js +4 -0
- package/dist/widgets/index.js.map +1 -0
- package/dist/widgets/inputs.d.ts +32 -0
- package/dist/widgets/inputs.d.ts.map +1 -0
- package/dist/widgets/inputs.js +312 -0
- package/dist/widgets/inputs.js.map +1 -0
- package/dist/widgets/metric.d.ts +3 -0
- package/dist/widgets/metric.d.ts.map +1 -0
- package/dist/widgets/metric.js +15 -0
- package/dist/widgets/metric.js.map +1 -0
- package/dist/widgets/registry.d.ts +26 -0
- package/dist/widgets/registry.d.ts.map +1 -0
- package/dist/widgets/registry.js +53 -0
- package/dist/widgets/registry.js.map +1 -0
- package/dist/widgets/timeseries.d.ts +4 -0
- package/dist/widgets/timeseries.d.ts.map +1 -0
- package/dist/widgets/timeseries.js +34 -0
- package/dist/widgets/timeseries.js.map +1 -0
- package/dist/widgets/types.d.ts +87 -0
- package/dist/widgets/types.d.ts.map +1 -0
- package/dist/widgets/types.js +18 -0
- package/dist/widgets/types.js.map +1 -0
- package/package.json +2 -7
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
7
7
|
* import { TrackhomeDashboard } from 'trackhome-react';
|
|
8
8
|
*
|
|
9
9
|
* <TrackhomeDashboard
|
|
10
|
-
* endpoint="
|
|
10
|
+
* endpoint="YOUR_TRACKHOME_URL"
|
|
11
11
|
* authKey="ek_abc123..."
|
|
12
12
|
* uid="my-sales-dashboard"
|
|
13
13
|
* />
|
|
@@ -18,47 +18,15 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
18
18
|
* (add / remove / edit / reorder via drag / resize) that saves back to
|
|
19
19
|
* the server via PATCH /e/<authKey>/<uid>.
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* The component is a thin shell: each widget kind is a self-contained module
|
|
22
|
+
* in ./widgets registered into `uiRegistry` (metadata + renderer + edit form).
|
|
23
|
+
* This file owns only the grid / drag / resize / autosave chrome and a generic
|
|
24
|
+
* editor that delegates per-kind fields to `uiRegistry[kind].EditForm`.
|
|
25
|
+
*
|
|
26
|
+
* Zero runtime dependencies beyond React itself.
|
|
23
27
|
*/
|
|
24
28
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
25
|
-
|
|
26
|
-
// Constants
|
|
27
|
-
// ============================================================
|
|
28
|
-
/** Width range in 12-column grid units. */
|
|
29
|
-
const MIN_W = 2;
|
|
30
|
-
const MAX_W = 12;
|
|
31
|
-
const DEFAULT_W = 6;
|
|
32
|
-
const WIDGET_KINDS = [
|
|
33
|
-
{ value: 'metric', label: 'Metric', hint: 'Single big number (count over last N minutes)' },
|
|
34
|
-
{ value: 'timeseries', label: 'Time series', hint: 'Line chart over time' },
|
|
35
|
-
{ value: 'area', label: 'Area', hint: 'Filled area chart over time' },
|
|
36
|
-
{ value: 'bar', label: 'Bar', hint: 'Vertical categorical bars' },
|
|
37
|
-
{ value: 'pie', label: 'Pie', hint: 'Donut share-of-total' },
|
|
38
|
-
{ value: 'top_events', label: 'Top events', hint: 'Ranked event-type list' },
|
|
39
|
-
{ value: 'top_tags', label: 'Top tags', hint: 'Ranked tag-value list (needs prefix)' },
|
|
40
|
-
{ value: 'funnel', label: 'Funnel', hint: 'Funnel waterfall (needs funnel slug)' },
|
|
41
|
-
];
|
|
42
|
-
function clampW(n) {
|
|
43
|
-
return Math.max(MIN_W, Math.min(MAX_W, Math.round(n)));
|
|
44
|
-
}
|
|
45
|
-
function newId() {
|
|
46
|
-
// RFC4122-ish, sufficient for client-side widget ids.
|
|
47
|
-
return 'w_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4);
|
|
48
|
-
}
|
|
49
|
-
function defaultWidget(kind, partial) {
|
|
50
|
-
return {
|
|
51
|
-
id: newId(),
|
|
52
|
-
title: 'New widget',
|
|
53
|
-
kind,
|
|
54
|
-
tags: [],
|
|
55
|
-
bucket: '1 day',
|
|
56
|
-
metric: 'count',
|
|
57
|
-
minutes: 5,
|
|
58
|
-
layout: { x: 0, y: 0, w: DEFAULT_W, h: 4 },
|
|
59
|
-
...partial,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
29
|
+
import { MIN_W, MAX_W, DEFAULT_W, ACCENT, clampW, newId, uiRegistry, WIDGET_KINDS, defaultWidget, normalizeWidget, TagsAutosuggestInput, } from './widgets/index.js';
|
|
62
30
|
// ============================================================
|
|
63
31
|
// API client
|
|
64
32
|
// ============================================================
|
|
@@ -79,75 +47,19 @@ function defaultRange() {
|
|
|
79
47
|
const from = new Date(to.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
80
48
|
return { from: from.toISOString().slice(0, 10), to: to.toISOString().slice(0, 10) };
|
|
81
49
|
}
|
|
82
|
-
// ============================================================
|
|
83
|
-
// Widget renderers (self-contained, no external deps)
|
|
84
|
-
// ============================================================
|
|
85
|
-
function WidgetBody({ widget, data, theme, }) {
|
|
86
|
-
const accent = '#4f46e5';
|
|
87
|
-
const subtext = theme === 'dark' ? '#94a3b8' : '#64748b';
|
|
88
|
-
switch (widget.kind) {
|
|
89
|
-
case 'metric': {
|
|
90
|
-
const d = data;
|
|
91
|
-
const count = d?.count ?? 0;
|
|
92
|
-
return _jsx("div", { style: { fontSize: 28, fontWeight: 700, color: accent }, children: count.toLocaleString() });
|
|
93
|
-
}
|
|
94
|
-
case 'timeseries':
|
|
95
|
-
case 'area': {
|
|
96
|
-
const d = data ?? [];
|
|
97
|
-
if (d.length === 0)
|
|
98
|
-
return _jsx("div", { style: { fontSize: 12, color: subtext }, children: "No data." });
|
|
99
|
-
const max = Math.max(...d.map((p) => p.count), 1);
|
|
100
|
-
return (_jsx("div", { style: { display: 'flex', alignItems: 'flex-end', gap: 2, height: 80 }, children: d.slice(-30).map((p, i) => (_jsx("div", { title: `${p.ts.slice(0, 10)}: ${p.count}`, style: {
|
|
101
|
-
flex: 1,
|
|
102
|
-
height: `${(p.count / max) * 100}%`,
|
|
103
|
-
minHeight: 2,
|
|
104
|
-
background: accent,
|
|
105
|
-
opacity: widget.kind === 'area' ? 0.6 : 1,
|
|
106
|
-
borderRadius: '2px 2px 0 0',
|
|
107
|
-
} }, i))) }));
|
|
108
|
-
}
|
|
109
|
-
case 'bar':
|
|
110
|
-
case 'pie':
|
|
111
|
-
case 'top_events':
|
|
112
|
-
case 'top_tags': {
|
|
113
|
-
const d = data ?? [];
|
|
114
|
-
if (d.length === 0)
|
|
115
|
-
return _jsx("div", { style: { fontSize: 12, color: subtext }, children: "No data." });
|
|
116
|
-
const max = Math.max(...d.map((x) => x.count), 1);
|
|
117
|
-
return (_jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: 4 }, children: d.map((x, i) => {
|
|
118
|
-
const label = x.type ?? x.tag ?? '—';
|
|
119
|
-
return (_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 8 }, children: [_jsx("div", { style: { fontSize: 11, color: subtext, width: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: 'monospace' }, children: label }), _jsx("div", { style: { flex: 1, height: 8, background: theme === 'dark' ? '#334155' : '#f1f5f9', borderRadius: 4, overflow: 'hidden' }, children: _jsx("div", { style: { width: `${(x.count / max) * 100}%`, height: '100%', background: accent, borderRadius: 4 } }) }), _jsx("div", { style: { fontSize: 11, color: subtext, minWidth: 40, textAlign: 'right' }, children: x.count.toLocaleString() })] }, i));
|
|
120
|
-
}) }));
|
|
121
|
-
}
|
|
122
|
-
case 'funnel': {
|
|
123
|
-
const d = data ?? {};
|
|
124
|
-
const steps = d.steps ?? [];
|
|
125
|
-
if (steps.length === 0)
|
|
126
|
-
return _jsx("div", { style: { fontSize: 12, color: subtext }, children: "No funnel data." });
|
|
127
|
-
const max = Math.max(...steps.map((s) => s.count), 1);
|
|
128
|
-
return (_jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: 4 }, children: steps.map((s, i) => (_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 8 }, children: [_jsx("div", { style: { fontSize: 11, color: subtext, width: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: 'monospace' }, children: s.type }), _jsx("div", { style: { flex: 1, height: 10, background: theme === 'dark' ? '#334155' : '#f1f5f9', borderRadius: 4, overflow: 'hidden' }, children: _jsx("div", { style: { width: `${(s.count / max) * 100}%`, height: '100%', background: accent, borderRadius: 4 } }) }), _jsx("div", { style: { fontSize: 11, color: subtext, minWidth: 50, textAlign: 'right' }, children: s.count.toLocaleString() })] }, i))) }));
|
|
129
|
-
}
|
|
130
|
-
default:
|
|
131
|
-
return null;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
50
|
function EditableWidgetCard({ widget, data, theme, editMode, index, total, dragIndex, dragOverIndex, onDragStart, onDragOver, onDragEnd, onDrop, onEdit, onDuplicate, onRemove, onResize, onMove, }) {
|
|
135
51
|
const bg = theme === 'dark' ? '#1e293b' : '#ffffff';
|
|
136
52
|
const text = theme === 'dark' ? '#f1f5f9' : '#1e293b';
|
|
137
53
|
const border = theme === 'dark' ? '#334155' : '#e2e8f0';
|
|
138
|
-
const accentBorder = '#4f46e5';
|
|
139
54
|
const layoutW = widget.layout?.w ?? DEFAULT_W;
|
|
140
|
-
// 12-col span — only applied in editMode so non-edit grid still uses minmax.
|
|
141
55
|
const spanStyle = editMode ? { gridColumn: `span ${layoutW} / span ${layoutW}` } : {};
|
|
142
|
-
// Drag affordances.
|
|
143
56
|
const isBeingDragged = dragIndex === index;
|
|
144
57
|
const isDropTarget = editMode && dragOverIndex === index && dragIndex !== index;
|
|
145
58
|
const cardBorder = isDropTarget
|
|
146
|
-
? `2px dashed ${
|
|
59
|
+
? `2px dashed ${ACCENT}`
|
|
147
60
|
: editMode
|
|
148
|
-
? `1px solid ${
|
|
61
|
+
? `1px solid ${ACCENT}`
|
|
149
62
|
: `1px solid ${border}`;
|
|
150
|
-
// Inline edit-mode button (small, icon-only).
|
|
151
63
|
const btn = (extraStyle = {}) => ({
|
|
152
64
|
background: theme === 'dark' ? '#475569' : '#e2e8f0',
|
|
153
65
|
color: theme === 'dark' ? '#f1f5f9' : '#1e293b',
|
|
@@ -164,9 +76,8 @@ function EditableWidgetCard({ widget, data, theme, editMode, index, total, dragI
|
|
|
164
76
|
justifyContent: 'center',
|
|
165
77
|
...extraStyle,
|
|
166
78
|
});
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
draggable: editMode, onDragStart: (e) => {
|
|
79
|
+
const Render = uiRegistry[widget.kind].Render;
|
|
80
|
+
return (_jsxs("div", { draggable: editMode, onDragStart: (e) => {
|
|
170
81
|
if (!editMode)
|
|
171
82
|
return;
|
|
172
83
|
e.dataTransfer.effectAllowed = 'move';
|
|
@@ -208,7 +119,7 @@ function EditableWidgetCard({ widget, data, theme, editMode, index, total, dragI
|
|
|
208
119
|
padding: '1px 6px',
|
|
209
120
|
borderRadius: 4,
|
|
210
121
|
flexShrink: 0,
|
|
211
|
-
}, children: widget.kind }))] }), editMode && (_jsxs("div", { style: { display: 'flex', gap: 2, flexShrink: 0 }, children: [_jsx("button", { title: "Edit configuration", onClick: onEdit, style: btn(), type: "button", children: "\u270E" }), _jsx("button", { title: "Duplicate", onClick: onDuplicate, style: btn(), type: "button", children: "\u29C9" }), _jsx("button", { title: "Move earlier", onClick: () => onMove(-1), disabled: index === 0, style: btn({ opacity: index === 0 ? 0.3 : 1 }), type: "button", children: "\u2191" }), _jsx("button", { title: "Move later", onClick: () => onMove(1), disabled: index === total - 1, style: btn({ opacity: index === total - 1 ? 0.3 : 1 }), type: "button", children: "\u2193" }), _jsx("button", { title: "Remove widget", onClick: onRemove, type: "button", style: { ...btn(), background: '#ef4444', color: '#fff' }, children: "\u00D7" })] }))] }), _jsx(
|
|
122
|
+
}, children: widget.kind }))] }), editMode && (_jsxs("div", { style: { display: 'flex', gap: 2, flexShrink: 0 }, children: [_jsx("button", { title: "Edit configuration", onClick: onEdit, style: btn(), type: "button", children: "\u270E" }), _jsx("button", { title: "Duplicate", onClick: onDuplicate, style: btn(), type: "button", children: "\u29C9" }), _jsx("button", { title: "Move earlier", onClick: () => onMove(-1), disabled: index === 0, style: btn({ opacity: index === 0 ? 0.3 : 1 }), type: "button", children: "\u2191" }), _jsx("button", { title: "Move later", onClick: () => onMove(1), disabled: index === total - 1, style: btn({ opacity: index === total - 1 ? 0.3 : 1 }), type: "button", children: "\u2193" }), _jsx("button", { title: "Remove widget", onClick: onRemove, type: "button", style: { ...btn(), background: '#ef4444', color: '#fff' }, children: "\u00D7" })] }))] }), _jsx(Render, { widget: widget, data: data, theme: theme }), editMode && (_jsxs("div", { style: {
|
|
212
123
|
marginTop: 10,
|
|
213
124
|
paddingTop: 8,
|
|
214
125
|
borderTop: `1px dashed ${theme === 'dark' ? '#334155' : '#cbd5e1'}`,
|
|
@@ -219,337 +130,14 @@ function EditableWidgetCard({ widget, data, theme, editMode, index, total, dragI
|
|
|
219
130
|
color: theme === 'dark' ? '#94a3b8' : '#64748b',
|
|
220
131
|
}, children: [_jsx("span", { style: { minWidth: 36 }, children: "Width" }), _jsx("button", { type: "button", onClick: () => onResize(layoutW - 1), disabled: layoutW <= MIN_W, style: btn({ width: 18, height: 18, fontSize: 11, opacity: layoutW <= MIN_W ? 0.3 : 1 }), children: "\u2212" }), _jsxs("span", { style: { minWidth: 38, textAlign: 'center', fontVariantNumeric: 'tabular-nums' }, children: [layoutW, " / ", MAX_W] }), _jsx("button", { type: "button", onClick: () => onResize(layoutW + 1), disabled: layoutW >= MAX_W, style: btn({ width: 18, height: 18, fontSize: 11, opacity: layoutW >= MAX_W ? 0.3 : 1 }), children: "+" }), _jsx("div", { style: { display: 'flex', gap: 2, marginLeft: 'auto' }, children: [3, 4, 6, 8, 12].map((w) => (_jsx("button", { type: "button", onClick: () => onResize(w), title: `Width ${w}/${MAX_W}`, style: {
|
|
221
132
|
...btn({ width: 20, height: 18, fontSize: 10 }),
|
|
222
|
-
background: layoutW === w
|
|
223
|
-
? accentBorder
|
|
224
|
-
: theme === 'dark'
|
|
225
|
-
? '#334155'
|
|
226
|
-
: '#e2e8f0',
|
|
133
|
+
background: layoutW === w ? ACCENT : theme === 'dark' ? '#334155' : '#e2e8f0',
|
|
227
134
|
color: layoutW === w ? '#fff' : text,
|
|
228
135
|
}, children: w }, w))) })] }))] }));
|
|
229
136
|
}
|
|
230
|
-
function useDebounced(value, delayMs) {
|
|
231
|
-
const [debounced, setDebounced] = useState(value);
|
|
232
|
-
useEffect(() => {
|
|
233
|
-
const t = setTimeout(() => setDebounced(value), delayMs);
|
|
234
|
-
return () => clearTimeout(t);
|
|
235
|
-
}, [value, delayMs]);
|
|
236
|
-
return debounced;
|
|
237
|
-
}
|
|
238
|
-
/**
|
|
239
|
-
* Single-value input with autosuggest dropdown. Used for event type and tag
|
|
240
|
-
* prefix fields.
|
|
241
|
-
*/
|
|
242
|
-
function AutosuggestInput({ endpoint, authKey, uid, suggestPath, value, onChange, placeholder, theme, inputStyle, }) {
|
|
243
|
-
const [text, setText] = useState(value);
|
|
244
|
-
const [open, setOpen] = useState(false);
|
|
245
|
-
const [highlight, setHighlight] = useState(0);
|
|
246
|
-
const [items, setItems] = useState([]);
|
|
247
|
-
const [loading, setLoading] = useState(false);
|
|
248
|
-
const debounced = useDebounced(text.trim(), 150);
|
|
249
|
-
// Sync incoming value once on mount / when external value changes (rare).
|
|
250
|
-
useEffect(() => {
|
|
251
|
-
setText(value);
|
|
252
|
-
}, [value]);
|
|
253
|
-
useEffect(() => {
|
|
254
|
-
let cancelled = false;
|
|
255
|
-
if (!open || debounced.length === 0) {
|
|
256
|
-
setItems([]);
|
|
257
|
-
setLoading(false);
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
setLoading(true);
|
|
261
|
-
const url = `${endpoint.replace(/\/$/, '')}/e/${authKey}/${uid}/suggest/${suggestPath}?q=${encodeURIComponent(debounced)}&limit=10`;
|
|
262
|
-
fetch(url)
|
|
263
|
-
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
|
264
|
-
.then((body) => {
|
|
265
|
-
if (cancelled)
|
|
266
|
-
return;
|
|
267
|
-
setItems(body.items ?? []);
|
|
268
|
-
setHighlight(0);
|
|
269
|
-
})
|
|
270
|
-
.catch(() => {
|
|
271
|
-
if (cancelled)
|
|
272
|
-
return;
|
|
273
|
-
setItems([]);
|
|
274
|
-
})
|
|
275
|
-
.finally(() => {
|
|
276
|
-
if (!cancelled)
|
|
277
|
-
setLoading(false);
|
|
278
|
-
});
|
|
279
|
-
return () => {
|
|
280
|
-
cancelled = true;
|
|
281
|
-
};
|
|
282
|
-
}, [endpoint, authKey, uid, suggestPath, debounced, open]);
|
|
283
|
-
const showItemCreate = text.trim().length > 0 && !items.some((it) => it.value === text.trim());
|
|
284
|
-
function commit(v) {
|
|
285
|
-
setText(v);
|
|
286
|
-
onChange(v);
|
|
287
|
-
setOpen(false);
|
|
288
|
-
}
|
|
289
|
-
function onKeyDown(e) {
|
|
290
|
-
const totalOptions = items.length + (showItemCreate ? 1 : 0);
|
|
291
|
-
if (e.key === 'ArrowDown' && totalOptions > 0) {
|
|
292
|
-
e.preventDefault();
|
|
293
|
-
setOpen(true);
|
|
294
|
-
setHighlight((h) => Math.min(h + 1, totalOptions - 1));
|
|
295
|
-
}
|
|
296
|
-
else if (e.key === 'ArrowUp' && totalOptions > 0) {
|
|
297
|
-
e.preventDefault();
|
|
298
|
-
setHighlight((h) => Math.max(h - 1, 0));
|
|
299
|
-
}
|
|
300
|
-
else if (e.key === 'Enter') {
|
|
301
|
-
if (open && totalOptions > 0) {
|
|
302
|
-
e.preventDefault();
|
|
303
|
-
if (highlight < items.length)
|
|
304
|
-
commit(items[highlight].value);
|
|
305
|
-
else
|
|
306
|
-
commit(text.trim());
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
else if (e.key === 'Escape') {
|
|
310
|
-
setOpen(false);
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
const dropdownBg = theme === 'dark' ? '#0f172a' : '#ffffff';
|
|
314
|
-
const dropdownBorder = theme === 'dark' ? '#334155' : '#cbd5e1';
|
|
315
|
-
const hoverBg = theme === 'dark' ? '#1e293b' : '#eef2ff';
|
|
316
|
-
const subText = theme === 'dark' ? '#94a3b8' : '#64748b';
|
|
317
|
-
return (_jsxs("div", { style: { position: 'relative' }, children: [_jsx("input", { style: inputStyle, value: text, onChange: (e) => {
|
|
318
|
-
setText(e.target.value);
|
|
319
|
-
onChange(e.target.value);
|
|
320
|
-
setOpen(true);
|
|
321
|
-
setHighlight(0);
|
|
322
|
-
}, onFocus: () => setOpen(true), onBlur: () => setTimeout(() => setOpen(false), 120), onKeyDown: onKeyDown, placeholder: placeholder, autoComplete: "off", spellCheck: false }), open && (text.trim().length > 0) && (_jsxs("div", { style: {
|
|
323
|
-
position: 'absolute',
|
|
324
|
-
top: 'calc(100% + 2px)',
|
|
325
|
-
left: 0,
|
|
326
|
-
right: 0,
|
|
327
|
-
background: dropdownBg,
|
|
328
|
-
border: `1px solid ${dropdownBorder}`,
|
|
329
|
-
borderRadius: 6,
|
|
330
|
-
boxShadow: '0 8px 24px rgba(0,0,0,0.15)',
|
|
331
|
-
zIndex: 1100,
|
|
332
|
-
maxHeight: 240,
|
|
333
|
-
overflowY: 'auto',
|
|
334
|
-
fontSize: 12,
|
|
335
|
-
}, onMouseDown: (e) => e.preventDefault(), children: [loading && (_jsx("div", { style: { padding: '8px 10px', color: subText }, children: "Searching\u2026" })), !loading && items.length === 0 && !showItemCreate && (_jsx("div", { style: { padding: '8px 10px', color: subText }, children: "No matches." })), items.map((it, i) => (_jsxs("div", { onMouseEnter: () => setHighlight(i), onMouseDown: (e) => {
|
|
336
|
-
e.preventDefault();
|
|
337
|
-
commit(it.value);
|
|
338
|
-
}, style: {
|
|
339
|
-
padding: '6px 10px',
|
|
340
|
-
cursor: 'pointer',
|
|
341
|
-
display: 'flex',
|
|
342
|
-
justifyContent: 'space-between',
|
|
343
|
-
gap: 8,
|
|
344
|
-
background: i === highlight ? hoverBg : 'transparent',
|
|
345
|
-
borderRadius: 4,
|
|
346
|
-
fontFamily: suggestPath === 'prefixes' ? 'monospace' : undefined,
|
|
347
|
-
}, children: [_jsx("span", { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: it.value }), _jsx("span", { style: { color: subText, flexShrink: 0, fontVariantNumeric: 'tabular-nums' }, children: it.count.toLocaleString() })] }, it.value))), showItemCreate && (_jsxs("div", { onMouseEnter: () => setHighlight(items.length), onMouseDown: (e) => {
|
|
348
|
-
e.preventDefault();
|
|
349
|
-
commit(text.trim());
|
|
350
|
-
}, style: {
|
|
351
|
-
padding: '6px 10px',
|
|
352
|
-
cursor: 'pointer',
|
|
353
|
-
borderTop: `1px dashed ${dropdownBorder}`,
|
|
354
|
-
marginTop: items.length > 0 ? 4 : 0,
|
|
355
|
-
background: highlight === items.length ? hoverBg : 'transparent',
|
|
356
|
-
borderRadius: 4,
|
|
357
|
-
color: '#4f46e5',
|
|
358
|
-
fontStyle: 'italic',
|
|
359
|
-
}, children: ["Use \"", text.trim(), "\"", suggestPath === 'prefixes' ? ' (new prefix)' : ' (new)'] }))] }))] }));
|
|
360
|
-
}
|
|
361
|
-
/**
|
|
362
|
-
* Multi-value tag input. Comma-separated in the draft model; each token gets
|
|
363
|
-
* its own autosuggest dropdown as the user types. Backspace removes the last
|
|
364
|
-
* token; Enter / comma commits the current text using the highlighted
|
|
365
|
-
* suggestion (if any) and clears the input.
|
|
366
|
-
*/
|
|
367
|
-
function TagsAutosuggestInput({ endpoint, authKey, uid, tags, onChange, placeholder, theme, inputStyle, }) {
|
|
368
|
-
const [text, setText] = useState('');
|
|
369
|
-
const [open, setOpen] = useState(false);
|
|
370
|
-
const [highlight, setHighlight] = useState(0);
|
|
371
|
-
const [items, setItems] = useState([]);
|
|
372
|
-
const [loading, setLoading] = useState(false);
|
|
373
|
-
const debounced = useDebounced(text.trim(), 150);
|
|
374
|
-
useEffect(() => {
|
|
375
|
-
let cancelled = false;
|
|
376
|
-
if (!open || debounced.length === 0) {
|
|
377
|
-
setItems([]);
|
|
378
|
-
setLoading(false);
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
setLoading(true);
|
|
382
|
-
const url = `${endpoint.replace(/\/$/, '')}/e/${authKey}/${uid}/suggest/tags?q=${encodeURIComponent(debounced)}&limit=10`;
|
|
383
|
-
fetch(url)
|
|
384
|
-
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
|
385
|
-
.then((body) => {
|
|
386
|
-
if (cancelled)
|
|
387
|
-
return;
|
|
388
|
-
setItems(body.items ?? []);
|
|
389
|
-
setHighlight(0);
|
|
390
|
-
})
|
|
391
|
-
.catch(() => {
|
|
392
|
-
if (cancelled)
|
|
393
|
-
return;
|
|
394
|
-
setItems([]);
|
|
395
|
-
})
|
|
396
|
-
.finally(() => {
|
|
397
|
-
if (!cancelled)
|
|
398
|
-
setLoading(false);
|
|
399
|
-
});
|
|
400
|
-
return () => {
|
|
401
|
-
cancelled = true;
|
|
402
|
-
};
|
|
403
|
-
}, [endpoint, authKey, uid, debounced, open]);
|
|
404
|
-
function addTag(raw) {
|
|
405
|
-
const v = raw.trim();
|
|
406
|
-
if (!v)
|
|
407
|
-
return;
|
|
408
|
-
const next = Array.from(new Set([...tags, v]));
|
|
409
|
-
onChange(next);
|
|
410
|
-
setText('');
|
|
411
|
-
setItems([]);
|
|
412
|
-
setHighlight(0);
|
|
413
|
-
}
|
|
414
|
-
function removeTag(t) {
|
|
415
|
-
onChange(tags.filter((x) => x !== t));
|
|
416
|
-
}
|
|
417
|
-
function onKeyDown(e) {
|
|
418
|
-
const totalOptions = items.length + (text.trim().length > 0 && !items.some((it) => it.value === text.trim()) ? 1 : 0);
|
|
419
|
-
if (e.key === 'ArrowDown' && totalOptions > 0) {
|
|
420
|
-
e.preventDefault();
|
|
421
|
-
setOpen(true);
|
|
422
|
-
setHighlight((h) => Math.min(h + 1, totalOptions - 1));
|
|
423
|
-
}
|
|
424
|
-
else if (e.key === 'ArrowUp' && totalOptions > 0) {
|
|
425
|
-
e.preventDefault();
|
|
426
|
-
setHighlight((h) => Math.max(h - 1, 0));
|
|
427
|
-
}
|
|
428
|
-
else if (e.key === 'Enter' || e.key === ',') {
|
|
429
|
-
e.preventDefault();
|
|
430
|
-
if (open && totalOptions > 0) {
|
|
431
|
-
if (highlight < items.length)
|
|
432
|
-
addTag(items[highlight].value);
|
|
433
|
-
else
|
|
434
|
-
addTag(text.trim());
|
|
435
|
-
}
|
|
436
|
-
else {
|
|
437
|
-
addTag(text.trim());
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
else if (e.key === 'Backspace' && text === '' && tags.length > 0) {
|
|
441
|
-
// Remove last tag on backspace when input is empty.
|
|
442
|
-
removeTag(tags[tags.length - 1]);
|
|
443
|
-
}
|
|
444
|
-
else if (e.key === 'Escape') {
|
|
445
|
-
setOpen(false);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
const dropdownBg = theme === 'dark' ? '#0f172a' : '#ffffff';
|
|
449
|
-
const dropdownBorder = theme === 'dark' ? '#334155' : '#cbd5e1';
|
|
450
|
-
const constainerBorder = dropdownBorder;
|
|
451
|
-
const hoverBg = theme === 'dark' ? '#1e293b' : '#eef2ff';
|
|
452
|
-
const subText = theme === 'dark' ? '#94a3b8' : '#64748b';
|
|
453
|
-
const chipBg = theme === 'dark' ? '#334155' : '#e0e7ff';
|
|
454
|
-
const chipText = theme === 'dark' ? '#f1f5f9' : '#3730a3';
|
|
455
|
-
const showItemCreate = text.trim().length > 0 && !items.some((it) => it.value === text.trim());
|
|
456
|
-
return (_jsxs("div", { style: {
|
|
457
|
-
...inputStyle,
|
|
458
|
-
display: 'flex',
|
|
459
|
-
flexWrap: 'wrap',
|
|
460
|
-
gap: 4,
|
|
461
|
-
alignItems: 'center',
|
|
462
|
-
padding: '4px 6px',
|
|
463
|
-
cursor: 'text',
|
|
464
|
-
minHeight: inputStyle.padding ?? 36,
|
|
465
|
-
}, onMouseDown: () => {
|
|
466
|
-
// Focus the input when clicking anywhere in the chip area.
|
|
467
|
-
const i = document.getElementById('tag-input-internal');
|
|
468
|
-
if (i)
|
|
469
|
-
i.focus();
|
|
470
|
-
}, children: [tags.map((t) => (_jsxs("span", { style: {
|
|
471
|
-
display: 'inline-flex',
|
|
472
|
-
alignItems: 'center',
|
|
473
|
-
gap: 4,
|
|
474
|
-
background: chipBg,
|
|
475
|
-
color: chipText,
|
|
476
|
-
padding: '2px 4px 2px 8px',
|
|
477
|
-
borderRadius: 4,
|
|
478
|
-
fontSize: 11,
|
|
479
|
-
fontFamily: 'monospace',
|
|
480
|
-
}, children: [t, _jsx("button", { type: "button", onMouseDown: (e) => {
|
|
481
|
-
e.preventDefault();
|
|
482
|
-
e.stopPropagation();
|
|
483
|
-
removeTag(t);
|
|
484
|
-
}, style: {
|
|
485
|
-
border: 'none',
|
|
486
|
-
background: 'transparent',
|
|
487
|
-
color: chipText,
|
|
488
|
-
cursor: 'pointer',
|
|
489
|
-
fontSize: 13,
|
|
490
|
-
lineHeight: 1,
|
|
491
|
-
padding: '0 4px',
|
|
492
|
-
}, "aria-label": `Remove ${t}`, children: "\u00D7" })] }, t))), _jsxs("div", { style: { position: 'relative', flex: 1, minWidth: 80 }, children: [_jsx("input", { id: "tag-input-internal", style: {
|
|
493
|
-
width: '100%',
|
|
494
|
-
background: 'transparent',
|
|
495
|
-
border: 'none',
|
|
496
|
-
outline: 'none',
|
|
497
|
-
color: 'inherit',
|
|
498
|
-
fontSize: 13,
|
|
499
|
-
padding: '4px 2px',
|
|
500
|
-
fontFamily: 'monospace',
|
|
501
|
-
}, value: text, onChange: (e) => {
|
|
502
|
-
// Strip commas — they commit a tag instead.
|
|
503
|
-
const next = e.target.value.replace(/,/g, '');
|
|
504
|
-
setText(next);
|
|
505
|
-
setOpen(true);
|
|
506
|
-
setHighlight(0);
|
|
507
|
-
}, onFocus: () => setOpen(true), onBlur: () => setTimeout(() => setOpen(false), 120), onKeyDown: onKeyDown, placeholder: tags.length === 0 ? placeholder : '', autoComplete: "off", spellCheck: false }), open && text.trim().length > 0 && (_jsxs("div", { style: {
|
|
508
|
-
position: 'absolute',
|
|
509
|
-
top: 'calc(100% + 2px)',
|
|
510
|
-
left: 0,
|
|
511
|
-
right: 0,
|
|
512
|
-
background: dropdownBg,
|
|
513
|
-
border: `1px solid ${constainerBorder}`,
|
|
514
|
-
borderRadius: 6,
|
|
515
|
-
boxShadow: '0 8px 24px rgba(0,0,0,0.15)',
|
|
516
|
-
zIndex: 1100,
|
|
517
|
-
maxHeight: 240,
|
|
518
|
-
overflowY: 'auto',
|
|
519
|
-
fontSize: 12,
|
|
520
|
-
}, onMouseDown: (e) => e.preventDefault(), children: [loading && (_jsx("div", { style: { padding: '8px 10px', color: subText }, children: "Searching\u2026" })), !loading && items.length === 0 && !showItemCreate && (_jsx("div", { style: { padding: '8px 10px', color: subText }, children: "No matches." })), items.map((it, i) => (_jsxs("div", { onMouseEnter: () => setHighlight(i), onMouseDown: (e) => {
|
|
521
|
-
e.preventDefault();
|
|
522
|
-
addTag(it.value);
|
|
523
|
-
}, style: {
|
|
524
|
-
padding: '6px 10px',
|
|
525
|
-
cursor: 'pointer',
|
|
526
|
-
display: 'flex',
|
|
527
|
-
justifyContent: 'space-between',
|
|
528
|
-
gap: 8,
|
|
529
|
-
background: i === highlight ? hoverBg : 'transparent',
|
|
530
|
-
borderRadius: 4,
|
|
531
|
-
fontFamily: 'monospace',
|
|
532
|
-
}, children: [_jsx("span", { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: it.value }), _jsx("span", { style: { color: subText, flexShrink: 0, fontVariantNumeric: 'tabular-nums' }, children: it.count.toLocaleString() })] }, it.value))), showItemCreate && (_jsxs("div", { onMouseEnter: () => setHighlight(items.length), onMouseDown: (e) => {
|
|
533
|
-
e.preventDefault();
|
|
534
|
-
addTag(text.trim());
|
|
535
|
-
}, style: {
|
|
536
|
-
padding: '6px 10px',
|
|
537
|
-
cursor: 'pointer',
|
|
538
|
-
borderTop: `1px dashed ${constainerBorder}`,
|
|
539
|
-
marginTop: items.length > 0 ? 4 : 0,
|
|
540
|
-
background: highlight === items.length ? hoverBg : 'transparent',
|
|
541
|
-
borderRadius: 4,
|
|
542
|
-
color: '#4f46e5',
|
|
543
|
-
fontStyle: 'italic',
|
|
544
|
-
fontFamily: 'monospace',
|
|
545
|
-
}, children: ["Add \"", text.trim(), "\" (new tag)"] }))] }))] })] }));
|
|
546
|
-
}
|
|
547
137
|
function WidgetEditorModal({ endpoint, authKey, uid, state, theme, onClose, onSave, }) {
|
|
548
138
|
const [draft, setDraft] = useState(state.widget);
|
|
549
|
-
|
|
550
|
-
const set = (k, v) => setDraft((d) => ({ ...d, [k]: v }));
|
|
139
|
+
const update = (patch) => setDraft((d) => ({ ...d, ...patch }));
|
|
551
140
|
const setLayout = (patch) => setDraft((d) => ({ ...d, layout: { x: 0, y: 0, w: DEFAULT_W, h: 4, ...d.layout, ...patch } }));
|
|
552
|
-
// Theme-driven styles
|
|
553
141
|
const overlay = {
|
|
554
142
|
position: 'fixed',
|
|
555
143
|
inset: 0,
|
|
@@ -566,7 +154,15 @@ function WidgetEditorModal({ endpoint, authKey, uid, state, theme, onClose, onSa
|
|
|
566
154
|
const inputBorder = theme === 'dark' ? '#334155' : '#cbd5e1';
|
|
567
155
|
const labelColor = theme === 'dark' ? '#cbd5e1' : '#475569';
|
|
568
156
|
const subLabelColor = theme === 'dark' ? '#94a3b8' : '#64748b';
|
|
569
|
-
const labelStyle = {
|
|
157
|
+
const labelStyle = {
|
|
158
|
+
fontSize: 11,
|
|
159
|
+
fontWeight: 600,
|
|
160
|
+
color: labelColor,
|
|
161
|
+
textTransform: 'uppercase',
|
|
162
|
+
letterSpacing: 0.5,
|
|
163
|
+
marginBottom: 4,
|
|
164
|
+
display: 'block',
|
|
165
|
+
};
|
|
570
166
|
const inputStyle = {
|
|
571
167
|
width: '100%',
|
|
572
168
|
padding: '8px 10px',
|
|
@@ -577,9 +173,10 @@ function WidgetEditorModal({ endpoint, authKey, uid, state, theme, onClose, onSa
|
|
|
577
173
|
borderRadius: 6,
|
|
578
174
|
outline: 'none',
|
|
579
175
|
};
|
|
580
|
-
const
|
|
581
|
-
const
|
|
582
|
-
|
|
176
|
+
const ctx = { endpoint, authKey, uid, theme, inputStyle, labelStyle, subLabelColor };
|
|
177
|
+
const EditForm = uiRegistry[draft.kind].EditForm;
|
|
178
|
+
// Tag filters apply to most kinds; funnel carries its own filterTags instead.
|
|
179
|
+
const showCommonTags = draft.kind !== 'funnel';
|
|
583
180
|
return (_jsx("div", { style: overlay, onMouseDown: (e) => { if (e.target === e.currentTarget)
|
|
584
181
|
onClose(); }, children: _jsxs("div", { style: {
|
|
585
182
|
background: modalBg,
|
|
@@ -591,10 +188,10 @@ function WidgetEditorModal({ endpoint, authKey, uid, state, theme, onClose, onSa
|
|
|
591
188
|
overflowY: 'auto',
|
|
592
189
|
padding: 20,
|
|
593
190
|
boxShadow: '0 20px 50px rgba(0,0,0,0.3)',
|
|
594
|
-
}, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }, children: [_jsx("div", { style: { fontSize: 15, fontWeight: 700 }, children: state.isNew ? 'Add widget' : 'Edit widget' }), _jsx("button", { type: "button", onClick: onClose, style: btnClose(modalText), children: "\u00D7" })] }), _jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: 12 }, children: [_jsxs("label", { children: [_jsx("span", { style: labelStyle, children: "Title" }), _jsx("input", { style: inputStyle, value: draft.title, onChange: (e) =>
|
|
191
|
+
}, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }, children: [_jsx("div", { style: { fontSize: 15, fontWeight: 700 }, children: state.isNew ? 'Add widget' : 'Edit widget' }), _jsx("button", { type: "button", onClick: onClose, style: btnClose(modalText), children: "\u00D7" })] }), _jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: 12 }, children: [_jsxs("label", { children: [_jsx("span", { style: labelStyle, children: "Title" }), _jsx("input", { style: inputStyle, value: draft.title, onChange: (e) => update({ title: e.target.value }), placeholder: "e.g. Signups per day", autoFocus: true })] }), _jsxs("label", { children: [_jsx("span", { style: labelStyle, children: "Kind" }), _jsx("select", { style: inputStyle, value: draft.kind, onChange: (e) => {
|
|
595
192
|
const kind = e.target.value;
|
|
596
|
-
setDraft((d) => ({
|
|
597
|
-
}, children: WIDGET_KINDS.map((k) => (_jsx("option", { value: k.value, children: k.label }, k.value))) }), _jsx("span", { style: { display: 'block', fontSize: 11, color: subLabelColor, marginTop: 4 }, children: WIDGET_KINDS.find((k) => k.value === draft.kind)?.hint })] }),
|
|
193
|
+
setDraft((d) => defaultWidget(kind, { id: d.id, title: d.title, tags: d.tags, layout: d.layout }));
|
|
194
|
+
}, children: WIDGET_KINDS.map((k) => (_jsx("option", { value: k.value, children: k.label }, k.value))) }), _jsx("span", { style: { display: 'block', fontSize: 11, color: subLabelColor, marginTop: 4 }, children: WIDGET_KINDS.find((k) => k.value === draft.kind)?.hint })] }), _jsx(EditForm, { draft: draft, update: update, ctx: ctx }), showCommonTags && (_jsxs("label", { children: [_jsx("span", { style: labelStyle, children: "Tag filters" }), _jsx(TagsAutosuggestInput, { endpoint: endpoint, authKey: authKey, uid: uid, tags: draft.tags ?? [], onChange: (t) => update({ tags: t }), placeholder: "type to search, Enter to add", theme: theme, inputStyle: inputStyle })] })), _jsxs("label", { children: [_jsxs("span", { style: labelStyle, children: ["Width: ", _jsxs("span", { style: { color: modalText }, children: [draft.layout?.w ?? DEFAULT_W, " / ", MAX_W] })] }), _jsx("input", { type: "range", min: MIN_W, max: MAX_W, step: 1, value: draft.layout?.w ?? DEFAULT_W, onChange: (e) => setLayout({ w: Number(e.target.value) }), style: { width: '100%', accentColor: ACCENT } })] })] }), _jsxs("div", { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 20 }, children: [_jsx("button", { type: "button", onClick: onClose, style: {
|
|
598
195
|
padding: '8px 14px',
|
|
599
196
|
fontSize: 13,
|
|
600
197
|
borderRadius: 6,
|
|
@@ -608,23 +205,23 @@ function WidgetEditorModal({ endpoint, authKey, uid, state, theme, onClose, onSa
|
|
|
608
205
|
fontWeight: 600,
|
|
609
206
|
borderRadius: 6,
|
|
610
207
|
border: 'none',
|
|
611
|
-
background: draft.title.trim() ?
|
|
208
|
+
background: draft.title.trim() ? ACCENT : '#94a3b8',
|
|
612
209
|
color: '#fff',
|
|
613
210
|
cursor: draft.title.trim() ? 'pointer' : 'not-allowed',
|
|
614
211
|
}, children: state.isNew ? 'Add widget' : 'Save changes' })] })] }) }));
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
}
|
|
212
|
+
}
|
|
213
|
+
function btnClose(text) {
|
|
214
|
+
return {
|
|
215
|
+
width: 28,
|
|
216
|
+
height: 28,
|
|
217
|
+
padding: 0,
|
|
218
|
+
border: 'none',
|
|
219
|
+
background: 'transparent',
|
|
220
|
+
color: text,
|
|
221
|
+
fontSize: 22,
|
|
222
|
+
lineHeight: 1,
|
|
223
|
+
cursor: 'pointer',
|
|
224
|
+
};
|
|
628
225
|
}
|
|
629
226
|
// ============================================================
|
|
630
227
|
// Main component
|
|
@@ -636,10 +233,8 @@ export function TrackhomeDashboard({ endpoint, authKey, uid, editable = false, r
|
|
|
636
233
|
const [error, setError] = useState(null);
|
|
637
234
|
const [editMode, setEditMode] = useState(false);
|
|
638
235
|
const [editor, setEditor] = useState(null);
|
|
639
|
-
// Drag-and-drop reorder state.
|
|
640
236
|
const [dragIndex, setDragIndex] = useState(null);
|
|
641
237
|
const [dragOverIndex, setDragOverIndex] = useState(null);
|
|
642
|
-
// Saving indicator.
|
|
643
238
|
const [saving, setSaving] = useState(false);
|
|
644
239
|
const refreshTimer = useRef(null);
|
|
645
240
|
const range = useMemo(() => dateRange ?? defaultRange(), [dateRange]);
|
|
@@ -649,7 +244,8 @@ export function TrackhomeDashboard({ endpoint, authKey, uid, editable = false, r
|
|
|
649
244
|
api(endpoint, authKey, uid, ''),
|
|
650
245
|
api(endpoint, authKey, uid, `/data?from=${range.from}&to=${range.to}`),
|
|
651
246
|
]);
|
|
652
|
-
|
|
247
|
+
// Upgrade any legacy stored widgets so old dashboards keep rendering.
|
|
248
|
+
setConfig({ ...cfg, widgets: (cfg.widgets ?? []).map(normalizeWidget) });
|
|
653
249
|
setData(widgetData);
|
|
654
250
|
setError(null);
|
|
655
251
|
}
|
|
@@ -681,21 +277,13 @@ export function TrackhomeDashboard({ endpoint, authKey, uid, editable = false, r
|
|
|
681
277
|
const headerText = theme === 'dark' ? '#f1f5f9' : '#1e293b';
|
|
682
278
|
const subText = theme === 'dark' ? '#94a3b8' : '#64748b';
|
|
683
279
|
// ---------------- Mutators ----------------
|
|
684
|
-
// Every mutation updates local state immediately (optimistic) then PATCHes
|
|
685
|
-
// the full config to the server. The PATCH route on the API merges into
|
|
686
|
-
// the stored row so this is safe to call repeatedly.
|
|
687
280
|
async function saveConfig(next) {
|
|
688
281
|
setConfig(next);
|
|
689
282
|
setSaving(true);
|
|
690
283
|
try {
|
|
691
|
-
await api(endpoint, authKey, uid, '', {
|
|
692
|
-
method: 'PATCH',
|
|
693
|
-
body: JSON.stringify(next),
|
|
694
|
-
});
|
|
284
|
+
await api(endpoint, authKey, uid, '', { method: 'PATCH', body: JSON.stringify(next) });
|
|
695
285
|
}
|
|
696
286
|
catch (err) {
|
|
697
|
-
// Surface the error inline; the optimistic update stays so the user
|
|
698
|
-
// can copy their work or retry by making any further edit.
|
|
699
287
|
setError(err.message);
|
|
700
288
|
}
|
|
701
289
|
finally {
|
|
@@ -745,14 +333,18 @@ export function TrackhomeDashboard({ endpoint, authKey, uid, editable = false, r
|
|
|
745
333
|
: x));
|
|
746
334
|
}
|
|
747
335
|
function commitEditor(next) {
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
336
|
+
if (!config)
|
|
337
|
+
return;
|
|
338
|
+
const exists = config.widgets.some((w) => w.id === next.id);
|
|
339
|
+
const widgets = exists
|
|
340
|
+
? config.widgets.map((w) => (w.id === next.id ? next : w))
|
|
341
|
+
: [...config.widgets, next];
|
|
752
342
|
setEditor(null);
|
|
343
|
+
// Save, then refresh widget data so a new/kind-changed widget shows correct
|
|
344
|
+
// data immediately. fetchAll runs only AFTER the PATCH settles, so it can't
|
|
345
|
+
// re-GET a stale config and drop the just-added widget.
|
|
346
|
+
void saveConfig({ ...config, widgets }).then(() => fetchAll());
|
|
753
347
|
}
|
|
754
|
-
// Drag handlers — HTML5 DnD. The card sets dragIndex on dragstart, and on
|
|
755
|
-
// drop we splice the dragged item out and reinsert at the drop position.
|
|
756
348
|
function handleDrop(dropAt) {
|
|
757
349
|
if (dragIndex === null || dragIndex === dropAt) {
|
|
758
350
|
setDragIndex(null);
|
|
@@ -768,14 +360,10 @@ export function TrackhomeDashboard({ endpoint, authKey, uid, editable = false, r
|
|
|
768
360
|
setDragIndex(null);
|
|
769
361
|
setDragOverIndex(null);
|
|
770
362
|
}
|
|
771
|
-
// Grid: in edit mode we use a strict 12-col grid so width spans make sense.
|
|
772
|
-
// In view mode we keep the auto-fill behaviour so widgets flow naturally.
|
|
773
363
|
const gridStyle = editMode
|
|
774
364
|
? { display: 'grid', gridTemplateColumns: 'repeat(12, 1fr)', gap: 8 }
|
|
775
365
|
: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 8 };
|
|
776
|
-
return (_jsxs("div", { className: className, style: { background: bg, borderRadius: 12, padding: 16, position: 'relative' }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, gap: 8, flexWrap: 'wrap' }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }, children: [_jsx("div", { style: { fontSize: 16, fontWeight: 700, color: headerText, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: config.name }), saving &&
|
|
777
|
-
setEditor({ widget: defaultWidget('timeseries'), isNew: true });
|
|
778
|
-
}, style: primaryBtn(false), children: "+ Add widget" })), _jsx("button", { type: "button", onClick: () => setEditMode(!editMode), style: primaryBtn(editMode), children: editMode ? '✓ Done' : '✎ Edit' })] }))] }), editMode && (_jsxs("div", { style: {
|
|
366
|
+
return (_jsxs("div", { className: className, style: { background: bg, borderRadius: 12, padding: 16, position: 'relative' }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, gap: 8, flexWrap: 'wrap' }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }, children: [_jsx("div", { style: { fontSize: 16, fontWeight: 700, color: headerText, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: config.name }), saving && _jsx("span", { style: { fontSize: 10, color: subText }, children: "saving\u2026" })] }), editable && (_jsxs("div", { style: { display: 'flex', gap: 6, alignItems: 'center' }, children: [editMode && (_jsx("button", { type: "button", onClick: () => setEditor({ widget: defaultWidget('timeseries'), isNew: true }), style: primaryBtn(false), children: "+ Add widget" })), _jsx("button", { type: "button", onClick: () => setEditMode(!editMode), style: primaryBtn(editMode), children: editMode ? '✓ Done' : '✎ Edit' })] }))] }), editMode && (_jsxs("div", { style: {
|
|
779
367
|
fontSize: 11,
|
|
780
368
|
color: subText,
|
|
781
369
|
background: theme === 'dark' ? '#1e293b' : '#eef2ff',
|
|
@@ -796,9 +384,6 @@ export function TrackhomeDashboard({ endpoint, authKey, uid, editable = false, r
|
|
|
796
384
|
borderRadius: 8,
|
|
797
385
|
}, children: editMode ? (_jsxs(_Fragment, { children: [_jsx("div", { style: { marginBottom: 8 }, children: "No widgets yet." }), _jsx("button", { type: "button", onClick: () => setEditor({ widget: defaultWidget('timeseries'), isNew: true }), style: primaryBtn(false), children: "+ Add your first widget" })] })) : (_jsx(_Fragment, { children: "No widgets." })) }))] }), _jsx("div", { style: { textAlign: 'right', marginTop: 8, fontSize: 10, color: theme === 'dark' ? '#475569' : '#cbd5e1' }, children: "powered by trackhome" }), editor && (_jsx(WidgetEditorModal, { endpoint: endpoint, authKey: authKey, uid: uid, state: editor, theme: theme, onClose: () => setEditor(null), onSave: commitEditor }))] }));
|
|
798
386
|
}
|
|
799
|
-
// ============================================================
|
|
800
|
-
// Shared button style
|
|
801
|
-
// ============================================================
|
|
802
387
|
function primaryBtn(solid) {
|
|
803
388
|
return {
|
|
804
389
|
fontSize: 12,
|