@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.
@@ -6,6 +6,14 @@ import { describePaymentAdapter } from "@/lib/adapters/payments";
6
6
  import { describePersistenceAdapter } from "@/lib/adapters/persistence";
7
7
  import { groupIntegrationsByLane } from "@/lib/domain/integrations";
8
8
  import { buildPortalWorkspace, portalCapabilities } from "@/lib/domain/portal";
9
+ import {
10
+ describePersistenceMode,
11
+ readWorkspaceConfig,
12
+ writeWorkspaceConfig
13
+ } from "@/lib/workspace-config";
14
+
15
+ const ALLOWED_PATCH_FIELDS = new Set(["dashboards", "widgetTypes", "canvas"]);
16
+
9
17
  async function GET() {
10
18
  const integrations = await listAgencyPortalIntegrations();
11
19
  const config = readAdapterConfig();
@@ -18,14 +26,66 @@ async function GET() {
18
26
  const settings = {
19
27
  integrations: groupIntegrationsByLane(integrations)
20
28
  };
29
+ const workspaceConfig = await readWorkspaceConfig();
30
+ const persistence = describePersistenceMode();
21
31
  return NextResponse.json({
22
32
  config,
23
33
  adapters,
24
34
  capabilities: portalCapabilities,
25
35
  settings,
26
- workspace: buildPortalWorkspace({ config, adapters, integrations: settings.integrations })
36
+ workspace: buildPortalWorkspace({ config, adapters, integrations: settings.integrations }),
37
+ workspaceConfig,
38
+ workspaceConfigPersistence: persistence
27
39
  });
28
40
  }
29
- export {
30
- GET
31
- };
41
+
42
+ async function PATCH(request) {
43
+ let patch;
44
+ try {
45
+ patch = await request.json();
46
+ } catch {
47
+ return NextResponse.json({ error: "invalid json body" }, { status: 400 });
48
+ }
49
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
50
+ return NextResponse.json({ error: "patch must be a plain object" }, { status: 400 });
51
+ }
52
+ const unknown = Object.keys(patch).filter((key) => !ALLOWED_PATCH_FIELDS.has(key));
53
+ if (unknown.length) {
54
+ return NextResponse.json(
55
+ { error: "patch contains unknown fields", details: unknown, allowed: Array.from(ALLOWED_PATCH_FIELDS) },
56
+ { status: 400 }
57
+ );
58
+ }
59
+ const sanitized = {};
60
+ for (const key of ALLOWED_PATCH_FIELDS) {
61
+ if (Object.prototype.hasOwnProperty.call(patch, key)) {
62
+ sanitized[key] = patch[key];
63
+ }
64
+ }
65
+ try {
66
+ const next = await writeWorkspaceConfig(sanitized);
67
+ return NextResponse.json({ workspaceConfig: next });
68
+ } catch (error) {
69
+ if (error.code === "WORKSPACE_PERSISTENCE_READ_ONLY") {
70
+ return NextResponse.json(
71
+ {
72
+ error: "workspace config is read-only in this runtime",
73
+ reason: error.message,
74
+ adapter: error.adapter,
75
+ guidance:
76
+ "Edit growthub.config.json locally, or set WORKSPACE_CONFIG_ALLOW_FS_WRITE=true on a writable runtime."
77
+ },
78
+ { status: 409 }
79
+ );
80
+ }
81
+ if (error.code === "WORKSPACE_PERSISTENCE_PATH_REFUSED") {
82
+ return NextResponse.json({ error: error.message }, { status: 500 });
83
+ }
84
+ if (error.code === "INVALID_WORKSPACE_CONFIG") {
85
+ return NextResponse.json({ error: error.message, details: error.details }, { status: 400 });
86
+ }
87
+ return NextResponse.json({ error: error.message || "failed to write workspace config" }, { status: 500 });
88
+ }
89
+ }
90
+
91
+ export { GET, PATCH };
@@ -548,19 +548,24 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0
548
548
  position: relative;
549
549
  display: grid;
550
550
  grid-template-columns: repeat(var(--workspace-columns), minmax(48px, 1fr));
551
+ grid-template-rows: repeat(var(--workspace-rows), 52px);
551
552
  grid-auto-rows: 52px;
552
553
  gap: 8px;
553
- min-height: 620px;
554
554
  padding: 8px;
555
555
  }
556
556
  .workspace-grid-cell {
557
+ appearance: none;
557
558
  border: 1px solid #ededed;
558
559
  border-radius: 7px;
559
560
  background: #fdfdfd;
561
+ cursor: crosshair;
562
+ padding: 0;
563
+ }
564
+ .workspace-grid-cell:disabled {
565
+ pointer-events: none;
566
+ opacity: 1;
560
567
  }
561
568
  .workspace-add-widget {
562
- grid-column: 1 / span 4;
563
- grid-row: 1 / span 4;
564
569
  z-index: 1;
565
570
  display: grid;
566
571
  place-items: center;
@@ -572,6 +577,11 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0
572
577
  color: #3d3d3d;
573
578
  cursor: pointer;
574
579
  }
580
+ .workspace-add-widget.selecting {
581
+ border-color: #7aa2ff;
582
+ background: rgba(122, 162, 255, 0.16);
583
+ box-shadow: inset 0 0 0 1px rgba(84, 125, 255, 0.35);
584
+ }
575
585
  .workspace-add-widget small {
576
586
  color: #979797;
577
587
  }
@@ -593,16 +603,138 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0
593
603
  background: #b9ecff;
594
604
  }
595
605
  .workspace-widget-preview {
606
+ position: relative;
596
607
  z-index: 2;
597
608
  border: 1px solid #dedede;
598
609
  border-radius: 7px;
599
610
  background: #ffffff;
600
- padding: 10px;
611
+ padding: 12px;
612
+ cursor: pointer;
613
+ overflow: hidden;
601
614
  }
602
- .workspace-widget-preview span {
615
+ .workspace-widget-preview.selected {
616
+ border-color: #3f68ff;
617
+ box-shadow: inset 0 0 0 1px rgba(63, 104, 255, 0.45);
618
+ }
619
+ .workspace-resize-handle {
620
+ position: absolute;
621
+ z-index: 3;
622
+ width: 13px;
623
+ height: 13px;
624
+ border: 1px solid #3f68ff;
625
+ border-radius: 4px;
626
+ background: #ffffff;
627
+ box-shadow: 0 1px 3px rgba(63, 104, 255, 0.22);
628
+ padding: 0;
629
+ }
630
+ .workspace-resize-handle.nw {
631
+ top: -6px;
632
+ left: -6px;
633
+ cursor: nwse-resize;
634
+ }
635
+ .workspace-resize-handle.ne {
636
+ top: -6px;
637
+ right: -6px;
638
+ cursor: nesw-resize;
639
+ }
640
+ .workspace-resize-handle.sw {
641
+ bottom: -6px;
642
+ left: -6px;
643
+ cursor: nesw-resize;
644
+ }
645
+ .workspace-resize-handle.se {
646
+ right: -6px;
647
+ bottom: -6px;
648
+ cursor: nwse-resize;
649
+ }
650
+ .workspace-widget-preview-title {
651
+ display: flex;
652
+ align-items: center;
653
+ gap: 8px;
654
+ min-height: 22px;
655
+ }
656
+ .workspace-widget-preview-title span,
657
+ .workspace-widget-preview-title button {
603
658
  color: #888;
604
659
  font-size: 11px;
605
660
  }
661
+ .workspace-widget-preview-title strong {
662
+ min-width: 0;
663
+ flex: 1;
664
+ overflow: hidden;
665
+ color: #333;
666
+ font-size: 13px;
667
+ text-overflow: ellipsis;
668
+ white-space: nowrap;
669
+ }
670
+ .workspace-widget-preview-title button {
671
+ width: 22px;
672
+ height: 22px;
673
+ border: 0;
674
+ border-radius: 5px;
675
+ background: transparent;
676
+ }
677
+ .workspace-widget-preview:not(.selected) .workspace-widget-preview-title button {
678
+ visibility: hidden;
679
+ }
680
+ .workspace-view-table {
681
+ margin-top: 10px;
682
+ border: 1px solid #eeeeee;
683
+ border-radius: 4px;
684
+ overflow: hidden;
685
+ color: #555;
686
+ font-size: 12px;
687
+ }
688
+ .workspace-view-table div {
689
+ display: grid;
690
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
691
+ min-height: 25px;
692
+ border-bottom: 1px solid #eeeeee;
693
+ }
694
+ .workspace-view-table span {
695
+ min-width: 0;
696
+ overflow: hidden;
697
+ padding: 5px 8px;
698
+ text-overflow: ellipsis;
699
+ white-space: nowrap;
700
+ }
701
+ .workspace-view-table span + span {
702
+ border-left: 1px solid #eeeeee;
703
+ }
704
+ .workspace-view-table footer {
705
+ padding: 7px 8px;
706
+ color: #999;
707
+ }
708
+ .workspace-iframe-preview {
709
+ height: calc(100% - 34px);
710
+ min-height: 90px;
711
+ display: grid;
712
+ place-items: center;
713
+ margin-top: 10px;
714
+ border-radius: 7px;
715
+ background: #fdfdfd;
716
+ color: #d54040;
717
+ font-size: 12px;
718
+ }
719
+ .workspace-rich-text-preview {
720
+ margin: 18px 10px 0;
721
+ color: #444;
722
+ font-size: 13px;
723
+ line-height: 1.5;
724
+ }
725
+ .workspace-chart-preview {
726
+ height: calc(100% - 42px);
727
+ min-height: 96px;
728
+ display: flex;
729
+ align-items: end;
730
+ gap: 8px;
731
+ padding: 16px 10px 6px;
732
+ }
733
+ .workspace-chart-preview span {
734
+ flex: 1;
735
+ border-radius: 5px 5px 0 0;
736
+ background: #d9e4ff;
737
+ }
606
738
  .workspace-widget-panel {
607
739
  border-left: 1px solid #e8e8e8;
608
740
  padding: 0 12px 16px;
@@ -614,16 +746,28 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0
614
746
  gap: 10px;
615
747
  border-bottom: 1px solid #ececec;
616
748
  }
617
- .workspace-panel-title button {
749
+ .workspace-panel-title button,
750
+ .workspace-panel-title > span {
618
751
  width: 24px;
619
752
  height: 24px;
753
+ display: grid;
754
+ place-items: center;
620
755
  border: 0;
621
756
  border-radius: 5px;
622
757
  background: #f1f1f1;
623
758
  color: #888;
624
759
  }
625
760
  .workspace-panel-title strong {
761
+ min-width: 0;
626
762
  font-size: 14px;
763
+ overflow: hidden;
764
+ text-overflow: ellipsis;
765
+ white-space: nowrap;
766
+ }
767
+ .workspace-panel-title em {
768
+ color: #999;
769
+ font-size: 12px;
770
+ font-style: normal;
627
771
  }
628
772
  .workspace-panel-label {
629
773
  margin: 14px 0 7px;
@@ -678,6 +822,57 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0
678
822
  color: #777;
679
823
  background: #f4f4f4;
680
824
  }
825
+ .workspace-widget-settings {
826
+ display: grid;
827
+ gap: 14px;
828
+ padding-top: 14px;
829
+ }
830
+ .workspace-widget-settings label {
831
+ display: grid;
832
+ gap: 6px;
833
+ }
834
+ .workspace-widget-settings label span {
835
+ color: #999;
836
+ font-size: 11px;
837
+ font-weight: 700;
838
+ }
839
+ .workspace-widget-settings input,
840
+ .workspace-widget-settings textarea {
841
+ width: 100%;
842
+ box-sizing: border-box;
843
+ border: 1px solid #e5e5e5;
844
+ border-radius: 6px;
845
+ background: #fafafa;
846
+ color: #444;
847
+ font: inherit;
848
+ padding: 8px 9px;
849
+ }
850
+ .workspace-widget-settings textarea {
851
+ min-height: 120px;
852
+ resize: vertical;
853
+ }
854
+ .workspace-settings-list {
855
+ display: grid;
856
+ gap: 2px;
857
+ }
858
+ .workspace-settings-list div {
859
+ display: flex;
860
+ align-items: center;
861
+ justify-content: space-between;
862
+ min-height: 32px;
863
+ border-radius: 5px;
864
+ color: #666;
865
+ font-size: 13px;
866
+ padding: 0 8px;
867
+ }
868
+ .workspace-settings-list div:nth-child(3),
869
+ .workspace-settings-list div:hover {
870
+ background: #f3f3f3;
871
+ }
872
+ .workspace-settings-list code {
873
+ color: #aaa;
874
+ background: transparent;
875
+ }
681
876
 
682
877
  @media (max-width: 1080px) {
683
878
  .workspace-builder {
@@ -1,115 +1,21 @@
1
1
  import workspaceConfig from "../growthub.config.json";
2
2
  import { readAdapterConfig } from "@/lib/adapters/env";
3
3
  import { describeIntegrationAdapter } from "@/lib/adapters/integrations";
4
- import Link from "next/link";
4
+ import { describePersistenceMode } from "@/lib/workspace-config";
5
+ import WorkspaceBuilder from "./workspace-builder.jsx";
5
6
 
6
7
  function Home() {
7
8
  const adapterConfig = readAdapterConfig();
8
9
  const integrationAdapter = describeIntegrationAdapter();
9
- const canvas = workspaceConfig.canvas;
10
- const dashboards = workspaceConfig.dashboards;
11
- const widgetTypes = workspaceConfig.widgetTypes;
12
-
13
- return <main className="workspace-builder">
14
- <aside className="workspace-rail" aria-label="Workspace navigation">
15
- <div className="workspace-brand">
16
- <span className="workspace-mark">G</span>
17
- <span>Growthub Workspace</span>
18
- </div>
19
- <nav className="workspace-nav">
20
- <a className="active" href="#dashboards">Dashboards</a>
21
- <a href="#canvas">Canvas</a>
22
- <a href="#widgets">Widgets</a>
23
- <a href="#bindings">Bindings</a>
24
- <Link href="/settings/integrations">Integrations</Link>
25
- </nav>
26
- <div className="workspace-rail-status">
27
- <span className="status-dot" />
28
- {integrationAdapter.authority}
29
- </div>
30
- </aside>
31
-
32
- <section className="workspace-surface">
33
- <header className="workspace-toolbar">
34
- <div>
35
- <p>Official starter</p>
36
- <h1>{workspaceConfig.name}</h1>
37
- </div>
38
- <div className="workspace-toolbar-actions">
39
- <button type="button">New Dashboard</button>
40
- <button type="button">Save</button>
41
- </div>
42
- </header>
43
-
44
- <section className="workspace-table" id="dashboards" aria-label="Dashboards">
45
- <div className="workspace-table-heading">
46
- <strong>Dashboards</strong>
47
- <span>{dashboards.length} template</span>
48
- </div>
49
- <div className="workspace-table-row workspace-table-head">
50
- <span>Title</span>
51
- <span>Created by</span>
52
- <span>Last update</span>
53
- <span>Status</span>
54
- </div>
55
- {dashboards.map((dashboard) => <div className="workspace-table-row" key={dashboard.id}>
56
- <span>{dashboard.name}</span>
57
- <span>{dashboard.createdBy}</span>
58
- <span>{dashboard.updatedAt}</span>
59
- <code>{dashboard.status}</code>
60
- </div>)}
61
- </section>
62
-
63
- <section className="workspace-canvas" id="canvas" aria-label="Composable dashboard canvas">
64
- <div className="workspace-tabs">
65
- <button className="active" type="button">{canvas.name}</button>
66
- <button type="button">New Tab</button>
67
- </div>
68
- <div className="workspace-grid" style={{ "--workspace-columns": canvas.layout.columns }}>
69
- <button className="workspace-add-widget" type="button">
70
- <span className="workspace-widget-icon" aria-hidden="true"><span /></span>
71
- <strong>Add widget</strong>
72
- <small>Click to add your first widget</small>
73
- </button>
74
- {Array.from({ length: 96 }).map((_, index) => <span aria-hidden="true" className="workspace-grid-cell" key={index} />)}
75
- {canvas.widgets.map((widget) => <article className="workspace-widget-preview" key={widget.id} style={{
76
- gridColumn: `${widget.position.x + 1} / span ${widget.position.w}`,
77
- gridRow: `${widget.position.y + 1} / span ${widget.position.h}`
78
- }}>
79
- <span>{widget.kind}</span>
80
- <strong>{widget.title}</strong>
81
- </article>)}
82
- </div>
83
- </section>
84
- </section>
85
-
86
- <aside className="workspace-widget-panel" id="widgets" aria-label="Widget configuration">
87
- <div className="workspace-panel-title">
88
- <button type="button" aria-label="Close widget panel">x</button>
89
- <strong>New widget</strong>
90
- </div>
91
- <section>
92
- <p className="workspace-panel-label">Widget type</p>
93
- <div className="workspace-widget-types">
94
- {widgetTypes.map((widget) => <button type="button" key={widget.kind}>
95
- <span>{widget.icon}</span>
96
- {widget.label}
97
- </button>)}
98
- </div>
99
- </section>
100
- <section className="workspace-bindings" id="bindings">
101
- <p className="workspace-panel-label">Config bindings</p>
102
- {Object.entries(canvas.bindings).map(([key, value]) => <div key={key}>
103
- <span>{key}</span>
104
- <code>{String(value)}</code>
105
- </div>)}
106
- <div>
107
- <span>integrationAdapter</span>
108
- <code>{adapterConfig.integrationAdapter}</code>
109
- </div>
110
- </section>
111
- </aside>
112
- </main>;
10
+ const persistence = describePersistenceMode();
11
+ return (
12
+ <WorkspaceBuilder
13
+ initialConfig={workspaceConfig}
14
+ adapterConfig={adapterConfig}
15
+ integrationAdapter={integrationAdapter}
16
+ persistence={persistence}
17
+ />
18
+ );
113
19
  }
114
20
 
115
21
  export {