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.
Files changed (42) hide show
  1. package/README.md +4 -2
  2. package/dist/index.d.ts +8 -3
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +59 -474
  5. package/dist/index.js.map +1 -1
  6. package/dist/widgets/categorical.d.ts +6 -0
  7. package/dist/widgets/categorical.d.ts.map +1 -0
  8. package/dist/widgets/categorical.js +37 -0
  9. package/dist/widgets/categorical.js.map +1 -0
  10. package/dist/widgets/common.d.ts +42 -0
  11. package/dist/widgets/common.d.ts.map +1 -0
  12. package/dist/widgets/common.js +39 -0
  13. package/dist/widgets/common.js.map +1 -0
  14. package/dist/widgets/funnel.d.ts +3 -0
  15. package/dist/widgets/funnel.d.ts.map +1 -0
  16. package/dist/widgets/funnel.js +83 -0
  17. package/dist/widgets/funnel.js.map +1 -0
  18. package/dist/widgets/index.d.ts +4 -0
  19. package/dist/widgets/index.d.ts.map +1 -0
  20. package/dist/widgets/index.js +4 -0
  21. package/dist/widgets/index.js.map +1 -0
  22. package/dist/widgets/inputs.d.ts +32 -0
  23. package/dist/widgets/inputs.d.ts.map +1 -0
  24. package/dist/widgets/inputs.js +312 -0
  25. package/dist/widgets/inputs.js.map +1 -0
  26. package/dist/widgets/metric.d.ts +3 -0
  27. package/dist/widgets/metric.d.ts.map +1 -0
  28. package/dist/widgets/metric.js +15 -0
  29. package/dist/widgets/metric.js.map +1 -0
  30. package/dist/widgets/registry.d.ts +26 -0
  31. package/dist/widgets/registry.d.ts.map +1 -0
  32. package/dist/widgets/registry.js +53 -0
  33. package/dist/widgets/registry.js.map +1 -0
  34. package/dist/widgets/timeseries.d.ts +4 -0
  35. package/dist/widgets/timeseries.d.ts.map +1 -0
  36. package/dist/widgets/timeseries.js +34 -0
  37. package/dist/widgets/timeseries.js.map +1 -0
  38. package/dist/widgets/types.d.ts +87 -0
  39. package/dist/widgets/types.d.ts.map +1 -0
  40. package/dist/widgets/types.js +18 -0
  41. package/dist/widgets/types.js.map +1 -0
  42. 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="https://trk.example.com"
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
- * Zero runtime dependencies beyond React itself drag-and-drop and resize
22
- * are implemented with native HTML5 APIs to keep the published bundle small.
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 ${accentBorder}`
59
+ ? `2px dashed ${ACCENT}`
147
60
  : editMode
148
- ? `1px solid ${accentBorder}`
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
- return (_jsxs("div", {
168
- // Draggable in edit mode for reorder.
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(WidgetBody, { widget: widget, data: data, theme: theme }), editMode && (_jsxs("div", { style: {
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
- // Local helpers
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 = { fontSize: 11, fontWeight: 600, color: labelColor, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 4, display: 'block' };
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 requiresTagPrefix = draft.kind === 'top_tags' || (draft.kind === 'bar' && draft.breakdown === 'tag') || (draft.kind === 'pie' && draft.breakdown === 'tag');
581
- const requiresFunnel = draft.kind === 'funnel';
582
- const isTimechart = draft.kind === 'timeseries' || draft.kind === 'area';
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) => set('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) => {
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) => ({ ...d, kind }));
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 })] }), draft.kind === 'metric' && (_jsxs("label", { children: [_jsx("span", { style: labelStyle, children: "Window (minutes)" }), _jsx("input", { type: "number", min: 1, style: inputStyle, value: draft.minutes ?? 5, onChange: (e) => set('minutes', Number(e.target.value) || 5) })] })), isTimechart && (_jsxs("label", { children: [_jsx("span", { style: labelStyle, children: "Bucket interval" }), _jsxs("select", { style: inputStyle, value: draft.bucket ?? '1 day', onChange: (e) => set('bucket', e.target.value), children: [_jsx("option", { value: "1 hour", children: "1 hour" }), _jsx("option", { value: "6 hours", children: "6 hours" }), _jsx("option", { value: "1 day", children: "1 day" }), _jsx("option", { value: "7 days", children: "7 days" })] })] })), (draft.kind === 'bar' || draft.kind === 'pie') && (_jsxs("label", { children: [_jsx("span", { style: labelStyle, children: "Breakdown" }), _jsxs("select", { style: inputStyle, value: draft.breakdown ?? 'event_type', onChange: (e) => set('breakdown', e.target.value), children: [_jsx("option", { value: "event_type", children: "Event type" }), _jsx("option", { value: "tag", children: "Tag value" })] })] })), (requiresTagPrefix || draft.kind === 'top_tags') && (_jsxs("label", { children: [_jsx("span", { style: labelStyle, children: "Tag prefix" }), _jsx(AutosuggestInput, { endpoint: endpoint, authKey: authKey, uid: uid, suggestPath: "prefixes", value: draft.tagPrefix ?? '', onChange: (v) => set('tagPrefix', v || undefined), placeholder: "e.g. variant:", theme: theme, inputStyle: inputStyle })] })), requiresFunnel && (_jsxs("label", { children: [_jsx("span", { style: labelStyle, children: "Funnel slug" }), _jsx("input", { style: inputStyle, value: draft.funnelSlug ?? '', onChange: (e) => set('funnelSlug', e.target.value), placeholder: "e.g. signup-funnel" })] })), draft.kind !== 'metric' && draft.kind !== 'funnel' && (_jsxs("label", { children: [_jsx("span", { style: labelStyle, children: "Metric" }), _jsxs("select", { style: inputStyle, value: draft.metric ?? 'count', onChange: (e) => set('metric', e.target.value), children: [_jsx("option", { value: "count", children: "Count" }), _jsx("option", { value: "unique", children: "Unique visitors" }), _jsx("option", { value: "sum", children: "Sum" }), _jsx("option", { value: "avg", children: "Average" })] })] })), (isTimechart || draft.kind === 'metric' || draft.kind === 'bar' || draft.kind === 'pie' || draft.kind === 'top_events') && (_jsxs("label", { children: [_jsx("span", { style: labelStyle, children: "Event type filter (optional)" }), _jsx(AutosuggestInput, { endpoint: endpoint, authKey: authKey, uid: uid, suggestPath: "events", value: draft.eventType ?? '', onChange: (v) => set('eventType', v || undefined), placeholder: "e.g. page_view", theme: theme, inputStyle: inputStyle })] })), _jsxs("label", { children: [_jsx("span", { style: labelStyle, children: "Tag filters" }), _jsx(TagsAutosuggestInput, { endpoint: endpoint, authKey: authKey, uid: uid, tags: draft.tags ?? [], onChange: (t) => set('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: '#4f46e5' } })] })] }), _jsxs("div", { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 20 }, children: [_jsx("button", { type: "button", onClick: onClose, style: {
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() ? '#4f46e5' : '#94a3b8',
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
- function btnClose(text) {
616
- return {
617
- width: 28,
618
- height: 28,
619
- padding: 0,
620
- border: 'none',
621
- background: 'transparent',
622
- color: text,
623
- fontSize: 22,
624
- lineHeight: 1,
625
- cursor: 'pointer',
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
- setConfig(cfg);
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
- mutateWidgets((ws) => {
749
- const exists = ws.some((w) => w.id === next.id);
750
- return exists ? ws.map((w) => (w.id === next.id ? next : w)) : [...ws, next];
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 && (_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: () => {
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,