@growthub/cli 0.9.4 → 0.9.6

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.
@@ -0,0 +1,951 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useCallback, useMemo, useRef, useState } from "react";
5
+ import {
6
+ DASHBOARD_TEMPLATES,
7
+ SAMPLE_DATA_BINDINGS,
8
+ SAMPLE_VIEW_ROWS,
9
+ defaultConfigFor,
10
+ validateWorkspaceConfig
11
+ } from "@/lib/workspace-schema";
12
+
13
+ const DEFAULT_POSITION = { x: 4, y: 0, w: 4, h: 5 };
14
+ const GRID_COLUMNS = 12;
15
+ const GRID_ROWS = 16;
16
+ const GRID_CELL_COUNT = GRID_COLUMNS * GRID_ROWS;
17
+ const DEFAULT_TAB_ID = "tab-default";
18
+ const COLLAPSED_GRID_COLUMNS = "220px minmax(0, 1fr)";
19
+
20
+ function generateId(prefix) {
21
+ if (typeof globalThis !== "undefined" && globalThis.crypto?.randomUUID) {
22
+ return `${prefix}_${globalThis.crypto.randomUUID()}`;
23
+ }
24
+ return `${prefix}_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`;
25
+ }
26
+
27
+ function defaultTitleFor(kind) {
28
+ switch (kind) {
29
+ case "chart": return "Untitled chart";
30
+ case "view": return "Companies";
31
+ case "iframe": return "Untitled iFrame";
32
+ case "rich-text": return "Untitled Rich Text";
33
+ default: return "Untitled widget";
34
+ }
35
+ }
36
+
37
+ function getTabs(canvas) {
38
+ if (Array.isArray(canvas?.tabs) && canvas.tabs.length > 0) {
39
+ return canvas.tabs;
40
+ }
41
+ return [{
42
+ id: DEFAULT_TAB_ID,
43
+ name: canvas?.name || "Tab 1",
44
+ widgets: Array.isArray(canvas?.widgets) ? canvas.widgets : []
45
+ }];
46
+ }
47
+
48
+ function getActiveTabId(canvas) {
49
+ const tabs = getTabs(canvas);
50
+ if (canvas?.activeTabId && tabs.some((tab) => tab.id === canvas.activeTabId)) {
51
+ return canvas.activeTabId;
52
+ }
53
+ return tabs[0].id;
54
+ }
55
+
56
+ function commitTabs(canvas, tabs, activeTabId) {
57
+ const next = { ...canvas };
58
+ if (tabs.length <= 1) {
59
+ const tab = tabs[0];
60
+ next.name = tab.name;
61
+ next.widgets = tab.widgets;
62
+ delete next.tabs;
63
+ delete next.activeTabId;
64
+ } else {
65
+ next.tabs = tabs;
66
+ next.activeTabId = activeTabId;
67
+ delete next.widgets;
68
+ delete next.name;
69
+ }
70
+ return next;
71
+ }
72
+
73
+ function findFreePosition(widgets) {
74
+ const occupied = new Set();
75
+ for (const widget of widgets) {
76
+ for (let dx = 0; dx < widget.position.w; dx += 1) {
77
+ for (let dy = 0; dy < widget.position.h; dy += 1) {
78
+ occupied.add(`${widget.position.x + dx}:${widget.position.y + dy}`);
79
+ }
80
+ }
81
+ }
82
+ for (let y = 0; y <= GRID_ROWS - DEFAULT_POSITION.h; y += 1) {
83
+ for (let x = 0; x <= GRID_COLUMNS - DEFAULT_POSITION.w; x += 1) {
84
+ let collides = false;
85
+ for (let dx = 0; dx < DEFAULT_POSITION.w && !collides; dx += 1) {
86
+ for (let dy = 0; dy < DEFAULT_POSITION.h && !collides; dy += 1) {
87
+ if (occupied.has(`${x + dx}:${y + dy}`)) collides = true;
88
+ }
89
+ }
90
+ if (!collides) return { ...DEFAULT_POSITION, x, y };
91
+ }
92
+ }
93
+ return { ...DEFAULT_POSITION };
94
+ }
95
+
96
+ function normalizePosition(start, end) {
97
+ const x1 = start % GRID_COLUMNS;
98
+ const y1 = Math.floor(start / GRID_COLUMNS);
99
+ const x2 = end % GRID_COLUMNS;
100
+ const y2 = Math.floor(end / GRID_COLUMNS);
101
+ return {
102
+ x: Math.min(x1, x2),
103
+ y: Math.min(y1, y2),
104
+ w: Math.abs(x2 - x1) + 1,
105
+ h: Math.abs(y2 - y1) + 1
106
+ };
107
+ }
108
+
109
+ function positionsOverlap(a, b) {
110
+ return (
111
+ a.x < b.x + b.w &&
112
+ a.x + a.w > b.x &&
113
+ a.y < b.y + b.h &&
114
+ a.y + a.h > b.y
115
+ );
116
+ }
117
+
118
+ function clampPositionToFreeSpace(position, widgets) {
119
+ const bounded = {
120
+ x: Math.max(0, Math.min(position.x, GRID_COLUMNS - 1)),
121
+ y: Math.max(0, Math.min(position.y, GRID_ROWS - 1)),
122
+ w: Math.max(1, Math.min(position.w, GRID_COLUMNS - position.x)),
123
+ h: Math.max(1, Math.min(position.h, GRID_ROWS - position.y))
124
+ };
125
+ const collides = widgets.some((widget) => positionsOverlap(bounded, widget.position));
126
+ return collides ? findFreePosition(widgets) : bounded;
127
+ }
128
+
129
+ function cellIndexFromGridPointer(event, gridElement) {
130
+ if (!gridElement) return null;
131
+ const rect = gridElement.getBoundingClientRect();
132
+ const styles = window.getComputedStyle(gridElement);
133
+ const paddingLeft = Number.parseFloat(styles.paddingLeft) || 0;
134
+ const paddingTop = Number.parseFloat(styles.paddingTop) || 0;
135
+ const paddingRight = Number.parseFloat(styles.paddingRight) || 0;
136
+ const gap = Number.parseFloat(styles.columnGap) || 0;
137
+ const x = event.clientX - rect.left - paddingLeft;
138
+ const y = event.clientY - rect.top - paddingTop;
139
+ const usableWidth = rect.width - paddingLeft - paddingRight - gap * (GRID_COLUMNS - 1);
140
+ const cellWidth = usableWidth / GRID_COLUMNS;
141
+ const cellHeight = 52;
142
+ const column = Math.max(0, Math.min(GRID_COLUMNS - 1, Math.floor(x / (cellWidth + gap))));
143
+ const row = Math.max(0, Math.min(GRID_ROWS - 1, Math.floor(y / (cellHeight + gap))));
144
+ if (x < 0 || y < 0) return null;
145
+ return row * GRID_COLUMNS + column;
146
+ }
147
+
148
+ function resizePositionFromCell(position, corner, index) {
149
+ const cellX = index % GRID_COLUMNS;
150
+ const cellY = Math.floor(index / GRID_COLUMNS);
151
+ const right = position.x + position.w - 1;
152
+ const bottom = position.y + position.h - 1;
153
+ if (corner === "nw") {
154
+ return {
155
+ x: Math.min(cellX, right),
156
+ y: Math.min(cellY, bottom),
157
+ w: Math.max(1, right - Math.min(cellX, right) + 1),
158
+ h: Math.max(1, bottom - Math.min(cellY, bottom) + 1)
159
+ };
160
+ }
161
+ if (corner === "ne") {
162
+ const y = Math.min(cellY, bottom);
163
+ return {
164
+ x: position.x,
165
+ y,
166
+ w: Math.max(1, Math.max(cellX, position.x) - position.x + 1),
167
+ h: Math.max(1, bottom - y + 1)
168
+ };
169
+ }
170
+ if (corner === "sw") {
171
+ const x = Math.min(cellX, right);
172
+ return {
173
+ x,
174
+ y: position.y,
175
+ w: Math.max(1, right - x + 1),
176
+ h: Math.max(1, Math.max(cellY, position.y) - position.y + 1)
177
+ };
178
+ }
179
+ return {
180
+ x: position.x,
181
+ y: position.y,
182
+ w: Math.max(1, Math.max(cellX, position.x) - position.x + 1),
183
+ h: Math.max(1, Math.max(cellY, position.y) - position.y + 1)
184
+ };
185
+ }
186
+
187
+ function todayIsoDate() {
188
+ return new Date().toISOString().slice(0, 10);
189
+ }
190
+
191
+ function widgetKindLabel(kind) {
192
+ if (kind === "iframe") return "iFrame";
193
+ if (kind === "rich-text") return "Rich Text";
194
+ return kind.charAt(0).toUpperCase() + kind.slice(1);
195
+ }
196
+
197
+ function cloneConfig(value) {
198
+ return JSON.parse(JSON.stringify(value));
199
+ }
200
+
201
+ function normalizeChartValues(value) {
202
+ return String(value)
203
+ .split(",")
204
+ .map((item) => Number(item.trim()))
205
+ .filter((item) => Number.isFinite(item));
206
+ }
207
+
208
+ function serializeChartValues(values) {
209
+ return (Array.isArray(values) ? values : []).join(", ");
210
+ }
211
+
212
+ function parseLineList(value) {
213
+ return String(value)
214
+ .split(",")
215
+ .map((item) => item.trim())
216
+ .filter(Boolean);
217
+ }
218
+
219
+ function serializeLineList(values) {
220
+ return (Array.isArray(values) ? values : []).join(", ");
221
+ }
222
+
223
+ function parseManualRows(value, columns) {
224
+ const activeColumns = columns.length ? columns : ["Name", "Domain Name"];
225
+ return String(value)
226
+ .split("\n")
227
+ .map((row) => row.trim())
228
+ .filter(Boolean)
229
+ .map((row) => {
230
+ const values = row.split("|").map((item) => item.trim());
231
+ return activeColumns.reduce((record, column, index) => {
232
+ record[column] = values[index] || "";
233
+ return record;
234
+ }, {});
235
+ });
236
+ }
237
+
238
+ function serializeManualRows(rows, columns) {
239
+ const activeColumns = columns.length ? columns : ["Name", "Domain Name"];
240
+ return (Array.isArray(rows) ? rows : [])
241
+ .map((row) => activeColumns.map((column) => row?.[column] || "").join(" | "))
242
+ .join("\n");
243
+ }
244
+
245
+ function hydrateTemplate(template) {
246
+ return {
247
+ name: template.name,
248
+ widgets: template.widgets.map((widget) => ({
249
+ ...cloneConfig(widget),
250
+ id: generateId("widget"),
251
+ config: cloneConfig(widget.config || defaultConfigFor(widget.kind))
252
+ }))
253
+ };
254
+ }
255
+
256
+ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart }) {
257
+ const viewColumns = widget.config?.columns?.length ? widget.config.columns : ["Name", "Domain Name"];
258
+ const viewRows = widget.config?.rows?.length ? widget.config.rows : SAMPLE_VIEW_ROWS;
259
+ const chartValues = widget.config?.values?.length ? widget.config.values : defaultConfigFor("chart").values;
260
+ return <article
261
+ className={`workspace-widget-preview${selected ? " selected" : ""}`}
262
+ onClick={onSelect}
263
+ style={{
264
+ gridColumn: `${widget.position.x + 1} / span ${widget.position.w}`,
265
+ gridRow: `${widget.position.y + 1} / span ${widget.position.h}`
266
+ }}
267
+ >
268
+ <div className="workspace-widget-preview-title">
269
+ <span aria-hidden="true">::</span>
270
+ <strong>{widget.title}</strong>
271
+ <button
272
+ aria-label={`Remove ${widget.title}`}
273
+ onClick={(event) => {
274
+ event.stopPropagation();
275
+ onRemove();
276
+ }}
277
+ type="button"
278
+ >x</button>
279
+ </div>
280
+ {widget.kind === "view" ? <div
281
+ className="workspace-view-table"
282
+ aria-label={`${widget.title} preview`}
283
+ style={{ "--workspace-view-columns": viewColumns.length }}
284
+ >
285
+ <div>{viewColumns.map((column) => <span key={column}>{column}</span>)}</div>
286
+ {viewRows.slice(0, 6).map((row, rowIndex) => <div key={rowIndex}>
287
+ {viewColumns.map((column) => <span key={column}>{row?.[column] || ""}</span>)}
288
+ </div>)}
289
+ <footer>Calculate</footer>
290
+ </div> : null}
291
+ {widget.kind === "iframe" ? <div className="workspace-iframe-preview">
292
+ {widget.config?.url ? <span>{widget.config.url}</span> : <span>Invalid URL</span>}
293
+ </div> : null}
294
+ {widget.kind === "rich-text" ? <p className="workspace-rich-text-preview">{widget.config?.text || "Start writing..."}</p> : null}
295
+ {widget.kind === "chart" ? <div className="workspace-chart-preview">
296
+ {chartValues.map((height, index) => <span key={index} style={{ height: `${Math.max(5, Math.min(100, height))}%` }} />)}
297
+ </div> : null}
298
+ {selected ? ["nw", "ne", "sw", "se"].map((corner) => <button
299
+ aria-label={`Resize ${widget.title} from ${corner} corner`}
300
+ className={`workspace-resize-handle ${corner}`}
301
+ key={corner}
302
+ onPointerDown={(event) => onResizeStart(corner, event)}
303
+ type="button"
304
+ />) : null}
305
+ </article>;
306
+ }
307
+
308
+ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter }) {
309
+ const [config, setConfig] = useState(initialConfig);
310
+ const [saving, setSaving] = useState(false);
311
+ const [panelOpen, setPanelOpen] = useState(true);
312
+ const gridRef = useRef(null);
313
+ const canvas = config.canvas;
314
+ const dashboards = config.dashboards;
315
+ const widgetTypes = config.widgetTypes;
316
+ const tabs = getTabs(canvas);
317
+ const activeTabId = getActiveTabId(canvas);
318
+ const activeTab = tabs.find((tab) => tab.id === activeTabId) || tabs[0];
319
+ const activeWidgets = activeTab.widgets || [];
320
+ const [selectedPosition, setSelectedPosition] = useState(() => findFreePosition(activeWidgets));
321
+ const [selectedWidgetId, setSelectedWidgetId] = useState(null);
322
+ const [dragStartCell, setDragStartCell] = useState(null);
323
+ const [dragPreview, setDragPreview] = useState(null);
324
+ const [resizeDrag, setResizeDrag] = useState(null);
325
+ const [configMessage, setConfigMessage] = useState("");
326
+ const resizeDragRef = useRef(null);
327
+ const importInputRef = useRef(null);
328
+ const addSlot = dragPreview || selectedPosition;
329
+ const selectedWidget = activeWidgets.find((widget) => widget.id === selectedWidgetId) || null;
330
+ const occupiedCells = useMemo(() => {
331
+ const cells = new Set();
332
+ for (const widget of activeWidgets) {
333
+ for (let dx = 0; dx < widget.position.w; dx += 1) {
334
+ for (let dy = 0; dy < widget.position.h; dy += 1) {
335
+ cells.add(`${widget.position.x + dx}:${widget.position.y + dy}`);
336
+ }
337
+ }
338
+ }
339
+ return cells;
340
+ }, [activeWidgets]);
341
+
342
+ const addWidget = useCallback((kind) => {
343
+ setConfig((prev) => {
344
+ const prevTabs = getTabs(prev.canvas);
345
+ const prevActiveId = getActiveTabId(prev.canvas);
346
+ const existingWidgets = prevTabs.find((tab) => tab.id === prevActiveId)?.widgets || [];
347
+ const position = clampPositionToFreeSpace(addSlot, existingWidgets);
348
+ const widget = {
349
+ id: generateId("widget"),
350
+ kind,
351
+ title: defaultTitleFor(kind),
352
+ position,
353
+ config: defaultConfigFor(kind)
354
+ };
355
+ const stableTabs = prevTabs.length === 1 && prevTabs[0].id === DEFAULT_TAB_ID
356
+ ? [{ ...prevTabs[0], id: DEFAULT_TAB_ID }]
357
+ : prevTabs;
358
+ const nextTabs = stableTabs.map((tab) =>
359
+ tab.id === prevActiveId ? { ...tab, widgets: [...(tab.widgets || []), widget] } : tab
360
+ );
361
+ setSelectedWidgetId(widget.id);
362
+ setSelectedPosition(findFreePosition([...existingWidgets, widget]));
363
+ setDragPreview(null);
364
+ return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, prevActiveId) };
365
+ });
366
+ }, [addSlot]);
367
+
368
+ const switchTab = useCallback((tabId) => {
369
+ setConfig((prev) => {
370
+ const prevTabs = getTabs(prev.canvas);
371
+ if (prevTabs.length <= 1) return prev;
372
+ if (!prevTabs.some((tab) => tab.id === tabId)) return prev;
373
+ const nextTab = prevTabs.find((tab) => tab.id === tabId);
374
+ setSelectedWidgetId(null);
375
+ setSelectedPosition(findFreePosition(nextTab?.widgets || []));
376
+ setDragPreview(null);
377
+ return { ...prev, canvas: commitTabs(prev.canvas, prevTabs, tabId) };
378
+ });
379
+ }, []);
380
+
381
+ const addTab = useCallback(() => {
382
+ setConfig((prev) => {
383
+ const prevTabs = getTabs(prev.canvas);
384
+ const stableFirst = prevTabs.length === 1 && prevTabs[0].id === DEFAULT_TAB_ID
385
+ ? { ...prevTabs[0], id: generateId("tab") }
386
+ : prevTabs[0];
387
+ const remaining = prevTabs.length === 1 ? [] : prevTabs.slice(1);
388
+ const allExisting = [stableFirst, ...remaining];
389
+ const newTab = {
390
+ id: generateId("tab"),
391
+ name: `Tab ${allExisting.length + 1}`,
392
+ widgets: []
393
+ };
394
+ const nextTabs = [...allExisting, newTab];
395
+ setSelectedWidgetId(null);
396
+ setSelectedPosition({ ...DEFAULT_POSITION });
397
+ setDragPreview(null);
398
+ return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, newTab.id) };
399
+ });
400
+ }, []);
401
+
402
+ const addDashboard = useCallback(() => {
403
+ setConfig((prev) => ({
404
+ ...prev,
405
+ dashboards: [
406
+ ...(prev.dashboards || []),
407
+ {
408
+ id: generateId("dashboard"),
409
+ name: "Untitled",
410
+ createdBy: "Workspace owner",
411
+ updatedAt: "new",
412
+ status: "draft"
413
+ }
414
+ ]
415
+ }));
416
+ }, []);
417
+
418
+ const duplicateDashboard = useCallback(() => {
419
+ setConfig((prev) => {
420
+ const source = prev.dashboards?.[0] || {
421
+ name: "Untitled",
422
+ createdBy: "Workspace owner",
423
+ updatedAt: "new",
424
+ status: "draft"
425
+ };
426
+ return {
427
+ ...prev,
428
+ dashboards: [
429
+ ...(prev.dashboards || []),
430
+ {
431
+ ...source,
432
+ id: generateId("dashboard"),
433
+ name: `${source.name} Copy`,
434
+ updatedAt: "new",
435
+ status: "draft"
436
+ }
437
+ ]
438
+ };
439
+ });
440
+ }, []);
441
+
442
+ const duplicateTab = useCallback(() => {
443
+ setConfig((prev) => {
444
+ const prevTabs = getTabs(prev.canvas);
445
+ const prevActiveId = getActiveTabId(prev.canvas);
446
+ const source = prevTabs.find((tab) => tab.id === prevActiveId) || prevTabs[0];
447
+ const stableFirst = prevTabs.length === 1 && prevTabs[0].id === DEFAULT_TAB_ID
448
+ ? { ...prevTabs[0], id: generateId("tab") }
449
+ : prevTabs[0];
450
+ const stableTabs = prevTabs.length === 1 ? [stableFirst] : prevTabs;
451
+ const cloned = {
452
+ id: generateId("tab"),
453
+ name: `${source.name} Copy`,
454
+ widgets: (source.widgets || []).map((widget) => ({
455
+ ...cloneConfig(widget),
456
+ id: generateId("widget")
457
+ }))
458
+ };
459
+ const nextTabs = [...stableTabs, cloned];
460
+ setSelectedWidgetId(null);
461
+ setSelectedPosition(findFreePosition(cloned.widgets));
462
+ setDragPreview(null);
463
+ return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, cloned.id) };
464
+ });
465
+ }, []);
466
+
467
+ const applyTemplate = useCallback((templateId) => {
468
+ const template = DASHBOARD_TEMPLATES.find((item) => item.id === templateId);
469
+ if (!template) return;
470
+ setConfig((prev) => {
471
+ const hydrated = hydrateTemplate(template);
472
+ const prevTabs = getTabs(prev.canvas);
473
+ const prevActiveId = getActiveTabId(prev.canvas);
474
+ const stableTabs = prevTabs.length === 1 && prevTabs[0].id === DEFAULT_TAB_ID
475
+ ? [{ ...prevTabs[0], id: DEFAULT_TAB_ID }]
476
+ : prevTabs;
477
+ const nextTabs = stableTabs.map((tab) =>
478
+ tab.id === prevActiveId ? { ...tab, name: hydrated.name, widgets: hydrated.widgets } : tab
479
+ );
480
+ setSelectedWidgetId(null);
481
+ setSelectedPosition(findFreePosition(hydrated.widgets));
482
+ setDragPreview(null);
483
+ setConfigMessage(`Applied ${template.name}`);
484
+ return {
485
+ ...prev,
486
+ dashboards: (prev.dashboards || []).map((dashboard, index) =>
487
+ index === 0 ? { ...dashboard, name: template.name, updatedAt: "new", status: "draft" } : dashboard
488
+ ),
489
+ canvas: commitTabs(prev.canvas, nextTabs, prevActiveId)
490
+ };
491
+ });
492
+ }, []);
493
+
494
+ const exportConfig = useCallback(() => {
495
+ const blob = new Blob([`${JSON.stringify({
496
+ dashboards: config.dashboards,
497
+ widgetTypes: config.widgetTypes,
498
+ canvas: config.canvas
499
+ }, null, 2)}\n`], { type: "application/json" });
500
+ const url = URL.createObjectURL(blob);
501
+ const anchor = document.createElement("a");
502
+ anchor.href = url;
503
+ anchor.download = "growthub-dashboard.config.json";
504
+ anchor.click();
505
+ URL.revokeObjectURL(url);
506
+ setConfigMessage("Exported dashboard config");
507
+ }, [config]);
508
+
509
+ const importConfig = useCallback(async (event) => {
510
+ const file = event.target.files?.[0];
511
+ if (!file) return;
512
+ try {
513
+ const imported = JSON.parse(await file.text());
514
+ validateWorkspaceConfig(imported);
515
+ setConfig((prev) => ({
516
+ ...prev,
517
+ dashboards: imported.dashboards,
518
+ widgetTypes: imported.widgetTypes,
519
+ canvas: imported.canvas
520
+ }));
521
+ const importedTabs = getTabs(imported.canvas);
522
+ setSelectedWidgetId(null);
523
+ setSelectedPosition(findFreePosition(importedTabs[0]?.widgets || []));
524
+ setDragPreview(null);
525
+ setConfigMessage(`Imported ${file.name}`);
526
+ } catch (error) {
527
+ setConfigMessage(error.message || "Import failed");
528
+ } finally {
529
+ event.target.value = "";
530
+ }
531
+ }, []);
532
+
533
+ const save = useCallback(async () => {
534
+ if (saving) return;
535
+ setSaving(true);
536
+ try {
537
+ const stamp = todayIsoDate();
538
+ const updatedDashboards = (config.dashboards || []).map((dashboard, index) =>
539
+ index === 0 ? { ...dashboard, updatedAt: stamp } : dashboard
540
+ );
541
+ const response = await fetch("/api/workspace", {
542
+ method: "PATCH",
543
+ headers: { "content-type": "application/json" },
544
+ body: JSON.stringify({
545
+ dashboards: updatedDashboards,
546
+ widgetTypes: config.widgetTypes,
547
+ canvas: config.canvas
548
+ })
549
+ });
550
+ const payload = await response.json();
551
+ if (response.ok && payload.workspaceConfig) {
552
+ setConfig(payload.workspaceConfig);
553
+ setConfigMessage("Saved dashboard config");
554
+ } else {
555
+ setConfigMessage(payload.error || "Save failed");
556
+ }
557
+ } catch (error) {
558
+ setConfigMessage(error.message || "Save failed");
559
+ } finally {
560
+ setSaving(false);
561
+ }
562
+ }, [saving, config]);
563
+
564
+ const reopenPanel = useCallback(() => setPanelOpen(true), []);
565
+ const closePanel = useCallback(() => setPanelOpen(false), []);
566
+ const beginCellDrag = useCallback((index, event) => {
567
+ const x = index % GRID_COLUMNS;
568
+ const y = Math.floor(index / GRID_COLUMNS);
569
+ if (occupiedCells.has(`${x}:${y}`)) return;
570
+ event.preventDefault();
571
+ const position = normalizePosition(index, index);
572
+ setSelectedWidgetId(null);
573
+ setDragStartCell(index);
574
+ setDragPreview(position);
575
+ setPanelOpen(true);
576
+ }, [occupiedCells]);
577
+ const updateCellDrag = useCallback((index) => {
578
+ if (dragStartCell === null) return;
579
+ setDragPreview(normalizePosition(dragStartCell, index));
580
+ }, [dragStartCell]);
581
+ const finishCellDrag = useCallback((index) => {
582
+ if (dragStartCell === null) return;
583
+ const position = normalizePosition(dragStartCell, index);
584
+ setSelectedPosition(clampPositionToFreeSpace(position, activeWidgets));
585
+ setDragStartCell(null);
586
+ setDragPreview(null);
587
+ setPanelOpen(true);
588
+ }, [activeWidgets, dragStartCell]);
589
+ const updatePointerDrag = useCallback((event) => {
590
+ if (dragStartCell === null) return;
591
+ const index = cellIndexFromGridPointer(event, gridRef.current);
592
+ if (index !== null) updateCellDrag(index);
593
+ }, [dragStartCell, updateCellDrag]);
594
+ const finishPointerDrag = useCallback((event) => {
595
+ if (dragStartCell === null) return;
596
+ const index = cellIndexFromGridPointer(event, gridRef.current);
597
+ finishCellDrag(index ?? dragStartCell);
598
+ }, [dragStartCell, finishCellDrag]);
599
+ const beginResizeDrag = useCallback((widget, corner, event) => {
600
+ event.preventDefault();
601
+ event.stopPropagation();
602
+ event.currentTarget.setPointerCapture?.(event.pointerId);
603
+ const nextResizeDrag = { widgetId: widget.id, corner, originalPosition: widget.position };
604
+ setSelectedWidgetId(widget.id);
605
+ resizeDragRef.current = nextResizeDrag;
606
+ setResizeDrag(nextResizeDrag);
607
+ }, []);
608
+ const updateResizeDrag = useCallback((event) => {
609
+ const activeResizeDrag = resizeDragRef.current;
610
+ if (!activeResizeDrag) return;
611
+ event.preventDefault();
612
+ const index = cellIndexFromGridPointer(event, gridRef.current);
613
+ if (index === null) return;
614
+ const nextPosition = resizePositionFromCell(activeResizeDrag.originalPosition, activeResizeDrag.corner, index);
615
+ const otherWidgets = activeWidgets.filter((widget) => widget.id !== activeResizeDrag.widgetId);
616
+ if (otherWidgets.some((widget) => positionsOverlap(nextPosition, widget.position))) return;
617
+ setConfig((prev) => {
618
+ const prevTabs = getTabs(prev.canvas);
619
+ const prevActiveId = getActiveTabId(prev.canvas);
620
+ const nextTabs = prevTabs.map((tab) => {
621
+ if (tab.id !== prevActiveId) return tab;
622
+ return {
623
+ ...tab,
624
+ widgets: (tab.widgets || []).map((widget) =>
625
+ widget.id === activeResizeDrag.widgetId ? { ...widget, position: nextPosition } : widget
626
+ )
627
+ };
628
+ });
629
+ return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, prevActiveId) };
630
+ });
631
+ }, [activeWidgets]);
632
+ const finishResizeDrag = useCallback(() => {
633
+ if (!resizeDragRef.current) return;
634
+ resizeDragRef.current = null;
635
+ setResizeDrag(null);
636
+ }, []);
637
+ const selectWidget = useCallback((widgetId) => {
638
+ setSelectedWidgetId(widgetId);
639
+ setPanelOpen(true);
640
+ }, []);
641
+ const updateSelectedWidget = useCallback((updates) => {
642
+ if (!selectedWidgetId) return;
643
+ setConfig((prev) => {
644
+ const prevTabs = getTabs(prev.canvas);
645
+ const prevActiveId = getActiveTabId(prev.canvas);
646
+ const nextTabs = prevTabs.map((tab) => {
647
+ if (tab.id !== prevActiveId) return tab;
648
+ return {
649
+ ...tab,
650
+ widgets: (tab.widgets || []).map((widget) =>
651
+ widget.id === selectedWidgetId ? { ...widget, ...updates } : widget
652
+ )
653
+ };
654
+ });
655
+ return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, prevActiveId) };
656
+ });
657
+ }, [selectedWidgetId]);
658
+ const updateSelectedWidgetConfig = useCallback((updates) => {
659
+ if (!selectedWidget) return;
660
+ updateSelectedWidget({ config: { ...(selectedWidget.config || {}), ...updates } });
661
+ }, [selectedWidget, updateSelectedWidget]);
662
+ const removeSelectedWidget = useCallback((widgetId) => {
663
+ setConfig((prev) => {
664
+ const prevTabs = getTabs(prev.canvas);
665
+ const prevActiveId = getActiveTabId(prev.canvas);
666
+ const nextTabs = prevTabs.map((tab) => {
667
+ if (tab.id !== prevActiveId) return tab;
668
+ return { ...tab, widgets: (tab.widgets || []).filter((widget) => widget.id !== widgetId) };
669
+ });
670
+ const nextActiveWidgets = nextTabs.find((tab) => tab.id === prevActiveId)?.widgets || [];
671
+ setSelectedWidgetId(null);
672
+ setSelectedPosition(findFreePosition(nextActiveWidgets));
673
+ return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, prevActiveId) };
674
+ });
675
+ }, []);
676
+
677
+ const builderStyle = panelOpen ? undefined : { gridTemplateColumns: COLLAPSED_GRID_COLUMNS };
678
+
679
+ return <main className="workspace-builder" style={builderStyle}>
680
+ <aside className="workspace-rail" aria-label="Workspace navigation">
681
+ <div className="workspace-brand">
682
+ <span className="workspace-mark">G</span>
683
+ <span>Growthub Workspace</span>
684
+ </div>
685
+ <nav className="workspace-nav">
686
+ <a className="active" href="#dashboards">Dashboards</a>
687
+ <a href="#canvas">Canvas</a>
688
+ <a href="#widgets" onClick={reopenPanel}>Widgets</a>
689
+ <a href="#bindings" onClick={reopenPanel}>Bindings</a>
690
+ <Link href="/settings/integrations">Integrations</Link>
691
+ </nav>
692
+ <div className="workspace-rail-status">
693
+ <span className="status-dot" />
694
+ {integrationAdapter.authority}
695
+ </div>
696
+ </aside>
697
+
698
+ <section className="workspace-surface">
699
+ <header className="workspace-toolbar">
700
+ <div>
701
+ <p>Official starter</p>
702
+ <h1>{config.name}</h1>
703
+ </div>
704
+ <div className="workspace-toolbar-actions">
705
+ <select aria-label="Apply dashboard template" defaultValue="" onChange={(event) => {
706
+ if (event.target.value) applyTemplate(event.target.value);
707
+ event.target.value = "";
708
+ }}>
709
+ <option value="">Templates</option>
710
+ {DASHBOARD_TEMPLATES.map((template) => <option key={template.id} value={template.id}>{template.name}</option>)}
711
+ </select>
712
+ <button type="button" onClick={addDashboard}>New Dashboard</button>
713
+ <button type="button" onClick={duplicateDashboard}>Duplicate Dashboard</button>
714
+ <button type="button" onClick={() => importInputRef.current?.click()}>Import</button>
715
+ <button type="button" onClick={exportConfig}>Export</button>
716
+ <button type="button" onClick={save} disabled={saving}>{saving ? "Saving..." : "Save"}</button>
717
+ </div>
718
+ <input
719
+ ref={importInputRef}
720
+ type="file"
721
+ accept="application/json,.json"
722
+ className="workspace-hidden-input"
723
+ onChange={importConfig}
724
+ />
725
+ </header>
726
+ {configMessage ? <p className="workspace-config-message">{configMessage}</p> : null}
727
+
728
+ <section className="workspace-table" id="dashboards" aria-label="Dashboards">
729
+ <div className="workspace-table-heading">
730
+ <strong>Dashboards</strong>
731
+ <span>{dashboards.length} template</span>
732
+ </div>
733
+ <div className="workspace-table-row workspace-table-head">
734
+ <span>Title</span>
735
+ <span>Created by</span>
736
+ <span>Last update</span>
737
+ <span>Status</span>
738
+ </div>
739
+ {dashboards.map((dashboard) => <div className="workspace-table-row" key={dashboard.id}>
740
+ <span>{dashboard.name}</span>
741
+ <span>{dashboard.createdBy}</span>
742
+ <span>{dashboard.updatedAt}</span>
743
+ <code>{dashboard.status}</code>
744
+ </div>)}
745
+ </section>
746
+
747
+ <section className="workspace-canvas" id="canvas" aria-label="Composable dashboard canvas">
748
+ <div className="workspace-tabs">
749
+ {tabs.map((tab) => <button
750
+ key={tab.id}
751
+ className={tab.id === activeTabId ? "active" : ""}
752
+ type="button"
753
+ onClick={() => switchTab(tab.id)}
754
+ >{tab.name}</button>)}
755
+ <button type="button" onClick={addTab}>New Tab</button>
756
+ <button type="button" onClick={duplicateTab}>Duplicate Tab</button>
757
+ </div>
758
+ <div
759
+ className="workspace-grid"
760
+ ref={gridRef}
761
+ onPointerMove={updatePointerDrag}
762
+ onPointerUp={(event) => {
763
+ finishPointerDrag(event);
764
+ finishResizeDrag();
765
+ }}
766
+ onPointerLeave={finishResizeDrag}
767
+ onPointerMoveCapture={updateResizeDrag}
768
+ style={{ "--workspace-columns": canvas.layout.columns, "--workspace-rows": GRID_ROWS }}
769
+ >
770
+ {Array.from({ length: GRID_CELL_COUNT }).map((_, index) => {
771
+ const x = index % GRID_COLUMNS;
772
+ const y = Math.floor(index / GRID_COLUMNS);
773
+ const isOccupied = occupiedCells.has(`${x}:${y}`);
774
+ return <button
775
+ aria-label={`Select cell ${x + 1}, ${y + 1}`}
776
+ aria-hidden={isOccupied ? "true" : undefined}
777
+ className="workspace-grid-cell"
778
+ data-cell-index={index}
779
+ disabled={isOccupied}
780
+ key={index}
781
+ onPointerDown={(event) => beginCellDrag(index, event)}
782
+ style={{
783
+ gridColumn: `${x + 1} / span 1`,
784
+ gridRow: `${y + 1} / span 1`
785
+ }}
786
+ type="button"
787
+ />;
788
+ })}
789
+ <button className={`workspace-add-widget${dragPreview ? " selecting" : ""}`} type="button" onClick={() => setPanelOpen(true)} style={{
790
+ gridColumn: `${addSlot.x + 1} / span ${addSlot.w}`,
791
+ gridRow: `${addSlot.y + 1} / span ${addSlot.h}`
792
+ }}>
793
+ <span className="workspace-widget-icon" aria-hidden="true"><span /></span>
794
+ <strong>Add widget</strong>
795
+ <small>Click to add your first widget</small>
796
+ </button>
797
+ {activeWidgets.map((widget) => <WidgetPreview
798
+ key={widget.id}
799
+ onRemove={() => removeSelectedWidget(widget.id)}
800
+ onResizeStart={(corner, event) => beginResizeDrag(widget, corner, event)}
801
+ onSelect={() => selectWidget(widget.id)}
802
+ selected={widget.id === selectedWidgetId}
803
+ widget={widget}
804
+ />)}
805
+ </div>
806
+ </section>
807
+ </section>
808
+
809
+ {panelOpen ? <aside className="workspace-widget-panel" id="widgets" aria-label="Widget configuration">
810
+ <div className="workspace-panel-title">
811
+ <button type="button" aria-label="Close widget panel" onClick={closePanel}>x</button>
812
+ <span aria-hidden="true">+</span>
813
+ <strong>{selectedWidget ? selectedWidget.title : "New widget"}</strong>
814
+ {selectedWidget ? <em>{widgetKindLabel(selectedWidget.kind)}</em> : null}
815
+ </div>
816
+ {selectedWidget ? <section className="workspace-widget-settings">
817
+ <label>
818
+ <span>Title</span>
819
+ <input value={selectedWidget.title} onChange={(event) => updateSelectedWidget({ title: event.target.value })} />
820
+ </label>
821
+ {selectedWidget.kind === "chart" ? <section className="workspace-field-stack">
822
+ <label>
823
+ <span>Sample Values</span>
824
+ <input
825
+ value={serializeChartValues(selectedWidget.config?.values || [])}
826
+ onChange={(event) => updateSelectedWidgetConfig({ values: normalizeChartValues(event.target.value) })}
827
+ />
828
+ </label>
829
+ <label>
830
+ <span>Static Binding</span>
831
+ <select
832
+ value={selectedWidget.config?.binding?.mode || "json"}
833
+ onChange={(event) => updateSelectedWidgetConfig({
834
+ binding: event.target.value === "csv" ? SAMPLE_DATA_BINDINGS.contentCsv : SAMPLE_DATA_BINDINGS.reportingJson
835
+ })}
836
+ >
837
+ <option value="json">Sample JSON</option>
838
+ <option value="csv">Sample CSV</option>
839
+ </select>
840
+ </label>
841
+ </section> : null}
842
+ {selectedWidget.kind === "iframe" ? <label>
843
+ <span>URL to Embed</span>
844
+ <input
845
+ placeholder="https://example.com/embed"
846
+ value={selectedWidget.config?.url || ""}
847
+ onChange={(event) => updateSelectedWidgetConfig({ url: event.target.value })}
848
+ />
849
+ </label> : null}
850
+ {selectedWidget.kind === "rich-text" ? <label>
851
+ <span>Content</span>
852
+ <textarea
853
+ placeholder="Write text..."
854
+ value={selectedWidget.config?.text || ""}
855
+ onChange={(event) => updateSelectedWidgetConfig({ text: event.target.value })}
856
+ />
857
+ </label> : null}
858
+ {selectedWidget.kind === "view" ? <section className="workspace-field-stack">
859
+ <label>
860
+ <span>Source</span>
861
+ <input
862
+ value={selectedWidget.config?.source || ""}
863
+ onChange={(event) => updateSelectedWidgetConfig({ source: event.target.value })}
864
+ />
865
+ </label>
866
+ <label>
867
+ <span>Columns</span>
868
+ <input
869
+ value={serializeLineList(selectedWidget.config?.columns || [])}
870
+ onChange={(event) => updateSelectedWidgetConfig({ columns: parseLineList(event.target.value) })}
871
+ />
872
+ </label>
873
+ <label>
874
+ <span>Manual Rows</span>
875
+ <textarea
876
+ value={serializeManualRows(selectedWidget.config?.rows || [], selectedWidget.config?.columns || [])}
877
+ onChange={(event) => {
878
+ const columns = selectedWidget.config?.columns?.length ? selectedWidget.config.columns : ["Name", "Domain Name"];
879
+ updateSelectedWidgetConfig({
880
+ rows: parseManualRows(event.target.value, columns),
881
+ binding: { mode: "manual", source: "Manual rows", rows: parseManualRows(event.target.value, columns) }
882
+ });
883
+ }}
884
+ />
885
+ </label>
886
+ <label>
887
+ <span>Static Binding</span>
888
+ <select
889
+ value={selectedWidget.config?.binding?.mode || "manual"}
890
+ onChange={(event) => {
891
+ const binding = event.target.value === "csv" ? SAMPLE_DATA_BINDINGS.contentCsv : SAMPLE_DATA_BINDINGS.companiesManual;
892
+ updateSelectedWidgetConfig({ binding });
893
+ }}
894
+ >
895
+ <option value="manual">Manual Rows</option>
896
+ <option value="csv">Sample CSV</option>
897
+ </select>
898
+ </label>
899
+ <div className="workspace-settings-list">
900
+ <p className="workspace-panel-label">Settings</p>
901
+ <div><span>Layout</span><code>{selectedWidget.config?.layout || "Table"}</code></div>
902
+ <div><span>Source</span><code>{selectedWidget.config?.source || "Companies"}</code></div>
903
+ <div><span>Fields</span><code>{selectedWidget.config?.columns?.length || 2} shown</code></div>
904
+ <div><span>Filter</span><code>›</code></div>
905
+ <div><span>Sort</span><code>›</code></div>
906
+ </div>
907
+ </section> : null}
908
+ {selectedWidget.kind === "rich-text" ? <label>
909
+ <span>Static Binding</span>
910
+ <select
911
+ value={selectedWidget.config?.binding?.mode || "manual"}
912
+ onChange={(event) => updateSelectedWidgetConfig({
913
+ binding: { mode: event.target.value, source: event.target.value === "manual" ? "Manual text" : "Sample JSON", rows: [] }
914
+ })}
915
+ >
916
+ <option value="manual">Manual Text</option>
917
+ <option value="json">Sample JSON</option>
918
+ </select>
919
+ </label> : null}
920
+ <div className="workspace-settings-list">
921
+ <p className="workspace-panel-label">Placement</p>
922
+ <div><span>Size</span><code>{selectedWidget.position.w} x {selectedWidget.position.h}</code></div>
923
+ <div><span>Origin</span><code>{selectedWidget.position.x + 1}, {selectedWidget.position.y + 1}</code></div>
924
+ </div>
925
+ </section> : <section>
926
+ <p className="workspace-panel-label">Widget type</p>
927
+ <div className="workspace-widget-types">
928
+ {widgetTypes.map((widget) => <button type="button" key={widget.kind} onClick={() => addWidget(widget.kind)}>
929
+ <span>{widget.icon}</span>
930
+ {widget.label}
931
+ </button>)}
932
+ </div>
933
+ </section>}
934
+ <section className="workspace-bindings" id="bindings">
935
+ <p className="workspace-panel-label">Config bindings</p>
936
+ {Object.entries(canvas.bindings).map(([key, value]) => <div key={key}>
937
+ <span>{key}</span>
938
+ <code>{String(value)}</code>
939
+ </div>)}
940
+ <div>
941
+ <span>integrationAdapter</span>
942
+ <code>{adapterConfig.integrationAdapter}</code>
943
+ </div>
944
+ </section>
945
+ </aside> : null}
946
+ </main>;
947
+ }
948
+
949
+ export {
950
+ WorkspaceBuilder as default
951
+ };