@growthub/cli 0.9.4 → 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.
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/route.js +64 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +201 -6
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/page.jsx +11 -105
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +680 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +232 -0
- package/dist/index.js +1 -0
- package/package.json +1 -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
|
+
};
|