@growthub/cli 0.9.3 → 0.9.5

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 (46) hide show
  1. package/assets/worker-kits/growthub-agency-portal-starter-v1/apps/agency-portal/growthub.config.json +112 -0
  2. package/assets/worker-kits/growthub-agency-portal-starter-v1/apps/agency-portal/package.json +1 -1
  3. package/assets/worker-kits/growthub-agency-portal-starter-v1/bundles/growthub-agency-portal-starter-v1.json +1 -0
  4. package/assets/worker-kits/growthub-agency-portal-starter-v1/kit.json +2 -0
  5. package/assets/worker-kits/growthub-creative-video-pipeline-v1/apps/creative-video-pipeline/package.json +1 -1
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/SKILL.md +35 -1
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/.env.example +41 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +38 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/settings/integrations/route.js +13 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/route.js +91 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +912 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/layout.jsx +14 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/page.jsx +23 -0
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +105 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +680 -0
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/growthub.config.json +53 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/jsconfig.json +8 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/auth/index.js +21 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/env.js +28 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/growthub-connection-normalizer.js +95 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/index.js +198 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/payments/index.js +13 -0
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/persistence/index.js +13 -0
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/persistence/postgres.js +16 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/persistence/provider-managed.js +16 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/persistence/qstash-kv.js +16 -0
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/domain/integrations.js +185 -0
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/domain/portal.js +150 -0
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +232 -0
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/next.config.js +10 -0
  31. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package-lock.json +976 -0
  32. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +17 -0
  33. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/postcss.config.mjs +3 -0
  34. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/vercel.json +5 -0
  35. package/assets/worker-kits/growthub-custom-workspace-starter-v1/bundles/growthub-custom-workspace-starter-v1.json +13 -0
  36. package/assets/worker-kits/growthub-custom-workspace-starter-v1/docs/adapter-contracts.md +86 -0
  37. package/assets/worker-kits/growthub-custom-workspace-starter-v1/docs/vercel-serverless-deployment.md +46 -0
  38. package/assets/worker-kits/growthub-custom-workspace-starter-v1/growthub.config.json +49 -0
  39. package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/check-self-improving-health.sh +60 -0
  40. package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/promote-capability.mjs +37 -0
  41. package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/propose-capability.mjs +38 -0
  42. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +45 -0
  43. package/assets/worker-kits/growthub-custom-workspace-starter-v1/templates/deployment-handoff.md +61 -0
  44. package/assets/worker-kits/growthub-custom-workspace-starter-v1/templates/supabase-setup-plan.md +26 -0
  45. package/dist/index.js +2903 -1596
  46. package/package.json +2 -1
@@ -0,0 +1,680 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useCallback, useMemo, useRef, useState } from "react";
5
+
6
+ const DEFAULT_POSITION = { x: 4, y: 0, w: 4, h: 5 };
7
+ const GRID_COLUMNS = 12;
8
+ const GRID_ROWS = 16;
9
+ const GRID_CELL_COUNT = GRID_COLUMNS * GRID_ROWS;
10
+ const DEFAULT_TAB_ID = "tab-default";
11
+ const COLLAPSED_GRID_COLUMNS = "220px minmax(0, 1fr)";
12
+
13
+ function generateId(prefix) {
14
+ if (typeof globalThis !== "undefined" && globalThis.crypto?.randomUUID) {
15
+ return `${prefix}_${globalThis.crypto.randomUUID()}`;
16
+ }
17
+ return `${prefix}_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`;
18
+ }
19
+
20
+ function defaultTitleFor(kind) {
21
+ switch (kind) {
22
+ case "chart": return "Untitled chart";
23
+ case "view": return "Companies";
24
+ case "iframe": return "Untitled iFrame";
25
+ case "rich-text": return "Untitled Rich Text";
26
+ default: return "Untitled widget";
27
+ }
28
+ }
29
+
30
+ function defaultConfigFor(kind) {
31
+ switch (kind) {
32
+ case "view":
33
+ return { source: "Companies", layout: "Table" };
34
+ case "iframe":
35
+ return { url: "" };
36
+ case "rich-text":
37
+ return { text: "" };
38
+ default:
39
+ return {};
40
+ }
41
+ }
42
+
43
+ function getTabs(canvas) {
44
+ if (Array.isArray(canvas?.tabs) && canvas.tabs.length > 0) {
45
+ return canvas.tabs;
46
+ }
47
+ return [{
48
+ id: DEFAULT_TAB_ID,
49
+ name: canvas?.name || "Tab 1",
50
+ widgets: Array.isArray(canvas?.widgets) ? canvas.widgets : []
51
+ }];
52
+ }
53
+
54
+ function getActiveTabId(canvas) {
55
+ const tabs = getTabs(canvas);
56
+ if (canvas?.activeTabId && tabs.some((tab) => tab.id === canvas.activeTabId)) {
57
+ return canvas.activeTabId;
58
+ }
59
+ return tabs[0].id;
60
+ }
61
+
62
+ function commitTabs(canvas, tabs, activeTabId) {
63
+ const next = { ...canvas };
64
+ if (tabs.length <= 1) {
65
+ const tab = tabs[0];
66
+ next.name = tab.name;
67
+ next.widgets = tab.widgets;
68
+ delete next.tabs;
69
+ delete next.activeTabId;
70
+ } else {
71
+ next.tabs = tabs;
72
+ next.activeTabId = activeTabId;
73
+ delete next.widgets;
74
+ delete next.name;
75
+ }
76
+ return next;
77
+ }
78
+
79
+ function findFreePosition(widgets) {
80
+ const occupied = new Set();
81
+ for (const widget of widgets) {
82
+ for (let dx = 0; dx < widget.position.w; dx += 1) {
83
+ for (let dy = 0; dy < widget.position.h; dy += 1) {
84
+ occupied.add(`${widget.position.x + dx}:${widget.position.y + dy}`);
85
+ }
86
+ }
87
+ }
88
+ for (let y = 0; y <= GRID_ROWS - DEFAULT_POSITION.h; y += 1) {
89
+ for (let x = 0; x <= GRID_COLUMNS - DEFAULT_POSITION.w; x += 1) {
90
+ let collides = false;
91
+ for (let dx = 0; dx < DEFAULT_POSITION.w && !collides; dx += 1) {
92
+ for (let dy = 0; dy < DEFAULT_POSITION.h && !collides; dy += 1) {
93
+ if (occupied.has(`${x + dx}:${y + dy}`)) collides = true;
94
+ }
95
+ }
96
+ if (!collides) return { ...DEFAULT_POSITION, x, y };
97
+ }
98
+ }
99
+ return { ...DEFAULT_POSITION };
100
+ }
101
+
102
+ function normalizePosition(start, end) {
103
+ const x1 = start % GRID_COLUMNS;
104
+ const y1 = Math.floor(start / GRID_COLUMNS);
105
+ const x2 = end % GRID_COLUMNS;
106
+ const y2 = Math.floor(end / GRID_COLUMNS);
107
+ return {
108
+ x: Math.min(x1, x2),
109
+ y: Math.min(y1, y2),
110
+ w: Math.abs(x2 - x1) + 1,
111
+ h: Math.abs(y2 - y1) + 1
112
+ };
113
+ }
114
+
115
+ function positionsOverlap(a, b) {
116
+ return (
117
+ a.x < b.x + b.w &&
118
+ a.x + a.w > b.x &&
119
+ a.y < b.y + b.h &&
120
+ a.y + a.h > b.y
121
+ );
122
+ }
123
+
124
+ function clampPositionToFreeSpace(position, widgets) {
125
+ const bounded = {
126
+ x: Math.max(0, Math.min(position.x, GRID_COLUMNS - 1)),
127
+ y: Math.max(0, Math.min(position.y, GRID_ROWS - 1)),
128
+ w: Math.max(1, Math.min(position.w, GRID_COLUMNS - position.x)),
129
+ h: Math.max(1, Math.min(position.h, GRID_ROWS - position.y))
130
+ };
131
+ const collides = widgets.some((widget) => positionsOverlap(bounded, widget.position));
132
+ return collides ? findFreePosition(widgets) : bounded;
133
+ }
134
+
135
+ function cellIndexFromGridPointer(event, gridElement) {
136
+ if (!gridElement) return null;
137
+ const rect = gridElement.getBoundingClientRect();
138
+ const styles = window.getComputedStyle(gridElement);
139
+ const paddingLeft = Number.parseFloat(styles.paddingLeft) || 0;
140
+ const paddingTop = Number.parseFloat(styles.paddingTop) || 0;
141
+ const paddingRight = Number.parseFloat(styles.paddingRight) || 0;
142
+ const gap = Number.parseFloat(styles.columnGap) || 0;
143
+ const x = event.clientX - rect.left - paddingLeft;
144
+ const y = event.clientY - rect.top - paddingTop;
145
+ const usableWidth = rect.width - paddingLeft - paddingRight - gap * (GRID_COLUMNS - 1);
146
+ const cellWidth = usableWidth / GRID_COLUMNS;
147
+ const cellHeight = 52;
148
+ const column = Math.max(0, Math.min(GRID_COLUMNS - 1, Math.floor(x / (cellWidth + gap))));
149
+ const row = Math.max(0, Math.min(GRID_ROWS - 1, Math.floor(y / (cellHeight + gap))));
150
+ if (x < 0 || y < 0) return null;
151
+ return row * GRID_COLUMNS + column;
152
+ }
153
+
154
+ function resizePositionFromCell(position, corner, index) {
155
+ const cellX = index % GRID_COLUMNS;
156
+ const cellY = Math.floor(index / GRID_COLUMNS);
157
+ const right = position.x + position.w - 1;
158
+ const bottom = position.y + position.h - 1;
159
+ if (corner === "nw") {
160
+ return {
161
+ x: Math.min(cellX, right),
162
+ y: Math.min(cellY, bottom),
163
+ w: Math.max(1, right - Math.min(cellX, right) + 1),
164
+ h: Math.max(1, bottom - Math.min(cellY, bottom) + 1)
165
+ };
166
+ }
167
+ if (corner === "ne") {
168
+ const y = Math.min(cellY, bottom);
169
+ return {
170
+ x: position.x,
171
+ y,
172
+ w: Math.max(1, Math.max(cellX, position.x) - position.x + 1),
173
+ h: Math.max(1, bottom - y + 1)
174
+ };
175
+ }
176
+ if (corner === "sw") {
177
+ const x = Math.min(cellX, right);
178
+ return {
179
+ x,
180
+ y: position.y,
181
+ w: Math.max(1, right - x + 1),
182
+ h: Math.max(1, Math.max(cellY, position.y) - position.y + 1)
183
+ };
184
+ }
185
+ return {
186
+ x: position.x,
187
+ y: position.y,
188
+ w: Math.max(1, Math.max(cellX, position.x) - position.x + 1),
189
+ h: Math.max(1, Math.max(cellY, position.y) - position.y + 1)
190
+ };
191
+ }
192
+
193
+ function todayIsoDate() {
194
+ return new Date().toISOString().slice(0, 10);
195
+ }
196
+
197
+ function widgetKindLabel(kind) {
198
+ if (kind === "iframe") return "iFrame";
199
+ if (kind === "rich-text") return "Rich Text";
200
+ return kind.charAt(0).toUpperCase() + kind.slice(1);
201
+ }
202
+
203
+ function WidgetPreview({ widget, selected, onSelect, onRemove, onResizeStart }) {
204
+ return <article
205
+ className={`workspace-widget-preview${selected ? " selected" : ""}`}
206
+ onClick={onSelect}
207
+ style={{
208
+ gridColumn: `${widget.position.x + 1} / span ${widget.position.w}`,
209
+ gridRow: `${widget.position.y + 1} / span ${widget.position.h}`
210
+ }}
211
+ >
212
+ <div className="workspace-widget-preview-title">
213
+ <span aria-hidden="true">::</span>
214
+ <strong>{widget.title}</strong>
215
+ <button
216
+ aria-label={`Remove ${widget.title}`}
217
+ onClick={(event) => {
218
+ event.stopPropagation();
219
+ onRemove();
220
+ }}
221
+ type="button"
222
+ >x</button>
223
+ </div>
224
+ {widget.kind === "view" ? <div className="workspace-view-table" aria-label={`${widget.title} preview`}>
225
+ <div><span>Name</span><span>Domain Name</span></div>
226
+ {[
227
+ ["CMWL Direct", "centerformedica"],
228
+ ["Medi-Weightloss", "mediweightloss.com"],
229
+ ["Optima Tyler", "optimatyler.com"],
230
+ ["Balanced Hormone He...", "balancedhormor"],
231
+ ["Jolie Aesthetics RVA", "jolie-aesthetics.c"],
232
+ ["Livea Centers", "livea.com"]
233
+ ].map(([name, domain]) => <div key={name}><span>{name}</span><span>{domain}</span></div>)}
234
+ <footer>Calculate</footer>
235
+ </div> : null}
236
+ {widget.kind === "iframe" ? <div className="workspace-iframe-preview">
237
+ {widget.config?.url ? <span>{widget.config.url}</span> : <span>Invalid URL</span>}
238
+ </div> : null}
239
+ {widget.kind === "rich-text" ? <p className="workspace-rich-text-preview">{widget.config?.text || "Start writing..."}</p> : null}
240
+ {widget.kind === "chart" ? <div className="workspace-chart-preview">
241
+ {[58, 36, 72, 48, 64].map((height, index) => <span key={index} style={{ height: `${height}%` }} />)}
242
+ </div> : null}
243
+ {selected ? ["nw", "ne", "sw", "se"].map((corner) => <button
244
+ aria-label={`Resize ${widget.title} from ${corner} corner`}
245
+ className={`workspace-resize-handle ${corner}`}
246
+ key={corner}
247
+ onPointerDown={(event) => onResizeStart(corner, event)}
248
+ type="button"
249
+ />) : null}
250
+ </article>;
251
+ }
252
+
253
+ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter }) {
254
+ const [config, setConfig] = useState(initialConfig);
255
+ const [saving, setSaving] = useState(false);
256
+ const [panelOpen, setPanelOpen] = useState(true);
257
+ const gridRef = useRef(null);
258
+ const canvas = config.canvas;
259
+ const dashboards = config.dashboards;
260
+ const widgetTypes = config.widgetTypes;
261
+ const tabs = getTabs(canvas);
262
+ const activeTabId = getActiveTabId(canvas);
263
+ const activeTab = tabs.find((tab) => tab.id === activeTabId) || tabs[0];
264
+ const activeWidgets = activeTab.widgets || [];
265
+ const [selectedPosition, setSelectedPosition] = useState(() => findFreePosition(activeWidgets));
266
+ const [selectedWidgetId, setSelectedWidgetId] = useState(null);
267
+ const [dragStartCell, setDragStartCell] = useState(null);
268
+ const [dragPreview, setDragPreview] = useState(null);
269
+ const [resizeDrag, setResizeDrag] = useState(null);
270
+ const resizeDragRef = useRef(null);
271
+ const addSlot = dragPreview || selectedPosition;
272
+ const selectedWidget = activeWidgets.find((widget) => widget.id === selectedWidgetId) || null;
273
+ const occupiedCells = useMemo(() => {
274
+ const cells = new Set();
275
+ for (const widget of activeWidgets) {
276
+ for (let dx = 0; dx < widget.position.w; dx += 1) {
277
+ for (let dy = 0; dy < widget.position.h; dy += 1) {
278
+ cells.add(`${widget.position.x + dx}:${widget.position.y + dy}`);
279
+ }
280
+ }
281
+ }
282
+ return cells;
283
+ }, [activeWidgets]);
284
+
285
+ const addWidget = useCallback((kind) => {
286
+ setConfig((prev) => {
287
+ const prevTabs = getTabs(prev.canvas);
288
+ const prevActiveId = getActiveTabId(prev.canvas);
289
+ const existingWidgets = prevTabs.find((tab) => tab.id === prevActiveId)?.widgets || [];
290
+ const position = clampPositionToFreeSpace(addSlot, existingWidgets);
291
+ const widget = {
292
+ id: generateId("widget"),
293
+ kind,
294
+ title: defaultTitleFor(kind),
295
+ position,
296
+ config: defaultConfigFor(kind)
297
+ };
298
+ const stableTabs = prevTabs.length === 1 && prevTabs[0].id === DEFAULT_TAB_ID
299
+ ? [{ ...prevTabs[0], id: DEFAULT_TAB_ID }]
300
+ : prevTabs;
301
+ const nextTabs = stableTabs.map((tab) =>
302
+ tab.id === prevActiveId ? { ...tab, widgets: [...(tab.widgets || []), widget] } : tab
303
+ );
304
+ setSelectedWidgetId(widget.id);
305
+ setSelectedPosition(findFreePosition([...existingWidgets, widget]));
306
+ setDragPreview(null);
307
+ return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, prevActiveId) };
308
+ });
309
+ }, [addSlot]);
310
+
311
+ const switchTab = useCallback((tabId) => {
312
+ setConfig((prev) => {
313
+ const prevTabs = getTabs(prev.canvas);
314
+ if (prevTabs.length <= 1) return prev;
315
+ if (!prevTabs.some((tab) => tab.id === tabId)) return prev;
316
+ const nextTab = prevTabs.find((tab) => tab.id === tabId);
317
+ setSelectedWidgetId(null);
318
+ setSelectedPosition(findFreePosition(nextTab?.widgets || []));
319
+ setDragPreview(null);
320
+ return { ...prev, canvas: commitTabs(prev.canvas, prevTabs, tabId) };
321
+ });
322
+ }, []);
323
+
324
+ const addTab = useCallback(() => {
325
+ setConfig((prev) => {
326
+ const prevTabs = getTabs(prev.canvas);
327
+ const stableFirst = prevTabs.length === 1 && prevTabs[0].id === DEFAULT_TAB_ID
328
+ ? { ...prevTabs[0], id: generateId("tab") }
329
+ : prevTabs[0];
330
+ const remaining = prevTabs.length === 1 ? [] : prevTabs.slice(1);
331
+ const allExisting = [stableFirst, ...remaining];
332
+ const newTab = {
333
+ id: generateId("tab"),
334
+ name: `Tab ${allExisting.length + 1}`,
335
+ widgets: []
336
+ };
337
+ const nextTabs = [...allExisting, newTab];
338
+ setSelectedWidgetId(null);
339
+ setSelectedPosition({ ...DEFAULT_POSITION });
340
+ setDragPreview(null);
341
+ return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, newTab.id) };
342
+ });
343
+ }, []);
344
+
345
+ const addDashboard = useCallback(() => {
346
+ setConfig((prev) => ({
347
+ ...prev,
348
+ dashboards: [
349
+ ...(prev.dashboards || []),
350
+ {
351
+ id: generateId("dashboard"),
352
+ name: "Untitled",
353
+ createdBy: "Workspace owner",
354
+ updatedAt: "new",
355
+ status: "draft"
356
+ }
357
+ ]
358
+ }));
359
+ }, []);
360
+
361
+ const save = useCallback(async () => {
362
+ if (saving) return;
363
+ setSaving(true);
364
+ try {
365
+ const stamp = todayIsoDate();
366
+ const updatedDashboards = (config.dashboards || []).map((dashboard, index) =>
367
+ index === 0 ? { ...dashboard, updatedAt: stamp } : dashboard
368
+ );
369
+ const response = await fetch("/api/workspace", {
370
+ method: "PATCH",
371
+ headers: { "content-type": "application/json" },
372
+ body: JSON.stringify({
373
+ dashboards: updatedDashboards,
374
+ widgetTypes: config.widgetTypes,
375
+ canvas: config.canvas
376
+ })
377
+ });
378
+ const payload = await response.json();
379
+ if (response.ok && payload.workspaceConfig) {
380
+ setConfig(payload.workspaceConfig);
381
+ }
382
+ } finally {
383
+ setSaving(false);
384
+ }
385
+ }, [saving, config]);
386
+
387
+ const reopenPanel = useCallback(() => setPanelOpen(true), []);
388
+ const closePanel = useCallback(() => setPanelOpen(false), []);
389
+ const beginCellDrag = useCallback((index, event) => {
390
+ const x = index % GRID_COLUMNS;
391
+ const y = Math.floor(index / GRID_COLUMNS);
392
+ if (occupiedCells.has(`${x}:${y}`)) return;
393
+ event.preventDefault();
394
+ const position = normalizePosition(index, index);
395
+ setSelectedWidgetId(null);
396
+ setDragStartCell(index);
397
+ setDragPreview(position);
398
+ setPanelOpen(true);
399
+ }, [occupiedCells]);
400
+ const updateCellDrag = useCallback((index) => {
401
+ if (dragStartCell === null) return;
402
+ setDragPreview(normalizePosition(dragStartCell, index));
403
+ }, [dragStartCell]);
404
+ const finishCellDrag = useCallback((index) => {
405
+ if (dragStartCell === null) return;
406
+ const position = normalizePosition(dragStartCell, index);
407
+ setSelectedPosition(clampPositionToFreeSpace(position, activeWidgets));
408
+ setDragStartCell(null);
409
+ setDragPreview(null);
410
+ setPanelOpen(true);
411
+ }, [activeWidgets, dragStartCell]);
412
+ const updatePointerDrag = useCallback((event) => {
413
+ if (dragStartCell === null) return;
414
+ const index = cellIndexFromGridPointer(event, gridRef.current);
415
+ if (index !== null) updateCellDrag(index);
416
+ }, [dragStartCell, updateCellDrag]);
417
+ const finishPointerDrag = useCallback((event) => {
418
+ if (dragStartCell === null) return;
419
+ const index = cellIndexFromGridPointer(event, gridRef.current);
420
+ finishCellDrag(index ?? dragStartCell);
421
+ }, [dragStartCell, finishCellDrag]);
422
+ const beginResizeDrag = useCallback((widget, corner, event) => {
423
+ event.preventDefault();
424
+ event.stopPropagation();
425
+ event.currentTarget.setPointerCapture?.(event.pointerId);
426
+ const nextResizeDrag = { widgetId: widget.id, corner, originalPosition: widget.position };
427
+ setSelectedWidgetId(widget.id);
428
+ resizeDragRef.current = nextResizeDrag;
429
+ setResizeDrag(nextResizeDrag);
430
+ }, []);
431
+ const updateResizeDrag = useCallback((event) => {
432
+ const activeResizeDrag = resizeDragRef.current;
433
+ if (!activeResizeDrag) return;
434
+ event.preventDefault();
435
+ const index = cellIndexFromGridPointer(event, gridRef.current);
436
+ if (index === null) return;
437
+ const nextPosition = resizePositionFromCell(activeResizeDrag.originalPosition, activeResizeDrag.corner, index);
438
+ const otherWidgets = activeWidgets.filter((widget) => widget.id !== activeResizeDrag.widgetId);
439
+ if (otherWidgets.some((widget) => positionsOverlap(nextPosition, widget.position))) return;
440
+ setConfig((prev) => {
441
+ const prevTabs = getTabs(prev.canvas);
442
+ const prevActiveId = getActiveTabId(prev.canvas);
443
+ const nextTabs = prevTabs.map((tab) => {
444
+ if (tab.id !== prevActiveId) return tab;
445
+ return {
446
+ ...tab,
447
+ widgets: (tab.widgets || []).map((widget) =>
448
+ widget.id === activeResizeDrag.widgetId ? { ...widget, position: nextPosition } : widget
449
+ )
450
+ };
451
+ });
452
+ return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, prevActiveId) };
453
+ });
454
+ }, [activeWidgets]);
455
+ const finishResizeDrag = useCallback(() => {
456
+ if (!resizeDragRef.current) return;
457
+ resizeDragRef.current = null;
458
+ setResizeDrag(null);
459
+ }, []);
460
+ const selectWidget = useCallback((widgetId) => {
461
+ setSelectedWidgetId(widgetId);
462
+ setPanelOpen(true);
463
+ }, []);
464
+ const updateSelectedWidget = useCallback((updates) => {
465
+ if (!selectedWidgetId) return;
466
+ setConfig((prev) => {
467
+ const prevTabs = getTabs(prev.canvas);
468
+ const prevActiveId = getActiveTabId(prev.canvas);
469
+ const nextTabs = prevTabs.map((tab) => {
470
+ if (tab.id !== prevActiveId) return tab;
471
+ return {
472
+ ...tab,
473
+ widgets: (tab.widgets || []).map((widget) =>
474
+ widget.id === selectedWidgetId ? { ...widget, ...updates } : widget
475
+ )
476
+ };
477
+ });
478
+ return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, prevActiveId) };
479
+ });
480
+ }, [selectedWidgetId]);
481
+ const updateSelectedWidgetConfig = useCallback((updates) => {
482
+ if (!selectedWidget) return;
483
+ updateSelectedWidget({ config: { ...(selectedWidget.config || {}), ...updates } });
484
+ }, [selectedWidget, updateSelectedWidget]);
485
+ const removeSelectedWidget = useCallback((widgetId) => {
486
+ setConfig((prev) => {
487
+ const prevTabs = getTabs(prev.canvas);
488
+ const prevActiveId = getActiveTabId(prev.canvas);
489
+ const nextTabs = prevTabs.map((tab) => {
490
+ if (tab.id !== prevActiveId) return tab;
491
+ return { ...tab, widgets: (tab.widgets || []).filter((widget) => widget.id !== widgetId) };
492
+ });
493
+ const nextActiveWidgets = nextTabs.find((tab) => tab.id === prevActiveId)?.widgets || [];
494
+ setSelectedWidgetId(null);
495
+ setSelectedPosition(findFreePosition(nextActiveWidgets));
496
+ return { ...prev, canvas: commitTabs(prev.canvas, nextTabs, prevActiveId) };
497
+ });
498
+ }, []);
499
+
500
+ const builderStyle = panelOpen ? undefined : { gridTemplateColumns: COLLAPSED_GRID_COLUMNS };
501
+
502
+ return <main className="workspace-builder" style={builderStyle}>
503
+ <aside className="workspace-rail" aria-label="Workspace navigation">
504
+ <div className="workspace-brand">
505
+ <span className="workspace-mark">G</span>
506
+ <span>Growthub Workspace</span>
507
+ </div>
508
+ <nav className="workspace-nav">
509
+ <a className="active" href="#dashboards">Dashboards</a>
510
+ <a href="#canvas">Canvas</a>
511
+ <a href="#widgets" onClick={reopenPanel}>Widgets</a>
512
+ <a href="#bindings" onClick={reopenPanel}>Bindings</a>
513
+ <Link href="/settings/integrations">Integrations</Link>
514
+ </nav>
515
+ <div className="workspace-rail-status">
516
+ <span className="status-dot" />
517
+ {integrationAdapter.authority}
518
+ </div>
519
+ </aside>
520
+
521
+ <section className="workspace-surface">
522
+ <header className="workspace-toolbar">
523
+ <div>
524
+ <p>Official starter</p>
525
+ <h1>{config.name}</h1>
526
+ </div>
527
+ <div className="workspace-toolbar-actions">
528
+ <button type="button" onClick={addDashboard}>New Dashboard</button>
529
+ <button type="button" onClick={save} disabled={saving}>{saving ? "Saving..." : "Save"}</button>
530
+ </div>
531
+ </header>
532
+
533
+ <section className="workspace-table" id="dashboards" aria-label="Dashboards">
534
+ <div className="workspace-table-heading">
535
+ <strong>Dashboards</strong>
536
+ <span>{dashboards.length} template</span>
537
+ </div>
538
+ <div className="workspace-table-row workspace-table-head">
539
+ <span>Title</span>
540
+ <span>Created by</span>
541
+ <span>Last update</span>
542
+ <span>Status</span>
543
+ </div>
544
+ {dashboards.map((dashboard) => <div className="workspace-table-row" key={dashboard.id}>
545
+ <span>{dashboard.name}</span>
546
+ <span>{dashboard.createdBy}</span>
547
+ <span>{dashboard.updatedAt}</span>
548
+ <code>{dashboard.status}</code>
549
+ </div>)}
550
+ </section>
551
+
552
+ <section className="workspace-canvas" id="canvas" aria-label="Composable dashboard canvas">
553
+ <div className="workspace-tabs">
554
+ {tabs.map((tab) => <button
555
+ key={tab.id}
556
+ className={tab.id === activeTabId ? "active" : ""}
557
+ type="button"
558
+ onClick={() => switchTab(tab.id)}
559
+ >{tab.name}</button>)}
560
+ <button type="button" onClick={addTab}>New Tab</button>
561
+ </div>
562
+ <div
563
+ className="workspace-grid"
564
+ ref={gridRef}
565
+ onPointerMove={updatePointerDrag}
566
+ onPointerUp={(event) => {
567
+ finishPointerDrag(event);
568
+ finishResizeDrag();
569
+ }}
570
+ onPointerLeave={finishResizeDrag}
571
+ onPointerMoveCapture={updateResizeDrag}
572
+ style={{ "--workspace-columns": canvas.layout.columns, "--workspace-rows": GRID_ROWS }}
573
+ >
574
+ {Array.from({ length: GRID_CELL_COUNT }).map((_, index) => {
575
+ const x = index % GRID_COLUMNS;
576
+ const y = Math.floor(index / GRID_COLUMNS);
577
+ const isOccupied = occupiedCells.has(`${x}:${y}`);
578
+ return <button
579
+ aria-label={`Select cell ${x + 1}, ${y + 1}`}
580
+ aria-hidden={isOccupied ? "true" : undefined}
581
+ className="workspace-grid-cell"
582
+ data-cell-index={index}
583
+ disabled={isOccupied}
584
+ key={index}
585
+ onPointerDown={(event) => beginCellDrag(index, event)}
586
+ style={{
587
+ gridColumn: `${x + 1} / span 1`,
588
+ gridRow: `${y + 1} / span 1`
589
+ }}
590
+ type="button"
591
+ />;
592
+ })}
593
+ <button className={`workspace-add-widget${dragPreview ? " selecting" : ""}`} type="button" onClick={() => setPanelOpen(true)} style={{
594
+ gridColumn: `${addSlot.x + 1} / span ${addSlot.w}`,
595
+ gridRow: `${addSlot.y + 1} / span ${addSlot.h}`
596
+ }}>
597
+ <span className="workspace-widget-icon" aria-hidden="true"><span /></span>
598
+ <strong>Add widget</strong>
599
+ <small>Click to add your first widget</small>
600
+ </button>
601
+ {activeWidgets.map((widget) => <WidgetPreview
602
+ key={widget.id}
603
+ onRemove={() => removeSelectedWidget(widget.id)}
604
+ onResizeStart={(corner, event) => beginResizeDrag(widget, corner, event)}
605
+ onSelect={() => selectWidget(widget.id)}
606
+ selected={widget.id === selectedWidgetId}
607
+ widget={widget}
608
+ />)}
609
+ </div>
610
+ </section>
611
+ </section>
612
+
613
+ {panelOpen ? <aside className="workspace-widget-panel" id="widgets" aria-label="Widget configuration">
614
+ <div className="workspace-panel-title">
615
+ <button type="button" aria-label="Close widget panel" onClick={closePanel}>x</button>
616
+ <span aria-hidden="true">+</span>
617
+ <strong>{selectedWidget ? selectedWidget.title : "New widget"}</strong>
618
+ {selectedWidget ? <em>{widgetKindLabel(selectedWidget.kind)}</em> : null}
619
+ </div>
620
+ {selectedWidget ? <section className="workspace-widget-settings">
621
+ <label>
622
+ <span>Title</span>
623
+ <input value={selectedWidget.title} onChange={(event) => updateSelectedWidget({ title: event.target.value })} />
624
+ </label>
625
+ {selectedWidget.kind === "iframe" ? <label>
626
+ <span>URL to Embed</span>
627
+ <input
628
+ placeholder="https://example.com/embed"
629
+ value={selectedWidget.config?.url || ""}
630
+ onChange={(event) => updateSelectedWidgetConfig({ url: event.target.value })}
631
+ />
632
+ </label> : null}
633
+ {selectedWidget.kind === "rich-text" ? <label>
634
+ <span>Content</span>
635
+ <textarea
636
+ placeholder="Write text..."
637
+ value={selectedWidget.config?.text || ""}
638
+ onChange={(event) => updateSelectedWidgetConfig({ text: event.target.value })}
639
+ />
640
+ </label> : null}
641
+ {selectedWidget.kind === "view" ? <div className="workspace-settings-list">
642
+ <p className="workspace-panel-label">Settings</p>
643
+ <div><span>Layout</span><code>{selectedWidget.config?.layout || "Table"}</code></div>
644
+ <div><span>Source</span><code>{selectedWidget.config?.source || "Companies"}</code></div>
645
+ <div><span>Fields</span><code>6 shown</code></div>
646
+ <div><span>Filter</span><code>›</code></div>
647
+ <div><span>Sort</span><code>›</code></div>
648
+ </div> : null}
649
+ <div className="workspace-settings-list">
650
+ <p className="workspace-panel-label">Placement</p>
651
+ <div><span>Size</span><code>{selectedWidget.position.w} x {selectedWidget.position.h}</code></div>
652
+ <div><span>Origin</span><code>{selectedWidget.position.x + 1}, {selectedWidget.position.y + 1}</code></div>
653
+ </div>
654
+ </section> : <section>
655
+ <p className="workspace-panel-label">Widget type</p>
656
+ <div className="workspace-widget-types">
657
+ {widgetTypes.map((widget) => <button type="button" key={widget.kind} onClick={() => addWidget(widget.kind)}>
658
+ <span>{widget.icon}</span>
659
+ {widget.label}
660
+ </button>)}
661
+ </div>
662
+ </section>}
663
+ <section className="workspace-bindings" id="bindings">
664
+ <p className="workspace-panel-label">Config bindings</p>
665
+ {Object.entries(canvas.bindings).map(([key, value]) => <div key={key}>
666
+ <span>{key}</span>
667
+ <code>{String(value)}</code>
668
+ </div>)}
669
+ <div>
670
+ <span>integrationAdapter</span>
671
+ <code>{adapterConfig.integrationAdapter}</code>
672
+ </div>
673
+ </section>
674
+ </aside> : null}
675
+ </main>;
676
+ }
677
+
678
+ export {
679
+ WorkspaceBuilder as default
680
+ };