@mixd-id/web-scaffold 0.2.250801012 → 0.2.250801014

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 (171) hide show
  1. package/docs/components/AIDashboard.md +472 -0
  2. package/docs/components/WebPageBuilder.md +125 -0
  3. package/package.json +8 -30
  4. package/src/components/AIChat.vue +479 -0
  5. package/src/components/AIChatItem.vue +168 -0
  6. package/src/components/AIChatTextbox.vue +56 -0
  7. package/src/components/AIDashboard.vue +1113 -0
  8. package/src/components/AccountIcon.vue +19 -0
  9. package/src/components/Ahref.vue +200 -0
  10. package/src/components/Alert.vue +257 -0
  11. package/src/components/Article.vue +387 -0
  12. package/src/components/BarChart.vue +87 -0
  13. package/src/components/Block.vue +31 -0
  14. package/src/components/Button.vue +258 -0
  15. package/src/components/Card.vue +255 -0
  16. package/src/components/Carousel.vue +404 -0
  17. package/src/components/Cart.vue +192 -0
  18. package/src/components/CartIcon.vue +89 -0
  19. package/src/components/Chart.vue +486 -0
  20. package/src/components/ChatTyping.vue +69 -0
  21. package/src/components/Checkbox.vue +200 -0
  22. package/src/components/CodeEditor.vue +158 -0
  23. package/src/components/CollapsiblePanel.vue +70 -0
  24. package/src/components/ColorPicker.vue +653 -0
  25. package/src/components/Confirm.vue +180 -0
  26. package/src/components/ContextMenu.vue +265 -0
  27. package/src/components/ContextMenuItem.vue +53 -0
  28. package/src/components/CopyToClipboard.vue +34 -0
  29. package/src/components/Countdown.vue +264 -0
  30. package/src/components/Dashboard.vue +1151 -0
  31. package/src/components/Datepicker.vue +412 -0
  32. package/src/components/Datetimepicker.vue +355 -0
  33. package/src/components/DoughnutChart.vue +68 -0
  34. package/src/components/Dropdown.vue +314 -0
  35. package/src/components/FilterCard.vue +177 -0
  36. package/src/components/Flex.vue +44 -0
  37. package/src/components/Gmaps.vue +251 -0
  38. package/src/components/Grid.vue +35 -0
  39. package/src/components/HTMLEditor.vue +786 -0
  40. package/src/components/IconMenu2.vue +89 -0
  41. package/src/components/Image.vue +311 -0
  42. package/src/components/ImagePreview.vue +93 -0
  43. package/src/components/ImageUploader.vue +111 -0
  44. package/src/components/LineChart.vue +104 -0
  45. package/src/components/Link.vue +95 -0
  46. package/src/components/List.vue +1549 -0
  47. package/src/components/ListItem.vue +160 -0
  48. package/src/components/MarkdownEdit.vue +346 -0
  49. package/src/components/MarkdownPreview.vue +102 -0
  50. package/src/components/MenuItem1.vue +36 -0
  51. package/src/components/Metric.vue +105 -0
  52. package/src/components/MultiDropdown.vue +124 -0
  53. package/src/components/OTPField.vue +139 -0
  54. package/src/components/Paragraph.vue +40 -0
  55. package/src/components/PieChart.vue +67 -0
  56. package/src/components/PresetSelectorFilterItem.vue +204 -0
  57. package/src/components/Radio.vue +109 -0
  58. package/src/components/SearchModal.vue +153 -0
  59. package/src/components/Slider.vue +407 -0
  60. package/src/components/Svg.vue +33 -0
  61. package/src/components/SvgEditor.vue +173 -0
  62. package/src/components/Switch.vue +101 -0
  63. package/src/components/Table.vue +56 -0
  64. package/src/components/Tabs.vue +131 -0
  65. package/src/components/Testimonial.vue +86 -0
  66. package/src/components/Text.vue +34 -0
  67. package/src/components/TextBlock.vue +50 -0
  68. package/src/components/TextWithTag.vue +298 -0
  69. package/src/components/Textarea.vue +195 -0
  70. package/src/components/Textbox.vue +216 -0
  71. package/src/components/Thumbnail.vue +27 -0
  72. package/src/components/Timepicker.vue +223 -0
  73. package/src/components/Toast.vue +117 -0
  74. package/src/components/TreeMenu.vue +122 -0
  75. package/src/components/TreeView.vue +107 -0
  76. package/src/components/TreeViewItem.vue +452 -0
  77. package/src/components/Uploader.vue +45 -0
  78. package/src/components/Video.vue +119 -0
  79. package/src/components/VirtualGrid.vue +296 -0
  80. package/src/components/VirtualTable.vue +1047 -0
  81. package/src/components/YoutubeVideo.vue +196 -0
  82. package/src/configs/web-page-builder.js +118 -0
  83. package/src/directives/intersect.js +26 -0
  84. package/src/hooks/device.js +14 -0
  85. package/src/index.css +3 -0
  86. package/src/index.js +635 -0
  87. package/src/instructions/dashboard.js +203 -0
  88. package/src/mixin/component.js +306 -0
  89. package/src/themes/default/index.js +303 -0
  90. package/src/utils/ai-chat-answer.mjs +240 -0
  91. package/src/utils/ai-chat.js +167 -0
  92. package/src/utils/ai-dashboard-chat.mjs +371 -0
  93. package/src/utils/ai-dashboard.js +321 -0
  94. package/src/utils/ai-list-chat.mjs +407 -0
  95. package/src/utils/dashboard.js +317 -0
  96. package/src/utils/helpers.js +635 -0
  97. package/src/utils/helpers.mjs +722 -0
  98. package/src/utils/list.mjs +1564 -0
  99. package/src/utils/preset-selector.mjs +390 -0
  100. package/src/utils/selection.js +43 -0
  101. package/src/utils/wss.js +311 -0
  102. package/src/utils/wss.mjs +286 -0
  103. package/src/widgets/AhrefSetting.vue +97 -0
  104. package/src/widgets/ArticleSetting.vue +457 -0
  105. package/src/widgets/BackgroundColorSetting.vue +149 -0
  106. package/src/widgets/BlockSetting.vue +49 -0
  107. package/src/widgets/BorderColorSetting.vue +53 -0
  108. package/src/widgets/ButtonSetting.vue +113 -0
  109. package/src/widgets/CarouselSetting.vue +181 -0
  110. package/src/widgets/CartSetting.vue +46 -0
  111. package/src/widgets/CollapsiblePanelSetting.vue +46 -0
  112. package/src/widgets/ColumnSelector.vue +93 -0
  113. package/src/widgets/ComponentSetting.vue +2220 -0
  114. package/src/widgets/ComponentSetting2.vue +927 -0
  115. package/src/widgets/ContactForm.vue +193 -0
  116. package/src/widgets/ContactFormSetting.vue +395 -0
  117. package/src/widgets/CountdownSetting.vue +76 -0
  118. package/src/widgets/DataListSetting.vue +102 -0
  119. package/src/widgets/EmbeddedVideo.vue +60 -0
  120. package/src/widgets/EmbeddedVideoSetting.vue +50 -0
  121. package/src/widgets/FAQ.vue +98 -0
  122. package/src/widgets/FAQSetting.vue +94 -0
  123. package/src/widgets/FeatureList.vue +131 -0
  124. package/src/widgets/FeatureListSetting.vue +233 -0
  125. package/src/widgets/FlexSetting.vue +174 -0
  126. package/src/widgets/GridSetting.vue +165 -0
  127. package/src/widgets/Header2.vue +65 -0
  128. package/src/widgets/Header2Setting.vue +193 -0
  129. package/src/widgets/IconList.vue +51 -0
  130. package/src/widgets/IconListSetting.vue +188 -0
  131. package/src/widgets/ImageSetting.vue +119 -0
  132. package/src/widgets/LLMChat.vue +327 -0
  133. package/src/widgets/LinkSetting.vue +209 -0
  134. package/src/widgets/LinkSettingModal.vue +173 -0
  135. package/src/widgets/MenuItem1Setting.vue +78 -0
  136. package/src/widgets/ModalSetting.vue +94 -0
  137. package/src/widgets/MultiValueSetting.vue +121 -0
  138. package/src/widgets/MultiValueSetting2.vue +147 -0
  139. package/src/widgets/OGSettingModal.vue +103 -0
  140. package/src/widgets/ParagraphSetting.vue +89 -0
  141. package/src/widgets/PositionSetting.vue +209 -0
  142. package/src/widgets/PresetBar.vue +1011 -0
  143. package/src/widgets/PresetBarPivot.vue +330 -0
  144. package/src/widgets/PresetBarPivotColumnEdit.vue +186 -0
  145. package/src/widgets/PresetBarPivotValueEdit.vue +172 -0
  146. package/src/widgets/PresetSelector.vue +713 -0
  147. package/src/widgets/Review.vue +131 -0
  148. package/src/widgets/ReviewSetting.vue +201 -0
  149. package/src/widgets/SearchModalSetting.vue +70 -0
  150. package/src/widgets/Share.vue +164 -0
  151. package/src/widgets/ShareSetting.vue +99 -0
  152. package/src/widgets/SvgSetting.vue +133 -0
  153. package/src/widgets/TableSetting.vue +172 -0
  154. package/src/widgets/TestimonialSetting.vue +161 -0
  155. package/src/widgets/TextBlockSetting.vue +81 -0
  156. package/src/widgets/ThumbnailSetting.vue +49 -0
  157. package/src/widgets/WebComponentSelector.vue +205 -0
  158. package/src/widgets/WebDatasourceSelector.vue +153 -0
  159. package/src/widgets/WebLayoutSelector.vue +139 -0
  160. package/src/widgets/WebPageBuilder.vue +1962 -0
  161. package/src/widgets/YoutubeVideoSetting.vue +68 -0
  162. package/docs/components/ArrayList.md +0 -39
  163. package/docs/components/Box.md +0 -15
  164. package/docs/components/ChartBar.md +0 -34
  165. package/docs/components/DayTimeRange.md +0 -20
  166. package/docs/components/ErrorText.md +0 -15
  167. package/docs/components/FBLogin.md +0 -21
  168. package/docs/components/GHeatMaps.md +0 -16
  169. package/docs/components/GSignIn.md +0 -21
  170. package/docs/components/GmapsDirection.md +0 -20
  171. package/docs/components/IconMenu.md +0 -20
@@ -0,0 +1,1113 @@
1
+ <template>
2
+ <div :class="$style.comp">
3
+
4
+ <!-- ============================ LEFT: builder ============================= -->
5
+ <div :style="{ width: config.params.leftWidth + 'px' }"
6
+ class="relative shrink-0 flex flex-col bg-base-300 overflow-hidden">
7
+
8
+ <div class="px-3 pt-3">
9
+ <Tabs :items="[
10
+ { text: 'Presets', value: 'presets' },
11
+ { text: 'Build', value: 'build' },
12
+ { text: 'Data', value: 'data' },
13
+ ]" v-model="config.params.leftTab" />
14
+ </div>
15
+
16
+ <!-- ---------- Presets tab ---------- -->
17
+ <template v-if="config.params.leftTab === 'presets'">
18
+ <div class="flex flex-row items-center px-4 pt-3 pb-1">
19
+ <h4 class="flex-1">Presets</h4>
20
+ <button type="button" class="text-primary px-1" title="Add preset" @click="addPreset()">
21
+ <svg width="18" height="18" class="fill-primary-500 hover:fill-primary-600" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32V224H48c-17.7 0-32 14.3-32 32s14.3 32 32 32H192V432c0 17.7 14.3 32 32 32s32-14.3 32-32V288H400c17.7 0 32-14.3 32-32s-14.3-32-32-32H256V80z"/></svg>
22
+ </button>
23
+ </div>
24
+
25
+ <div class="flex-1 overflow-y-auto px-2 pb-3">
26
+ <div v-for="preset in config.presets" :key="preset.uid"
27
+ class="flex flex-row items-center gap-2 px-2 py-1 rounded-md cursor-pointer"
28
+ :class="preset.uid === activeUid ? 'bg-base-500' : 'hover:bg-base-400'"
29
+ @click="selectPreset(preset)">
30
+ <div class="w-[18px] shrink-0 flex items-center text-text-300">
31
+ <svg width="14" height="14" class="fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M0 96C0 60.7 28.7 32 64 32H448c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zM64 96v64H448V96H64zm384 128H256v64H448V224zm0 128H256v64H448V352zM192 352H64v64H192V352zm0-128H64v64H192V224z"/></svg>
32
+ </div>
33
+ <div class="flex-1 text-ellipsis-nowrap">{{ preset.name }}</div>
34
+ <button type="button" class="px-1 opacity-60 hover:opacity-100"
35
+ @click.stop="presetMenu.open($event.currentTarget, { item: preset })">
36
+ <svg width="14" height="14" class="fill-text-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 512"><path d="M64 360a56 56 0 1 0 0 112 56 56 0 1 0 0-112zm0-160a56 56 0 1 0 0 112 56 56 0 1 0 0-112zM120 96A56 56 0 1 0 8 96a56 56 0 1 0 112 0z"/></svg>
37
+ </button>
38
+ </div>
39
+
40
+ <div v-if="!config.presets.length" class="text-text-400 text-sm text-center py-6">
41
+ No presets yet.<br />Add one to get started.
42
+ </div>
43
+ </div>
44
+ </template>
45
+
46
+ <!-- ---------- Data sources tab ---------- -->
47
+ <template v-else-if="config.params.leftTab === 'data'">
48
+ <div class="flex flex-row items-center px-4 pt-3 pb-1">
49
+ <h4 class="flex-1">Data sources</h4>
50
+ <button v-if="connected" type="button" class="px-1 text-text-300 hover:text-primary" title="Reload" @click="loadDatasources">
51
+ <svg width="15" height="15" class="fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M463.5 224H472c13.3 0 24-10.7 24-24V72c0-9.7-5.8-18.5-14.8-22.2s-19.3-1.7-26.2 5.2L413.4 96.6c-87.6-86.5-228.7-86.2-315.8 1c-87.5 87.5-87.5 229.3 0 316.8s229.3 87.5 316.8 0c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0c-62.5 62.5-163.8 62.5-226.3 0s-62.5-163.8 0-226.3c62.2-62.2 162.7-62.5 225.3-1L327 183c-6.9 6.9-8.9 17.2-5.2 26.2s12.5 14.8 22.2 14.8H463.5z"/></svg>
52
+ </button>
53
+ </div>
54
+
55
+ <div class="flex-1 overflow-y-auto px-3 py-2 flex flex-col gap-2">
56
+ <div v-if="!connected" class="text-text-400 text-sm text-center py-6">
57
+ Not connected.<br />Data sources load from the server.
58
+ </div>
59
+ <div v-else-if="!dsList.length" class="text-text-400 text-sm text-center py-6">
60
+ No data sources available.
61
+ </div>
62
+
63
+ <div v-for="ds in dsList" :key="ds.key" class="rounded-lg border-[1px] border-border-50 overflow-hidden bg-base-400">
64
+ <button type="button" class="w-full flex flex-row items-center gap-2 px-3 py-2 hover:bg-base-500" @click="toggleDs(ds.key)">
65
+ <div class="w-[16px] shrink-0 flex items-center text-primary">
66
+ <svg width="13" height="13" class="fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M448 80v48c0 44.2-100.3 80-224 80S0 172.2 0 128V80C0 35.8 100.3 0 224 0S448 35.8 448 80zM393.2 214.7c20.8-7.4 39.9-16.9 54.8-28.6V288c0 44.2-100.3 80-224 80S0 332.2 0 288V186.1c14.9 11.8 34 21.2 54.8 28.6C99.7 230.7 159.5 240 224 240s124.3-9.3 169.2-25.3zM0 346.1c14.9 11.8 34 21.2 54.8 28.6C99.7 390.7 159.5 400 224 400s124.3-9.3 169.2-25.3c20.8-7.4 39.9-16.9 54.8-28.6V432c0 44.2-100.3 80-224 80S0 476.2 0 432V346.1z"/></svg>
67
+ </div>
68
+ <div class="flex-1 text-left min-w-0">
69
+ <div class="text-sm truncate">{{ ds.label }}</div>
70
+ <div class="text-[11px] text-text-400 truncate">{{ ds.fields.length }} fields · {{ ds.aggregations.join(' / ') }}</div>
71
+ </div>
72
+ <svg width="11" height="11" class="fill-text-300 transition-transform shrink-0"
73
+ :style="{ transform: expandedDs === ds.key ? 'rotate(90deg)' : 'none' }"
74
+ xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 512"><path d="M64 448c-8.2 0-16.4-3.1-22.6-9.4c-12.5-12.5-12.5-32.8 0-45.3L178.8 256L41.4 118.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l160 160c12.5 12.5 12.5 32.8 0 45.3l-160 160C80.4 444.9 72.2 448 64 448z"/></svg>
75
+ </button>
76
+ <div v-if="expandedDs === ds.key" class="px-3 pb-2 pt-1 flex flex-col gap-1 border-t-[1px] border-border-50">
77
+ <div v-for="f in ds.fields" :key="f.key" class="flex flex-row items-center gap-2 text-xs py-[2px]">
78
+ <span class="flex-1 truncate text-text-300">{{ f.label }}</span>
79
+ <span class="text-[10px] uppercase tracking-wide text-text-400">{{ f.type }}</span>
80
+ </div>
81
+ <button type="button" class="mt-1 text-primary text-xs text-left hover:underline" @click="newMetricFrom(ds)">
82
+ + New metric from this source
83
+ </button>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </template>
88
+
89
+ <!-- ---------- Build tab ---------- -->
90
+ <template v-else>
91
+ <div class="flex flex-row items-center gap-2 px-4 pt-3 pb-1">
92
+ <h4 class="flex-1 truncate">{{ activePreset ? activePreset.name : 'Structure' }}</h4>
93
+ <button v-if="connected" type="button" class="px-1 text-text-300 hover:text-primary" title="Refresh data" @click="hydrateActive">
94
+ <svg width="15" height="15" :class="hydrating ? 'animate-spin fill-primary' : 'fill-current'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M463.5 224H472c13.3 0 24-10.7 24-24V72c0-9.7-5.8-18.5-14.8-22.2s-19.3-1.7-26.2 5.2L413.4 96.6c-87.6-86.5-228.7-86.2-315.8 1c-87.5 87.5-87.5 229.3 0 316.8s229.3 87.5 316.8 0c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0c-62.5 62.5-163.8 62.5-226.3 0s-62.5-163.8 0-226.3c62.2-62.2 162.7-62.5 225.3-1L327 183c-6.9 6.9-8.9 17.2-5.2 26.2s12.5 14.8 22.2 14.8H463.5z"/></svg>
95
+ </button>
96
+ <button type="button" ref="addBtn" class="text-primary px-1" title="Add component"
97
+ @click="addMenu.open($refs.addBtn)">
98
+ <svg width="18" height="18" class="fill-primary-500 hover:fill-primary-600" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32V224H48c-17.7 0-32 14.3-32 32s14.3 32 32 32H192V432c0 17.7 14.3 32 32 32s32-14.3 32-32V288H400c17.7 0 32-14.3 32-32s-14.3-32-32-32H256V80z"/></svg>
99
+ </button>
100
+ </div>
101
+
102
+ <div v-if="selectedNode" class="px-4 text-[11px] text-text-400 pb-1">
103
+ Adding to: <span class="text-text-200">{{ selectedNode.type === 'group' ? selectedNode.name : 'top level' }}</span>
104
+ </div>
105
+
106
+ <!-- Structure tree -->
107
+ <div class="flex-1 overflow-y-auto px-3 py-1">
108
+ <TreeView v-if="activePreset" v-model="structure" :selected-item="selectedNode"
109
+ @add="onTreeAdd" @duplicate="onTreeDuplicate" @change="onStructureChange">
110
+ <template #default="{ obj }">
111
+ <div class="flex-1 flex flex-row items-center gap-2 py-1 min-w-0" @click="selectNode(obj)">
112
+ <div class="w-[16px] shrink-0 flex items-center" :class="nodeColor(obj.type)">
113
+ <svg width="13" height="13" class="fill-current" xmlns="http://www.w3.org/2000/svg" :viewBox="typeIcon(obj.type).box"><path :d="typeIcon(obj.type).path"/></svg>
114
+ </div>
115
+ <div class="flex-1 truncate text-sm">{{ obj.name || labelFor(obj.type) }}</div>
116
+ <div v-if="obj.type === 'group'" class="text-[10px] text-text-400 uppercase tracking-wide shrink-0">
117
+ {{ (obj.layout && obj.layout.mode) || 'grid' }}
118
+ </div>
119
+ </div>
120
+ </template>
121
+ </TreeView>
122
+
123
+ <div v-if="activePreset && !structure.length" class="text-text-400 text-sm text-center py-6">
124
+ No components.<br />Use <span class="text-primary">+</span> to add one.
125
+ </div>
126
+ <div v-if="!activePreset" class="text-text-400 text-sm text-center py-6">
127
+ Select a preset first.
128
+ </div>
129
+ </div>
130
+
131
+ <!-- Node inspector (drag its top edge to resize; height persists in
132
+ config.params.inspectorHeight). Outer div owns the height + drag
133
+ handle, inner div scrolls — the handle must not scroll away. -->
134
+ <div v-if="selectedNode" class="relative shrink-0 max-h-[75%] border-t-[1px] border-border-50 bg-base-400"
135
+ :style="{ height: config.params.inspectorHeight + 'px' }">
136
+ <div :class="$style.resizeT" @mousedown="(e) => $util.dragResize(e, resizeInspector)" />
137
+ <div class="h-full p-4 flex flex-col gap-3 overflow-y-auto">
138
+ <div class="flex flex-row items-center gap-2">
139
+ <div class="w-[16px] shrink-0 flex items-center" :class="nodeColor(selectedNode.type)">
140
+ <svg width="13" height="13" class="fill-current" xmlns="http://www.w3.org/2000/svg" :viewBox="typeIcon(selectedNode.type).box"><path :d="typeIcon(selectedNode.type).path"/></svg>
141
+ </div>
142
+ <div class="flex-1 text-sm text-text-300">{{ labelFor(selectedNode.type) }} settings</div>
143
+ </div>
144
+
145
+ <label class="text-xs text-text-400">Name</label>
146
+ <Textbox v-model="selectedNode.name" placeholder="Component name" />
147
+
148
+ <!-- Group: layout -->
149
+ <template v-if="selectedNode.type === 'group'">
150
+ <label class="text-xs text-text-400">Layout</label>
151
+ <Dropdown :model-value="layoutMode(selectedNode)" @update:model-value="setLayoutMode(selectedNode, $event)">
152
+ <option value="grid">Grid</option>
153
+ <option value="flex">Flex (wrap)</option>
154
+ </Dropdown>
155
+ <template v-if="layoutMode(selectedNode) === 'grid'">
156
+ <label class="text-xs text-text-400">Columns</label>
157
+ <Dropdown :model-value="String(layoutCols(selectedNode))"
158
+ @update:model-value="setLayoutCols(selectedNode, Number($event))">
159
+ <option v-for="n in 4" :key="n" :value="String(n)">{{ n }} column{{ n > 1 ? 's' : '' }}</option>
160
+ </Dropdown>
161
+ </template>
162
+ </template>
163
+
164
+ <!-- Metric static data (offline / manual) -->
165
+ <template v-else-if="selectedNode.type === 'metric' && !hasDatasources">
166
+ <label class="text-xs text-text-400">Label</label>
167
+ <Textbox v-model="metricData(selectedNode).label" placeholder="Metric label" />
168
+ <label class="text-xs text-text-400">Value</label>
169
+ <Textbox v-model="metricData(selectedNode).value" type="tel" item-class="text-right" />
170
+ <label class="text-xs text-text-400">Delta % (optional)</label>
171
+ <Textbox v-model="metricData(selectedNode).delta" type="tel" item-class="text-right" />
172
+ </template>
173
+
174
+ <!-- Query builder (data-source backed) -->
175
+ <template v-else-if="hasDatasources">
176
+ <label class="text-xs text-text-400">Data source</label>
177
+ <Dropdown :model-value="query(selectedNode).datasource || ''"
178
+ @update:model-value="setDatasource(selectedNode, $event)">
179
+ <option value="">— none —</option>
180
+ <option v-for="ds in dsList" :key="ds.key" :value="ds.key">{{ ds.label }}</option>
181
+ </Dropdown>
182
+
183
+ <template v-if="query(selectedNode).datasource">
184
+ <!-- metric query -->
185
+ <template v-if="selectedNode.type === 'metric'">
186
+ <label class="text-xs text-text-400">Aggregation</label>
187
+ <Dropdown v-model="query(selectedNode).aggregation" @change="onQueryEdit">
188
+ <option v-for="a in aggOptions(selectedNode)" :key="a" :value="a">{{ aggLabel(a) }}</option>
189
+ </Dropdown>
190
+ <template v-if="query(selectedNode).aggregation && query(selectedNode).aggregation !== 'count'">
191
+ <label class="text-xs text-text-400">Field</label>
192
+ <Dropdown v-model="query(selectedNode).field" @change="onQueryEdit">
193
+ <option value="">— select —</option>
194
+ <option v-for="f in aggFields(selectedNode)" :key="f.key" :value="f.key">{{ f.label }}</option>
195
+ </Dropdown>
196
+ </template>
197
+ </template>
198
+
199
+ <!-- chart query -->
200
+ <template v-else-if="selectedNode.type === 'chart'">
201
+ <label class="text-xs text-text-400">Group by</label>
202
+ <Dropdown v-model="query(selectedNode).groupBy" @change="onQueryEdit">
203
+ <option value="">— select —</option>
204
+ <option v-for="f in fieldsOf(selectedNode)" :key="f.key" :value="f.key">{{ f.label }}</option>
205
+ </Dropdown>
206
+ <label class="text-xs text-text-400">Bucket</label>
207
+ <Dropdown v-model="query(selectedNode).dateModifier" @change="onQueryEdit">
208
+ <option value="">Exact value</option>
209
+ <option value="day">By day</option>
210
+ <option value="week">By week</option>
211
+ <option value="month">By month</option>
212
+ <option value="year">By year</option>
213
+ </Dropdown>
214
+ <label class="text-xs text-text-400">Limit</label>
215
+ <Textbox v-model.number="query(selectedNode).limit" type="tel" item-class="text-right" @change="onQueryEdit" />
216
+ </template>
217
+
218
+ <!-- table query -->
219
+ <template v-else-if="selectedNode.type === 'table'">
220
+ <label class="text-xs text-text-400">Page size</label>
221
+ <Textbox v-model.number="query(selectedNode).pageSize" type="tel" item-class="text-right" @change="onQueryEdit" />
222
+ <label class="text-xs text-text-400">Order by</label>
223
+ <Dropdown v-model="tableOrderField" @change="onQueryEdit">
224
+ <option value="">— default —</option>
225
+ <option v-for="f in fieldsOf(selectedNode)" :key="f.key" :value="f.key">{{ f.label }}</option>
226
+ </Dropdown>
227
+ </template>
228
+
229
+ <!-- Filters (applied to every data-backed widget) -->
230
+ <label class="text-xs text-text-400 pt-1">Filters</label>
231
+ <div class="flex flex-col gap-2">
232
+ <div v-for="(f, fi) in filtersOf(selectedNode)" :key="fi" class="flex flex-row items-center gap-1">
233
+ <Dropdown v-model="f.field" class="flex-1" @change="onFilterField(selectedNode, f)">
234
+ <option value="">field</option>
235
+ <option v-for="fl in fieldsOf(selectedNode)" :key="fl.key" :value="fl.key">{{ fl.label }}</option>
236
+ </Dropdown>
237
+ <Dropdown v-model="f.op" class="w-[64px]" @change="onQueryEdit">
238
+ <option v-for="o in opsFor(fieldType(selectedNode, f.field))" :key="o" :value="o">{{ opLabel(o) }}</option>
239
+ </Dropdown>
240
+ <Textbox v-model="f.value" class="flex-1" placeholder="value" @change="onQueryEdit" />
241
+ <button type="button" class="px-1 text-text-300 hover:text-red-600" title="Remove filter" @click="removeFilter(selectedNode, fi)">
242
+ <svg width="11" height="11" class="fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/></svg>
243
+ </button>
244
+ </div>
245
+ <button type="button" class="text-primary text-xs text-left hover:underline" @click="addFilter(selectedNode)">+ Add filter</button>
246
+ </div>
247
+ </template>
248
+ </template>
249
+
250
+ <button type="button" class="text-xs text-red-600 hover:underline text-left pt-1" @click="removeNode(selectedNode)">
251
+ Remove component
252
+ </button>
253
+ </div>
254
+ </div>
255
+ </template>
256
+
257
+ <ContextMenu ref="presetMenu" position="bottom-right">
258
+ <template #default="{ context }">
259
+ <div class="flex flex-col min-w-[160px] p-1 py-2">
260
+ <button class="w-full p-2 rounded-md text-left px-3 menu-item" @click="renamePreset(context.item)">Rename</button>
261
+ <button class="w-full p-2 rounded-md text-left px-3 menu-item" @click="duplicatePreset(context.item)">Duplicate</button>
262
+ <button class="w-full p-2 rounded-md text-left px-3 menu-item text-red-600" @click="removePreset(context.item)">Remove</button>
263
+ </div>
264
+ </template>
265
+ </ContextMenu>
266
+
267
+ <ContextMenu ref="addMenu" position="bottom-right">
268
+ <div class="flex flex-col min-w-[180px] p-1 py-2">
269
+ <button v-for="t in ['group', 'metric', 'chart', 'table']" :key="t"
270
+ class="w-full p-2 rounded-md text-left px-3 menu-item flex flex-row items-center gap-2"
271
+ @click="addComponent(t)">
272
+ <div class="w-[16px] shrink-0 flex items-center" :class="nodeColor(t)">
273
+ <svg width="13" height="13" class="fill-current" xmlns="http://www.w3.org/2000/svg" :viewBox="typeIcon(t).box"><path :d="typeIcon(t).path"/></svg>
274
+ </div>
275
+ {{ labelFor(t) }}
276
+ </button>
277
+ </div>
278
+ </ContextMenu>
279
+
280
+ <div :class="$style.resizeL" @mousedown="(e) => $util.dragResize(e, resizeLeft)" />
281
+ </div>
282
+
283
+ <!-- ============================ MIDDLE: dashboard ========================== -->
284
+ <div class="flex-1 flex flex-col min-w-0 bg-base-400">
285
+
286
+ <div class="flex flex-row items-center gap-3 px-6 py-3 border-b-[1px] border-border-50">
287
+ <h3 class="flex-1 truncate">{{ activePreset ? activePreset.name : 'Dashboard' }}</h3>
288
+ <Button variant="secondary" class="w-[110px]" :state="activePreset ? 1 : -1" @click="resetSamples">Load samples</Button>
289
+ </div>
290
+
291
+ <div class="flex-1 overflow-y-auto p-6">
292
+
293
+ <div v-if="activePreset && structure.length" class="flex flex-col gap-6">
294
+ <template v-for="node in structure" :key="node.uid">
295
+
296
+ <!-- Group: section heading + children laid out per node.layout -->
297
+ <section v-if="node.type === 'group'" class="flex flex-col gap-3">
298
+ <div class="flex flex-row items-center gap-2">
299
+ <h4 class="flex-1 truncate">{{ node.name }}</h4>
300
+ <button type="button" class="opacity-40 hover:opacity-100" title="Remove section" @click="removeNode(node)">
301
+ <svg width="12" height="12" class="fill-text-300 hover:fill-red-600" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/></svg>
302
+ </button>
303
+ </div>
304
+ <div :style="groupStyle(node)">
305
+ <Widget v-for="child in node.items" :key="child.uid" :node="child" @remove="removeNode(child)" />
306
+ </div>
307
+ <div v-if="!node.items || !node.items.length" class="text-text-400 text-sm">Empty section — add widgets from the Build tab or ask the AI.</div>
308
+ </section>
309
+
310
+ <!-- Leaf widget -->
311
+ <Widget v-else :node="node" @remove="removeNode(node)" />
312
+ </template>
313
+ </div>
314
+
315
+ <!-- Empty preset / no preset -->
316
+ <div v-else class="h-full flex flex-col items-center justify-center text-center text-text-300 gap-3">
317
+ <svg width="44" height="44" class="fill-text-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M0 96C0 60.7 28.7 32 64 32H448c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zM64 96v64H448V96H64zm384 128H256v64H448V224zm0 128H256v64H448V352zM192 352H64v64H192V352zm0-128H64v64H192V224z"/></svg>
318
+ <p class="text-lg">{{ activePreset ? 'This preset is empty — add components or ask the AI to build it.' : 'Add a preset to get started.' }}</p>
319
+ </div>
320
+ </div>
321
+ </div>
322
+
323
+ <!-- ============================ RIGHT: chat =============================== -->
324
+ <div :style="{ width: config.params.rightWidth + 'px' }"
325
+ class="relative shrink-0 flex flex-col bg-base-300 overflow-hidden">
326
+
327
+ <div :class="$style.resizeR" @mousedown="(e) => $util.dragResize(e, resizeRight)" />
328
+
329
+ <!-- Ask AI panel — server-driven like web-scaffold List: `${controller}.ai-chat`
330
+ receives { messages, structure } and returns { reply, structure?|nodes?, model }.
331
+ Offline mode routes through `chatFn` (canned sample widgets). -->
332
+ <LLMChat class="flex-1 min-h-0"
333
+ :controller="controller"
334
+ :messages="messages"
335
+ :extra="getChatContext"
336
+ :chat-fn="useServerChat ? null : offlineChat"
337
+ title="AI Assistant" placeholder="Describe the dashboard you want..."
338
+ @result="onChatResult" @reset="clearChat" />
339
+ </div>
340
+
341
+ <!-- ============================ Rename card =============================== -->
342
+ <Card ref="renameCard" :float="true" width="420" height="230">
343
+ <template #default="{ context }">
344
+ <div class="flex-1 flex flex-col">
345
+ <div class="p-6 pb-2"><h3>Rename Preset</h3></div>
346
+ <div class="p-6 pt-2 flex-1">
347
+ <Textbox v-model="context.name" placeholder="Preset name" maxlength="100" />
348
+ </div>
349
+ <div class="p-6 pt-0 flex flex-row justify-end gap-2">
350
+ <Button variant="secondary" class="w-[90px]" @click="renameCard.close()">Cancel</Button>
351
+ <Button class="w-[90px]" @click="context.apply(context.name)">Save</Button>
352
+ </div>
353
+ </div>
354
+ </template>
355
+ </Card>
356
+ </div>
357
+ </template>
358
+
359
+ <script setup>
360
+ import { computed, defineComponent, h, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
361
+ import { Bar } from 'vue-chartjs'
362
+ import LLMChat from '../widgets/LLMChat.vue'
363
+ import VirtualTable from './VirtualTable.vue'
364
+ import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip, Legend } from 'chart.js'
365
+ ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend)
366
+
367
+ // AIDashboard mirrors the UserConfig model (UserConfig hasMany UserPreset):
368
+ // config.params — UserConfig.params (UI state: widths, presetIdx, leftTab)
369
+ // config.presets[] — UserPreset rows: { uid, name, params }
370
+ // config.presets[].params — UserPreset.params: { structure: node[], messages: [] }
371
+ //
372
+ // It runs in two modes:
373
+ // • Offline — no `socket`/`controller`: the `samples` prop seeds structure and
374
+ // the chat panel appends canned widgets. The host page persists `config`.
375
+ // • Connected — `socket` + `controller` given: mirrors the server AIDashboard
376
+ // mixin. `${controller}.load-dashboard` hydrates node.data from datasources,
377
+ // `${controller}.save` persists the whole config (debounced), and
378
+ // `${controller}.ai-chat` drives the assistant. `datasources` describes the
379
+ // query-builder options: { [key]: { label, fields: [{ key, label }] } }.
380
+ const { controller, socket, config, samples, datasources, autoSave, serverChat } = defineProps({
381
+ controller: { type: String, default: '' },
382
+ socket: { type: Object, default: null },
383
+ config: { type: Object, required: true },
384
+ samples: { type: Object, default: () => ({ structure: [], widgets: {} }) },
385
+ datasources: { type: Object, default: () => ({}) },
386
+ // Persist edits through `${controller}.save`. Turn off when a host page owns
387
+ // persistence (e.g. via loadConfig/saveConfig) to avoid a double write.
388
+ autoSave: { type: Boolean, default: true },
389
+ // Route chat through `${controller}.ai-chat`. Turn off to keep the offline
390
+ // canned-widget generator even when a socket is connected.
391
+ serverChat: { type: Boolean, default: true },
392
+ })
393
+
394
+ const presetMenu = useTemplateRef('presetMenu')
395
+ const addMenu = useTemplateRef('addMenu')
396
+ const renameCard = useTemplateRef('renameCard')
397
+
398
+ const connected = computed(() => !!(socket && controller))
399
+ const useServerChat = computed(() => connected.value && serverChat)
400
+
401
+ // Data sources power the query builder + the Data tab. When connected they come
402
+ // from `${controller}.load-datasource`; otherwise from the `datasources` prop.
403
+ const serverDatasources = ref(null)
404
+ const expandedDs = ref(null)
405
+
406
+ function normalizeDs(list) {
407
+ return (list || []).map(d => ({
408
+ key: d.key,
409
+ label: d.label ?? d.key,
410
+ fields: Array.isArray(d.fields) ? d.fields : [],
411
+ aggregations: Array.isArray(d.aggregations) ? d.aggregations : ['count', 'sum', 'avg', 'min', 'max'],
412
+ }))
413
+ }
414
+
415
+ const dsList = computed(() => serverDatasources.value
416
+ ?? normalizeDs(Object.entries(datasources).map(([key, v]) => ({ key, ...v }))))
417
+ const hasDatasources = computed(() => dsList.value.length > 0)
418
+
419
+ async function loadDatasources() {
420
+ if (!connected.value) return
421
+ try {
422
+ const res = await socket.send(`${controller}.load-datasource`, {})
423
+ const list = Array.isArray(res?.datasources) ? res.datasources : (Array.isArray(res) ? res : [])
424
+ serverDatasources.value = normalizeDs(list)
425
+ } catch (e) {
426
+ // keep any prop-provided datasources on failure
427
+ }
428
+ }
429
+
430
+ let seq = 0
431
+ function uid(prefix = 'w') {
432
+ return `${prefix}-${Date.now().toString(36)}-${seq++}`
433
+ }
434
+
435
+ /* ---------------------------------------------------------------------------
436
+ Node templates. Offline templates come from the `samples` prop (uid-less);
437
+ we deep-clone and assign fresh uids on use. Blank nodes are built in-code.
438
+ --------------------------------------------------------------------------- */
439
+ function instantiate(node) {
440
+ const copy = JSON.parse(JSON.stringify(node))
441
+ const assign = (n) => {
442
+ n.uid = uid()
443
+ if (n.type === 'group' && !n.layout) n.layout = { mode: 'grid', cols: metricOnly(n) ? 4 : 2 }
444
+ if (Array.isArray(n.items)) n.items.forEach(assign)
445
+ }
446
+ assign(copy)
447
+ return copy
448
+ }
449
+
450
+ function blankNode(type) {
451
+ const base = { uid: uid(), name: labelFor(type), type }
452
+ if (type === 'group') { base.items = []; base.layout = { mode: 'grid', cols: 2 } }
453
+ else if (type === 'metric') base.data = { label: 'Metric', value: 0 }
454
+ else if (type === 'chart') base.data = { series: [] }
455
+ else if (type === 'table') base.data = { columns: [], rows: [] }
456
+ if (hasDatasources.value && type !== 'group') base.query = {}
457
+ return base
458
+ }
459
+
460
+ function labelFor(type) {
461
+ return { group: 'Section', metric: 'Metric', chart: 'Chart', table: 'Table' }[type] ?? type
462
+ }
463
+
464
+ function nodeColor(type) {
465
+ return {
466
+ group: 'text-text-300',
467
+ metric: 'text-primary',
468
+ chart: 'text-green-600',
469
+ table: 'text-amber-600',
470
+ }[type] ?? 'text-text-300'
471
+ }
472
+
473
+ // Distinct icons per node type (FontAwesome paths).
474
+ function typeIcon(type) {
475
+ switch (type) {
476
+ case 'metric': return { box: '0 0 448 512', path: 'M416 127.1h-58.23l9.789-58.74c2.906-17.44-8.875-33.92-26.31-36.83c-17.53-2.875-33.92 8.891-36.83 26.31L292.7 127.1H197.6l9.789-58.74c2.906-17.44-8.875-33.92-26.31-36.83c-17.53-2.875-33.92 8.891-36.83 26.31L132.7 127.1H64c-17.67 0-32 14.33-32 31.1c0 17.67 14.33 31.1 32 31.1h58.23l-21.33 128H32c-17.67 0-32 14.33-32 31.1s14.33 32 32 32h58.23l-9.789 58.74c-2.906 17.44 8.875 33.92 26.31 36.83C108.5 479.9 110.3 480 112 480c15.34 0 28.84-11.09 31.45-26.73l11.79-70.79h95.12l-9.789 58.74c-2.906 17.44 8.875 33.92 26.31 36.83C258.5 479.9 260.3 480 262 480c15.34 0 28.84-11.09 31.45-26.73l11.79-70.79H384c17.67 0 32-14.33 32-32s-14.33-31.1-32-31.1h-58.23l21.33-128H416c17.67 0 32-14.33 32-31.1C448 142.3 433.7 127.1 416 127.1zM282.9 319.1H187.8L209.1 191.1h95.12L282.9 319.1z' }
477
+ case 'chart': return { box: '0 0 512 512', path: 'M32 32c17.7 0 32 14.3 32 32V400c0 8.8 7.2 16 16 16H480c17.7 0 32 14.3 32 32s-14.3 32-32 32H80c-44.2 0-80-35.8-80-80V64C0 46.3 14.3 32 32 32zM160 224c17.7 0 32 14.3 32 32v64c0 17.7-14.3 32-32 32s-32-14.3-32-32V256c0-17.7 14.3-32 32-32zm128-64V320c0 17.7-14.3 32-32 32s-32-14.3-32-32V160c0-17.7 14.3-32 32-32s32 14.3 32 32zm64 32c17.7 0 32 14.3 32 32v96c0 17.7-14.3 32-32 32s-32-14.3-32-32V256c0-17.7 14.3-32 32-32z' }
478
+ case 'table': return { box: '0 0 512 512', path: 'M0 96C0 60.7 28.7 32 64 32H448c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zM64 96v64H448V96H64zm384 128H256v64H448V224zm0 128H256v64H448V352zM192 352H64v64H192V352zm0-128H64v64H192V224z' }
479
+ default: return { box: '0 0 448 512', path: 'M384 32c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96C0 60.7 28.7 32 64 32H384zM160 144c-13.3 0-24 10.7-24 24s10.7 24 24 24H352c13.3 0 24-10.7 24-24s-10.7-24-24-24H160zM96 168a32 32 0 1 0 64 0 32 32 0 1 0 -64 0zM160 240c-13.3 0-24 10.7-24 24s10.7 24 24 24H352c13.3 0 24-10.7 24-24s-10.7-24-24-24H160zM96 264a32 32 0 1 0 64 0 32 32 0 1 0 -64 0zm64 48c-13.3 0-24 10.7-24 24s10.7 24 24 24H352c13.3 0 24-10.7 24-24s-10.7-24-24-24H160zM96 360a32 32 0 1 0 64 0 32 32 0 1 0 -64 0z' }
480
+ }
481
+ }
482
+
483
+ /* ---------- Presets (UserConfig → UserPreset shape) ---------- */
484
+ function createPreset(name) {
485
+ return { uid: uid('p'), name, params: { structure: [], messages: [] } }
486
+ }
487
+
488
+ function normalizePreset(preset) {
489
+ if (!preset.uid) preset.uid = uid('p')
490
+ if (!preset.params || typeof preset.params !== 'object') preset.params = {}
491
+ if (!Array.isArray(preset.params.structure)) preset.params.structure = []
492
+ if (!Array.isArray(preset.params.messages)) preset.params.messages = []
493
+ // ensure every group carries a layout so the inspector/preview have defaults
494
+ const walk = (list) => list.forEach(n => {
495
+ if (n.type === 'group') {
496
+ if (!n.layout || typeof n.layout !== 'object') n.layout = { mode: 'grid', cols: metricOnly(n) ? 4 : 2 }
497
+ if (Array.isArray(n.items)) walk(n.items)
498
+ }
499
+ })
500
+ walk(preset.params.structure)
501
+ // a reload can leave a message stuck mid-stream — freeze it
502
+ for (const m of preset.params.messages) {
503
+ if (m.isLoading) {
504
+ m.isLoading = false
505
+ m.body = m.display || m.body || 'Cancelled.'
506
+ m.display = m.body
507
+ }
508
+ }
509
+ return preset
510
+ }
511
+
512
+ /* ---------- Persisted state (UserConfig shape) ---------- */
513
+ if (!config.params) config.params = {}
514
+ if (config.params.leftWidth == null) config.params.leftWidth = 260
515
+ if (config.params.rightWidth == null) config.params.rightWidth = 360
516
+ if (config.params.inspectorHeight == null) config.params.inspectorHeight = 300
517
+ if (config.params.leftTab == null) config.params.leftTab = 'presets'
518
+ if (!Array.isArray(config.presets)) config.presets = []
519
+ config.presets.forEach(normalizePreset)
520
+
521
+ // first run: seed one preset from the samples
522
+ if (!config.presets.length) {
523
+ const preset = createPreset('My Dashboard')
524
+ preset.params.structure = (samples.structure || []).map(instantiate)
525
+ config.presets.push(preset)
526
+ }
527
+ if (!config.presets.find(p => p.uid === config.params.presetIdx)) {
528
+ config.params.presetIdx = config.presets[0].uid
529
+ }
530
+
531
+ const activeUid = computed(() => config.params.presetIdx)
532
+ const activePreset = computed(() => config.presets.find(p => p.uid === activeUid.value) ?? null)
533
+ const structure = computed(() => activePreset.value?.params.structure ?? [])
534
+ const messages = computed(() => activePreset.value?.params.messages ?? [])
535
+
536
+ function selectPreset(preset) {
537
+ config.params.presetIdx = preset.uid
538
+ selectedNode.value = null
539
+ }
540
+
541
+ function addPreset(name = 'New Preset') {
542
+ const preset = createPreset(name)
543
+ config.presets.push(preset)
544
+ selectPreset(preset)
545
+ config.params.leftTab = 'build'
546
+ presetMenu.value?.close()
547
+ }
548
+
549
+ function renamePreset(preset) {
550
+ presetMenu.value?.close()
551
+ renameCard.value.open({
552
+ name: preset.name,
553
+ apply(name) {
554
+ if (name != null && `${name}`.trim()) preset.name = `${name}`.trim()
555
+ renameCard.value.close()
556
+ },
557
+ })
558
+ }
559
+
560
+ function duplicatePreset(preset) {
561
+ presetMenu.value?.close()
562
+ const copy = normalizePreset(JSON.parse(JSON.stringify(preset)))
563
+ copy.uid = uid('p')
564
+ copy.name = `${preset.name} (copy)`
565
+ const walk = (nodes) => nodes.forEach(n => { n.uid = uid(); if (Array.isArray(n.items)) walk(n.items) })
566
+ walk(copy.params.structure)
567
+ config.presets.splice(config.presets.indexOf(preset) + 1, 0, copy)
568
+ selectPreset(copy)
569
+ }
570
+
571
+ function removePreset(preset) {
572
+ presetMenu.value?.close()
573
+ const i = config.presets.indexOf(preset)
574
+ if (i >= 0) config.presets.splice(i, 1)
575
+ if (config.params.presetIdx === preset.uid) {
576
+ config.params.presetIdx = config.presets[0]?.uid ?? null
577
+ }
578
+ if (connected.value) socket.send(`${controller}.remove-preset`, { uid: preset.uid }).catch(() => {})
579
+ }
580
+
581
+ /* ---------- Resize ---------- */
582
+ function resizeLeft(w) {
583
+ const next = config.params.leftWidth + w
584
+ if (next >= 220 && next <= 520) config.params.leftWidth = next
585
+ }
586
+ function resizeRight(w) {
587
+ const next = config.params.rightWidth - w
588
+ if (next >= 280 && next <= 560) config.params.rightWidth = next
589
+ }
590
+ // Inspector handle sits on the panel's TOP edge, so dragging up (negative h)
591
+ // grows the panel.
592
+ function resizeInspector(_w, h) {
593
+ const next = config.params.inspectorHeight - h
594
+ if (next >= 140 && next <= 700) config.params.inspectorHeight = next
595
+ }
596
+
597
+ /* ---------- Structure editing (TreeView) ---------- */
598
+ const selectedNode = ref(null)
599
+
600
+ function selectNode(node) {
601
+ selectedNode.value = node
602
+ }
603
+
604
+ function metricOnly(node) {
605
+ const items = node.items || []
606
+ return items.length > 0 && items.every(n => n.type === 'metric')
607
+ }
608
+
609
+ // Add a component from the toolbar menu → into the selected group, else root.
610
+ function addComponent(type) {
611
+ addMenu.value?.close()
612
+ if (!activePreset.value) return
613
+ const node = blankNode(type)
614
+ const target = (selectedNode.value && selectedNode.value.type === 'group')
615
+ ? selectedNode.value.items
616
+ : structure.value
617
+ target.push(node)
618
+ selectNode(node)
619
+ onStructureChange()
620
+ }
621
+
622
+ // TreeView's inline "+" (groups only) → quick-add a metric into that group.
623
+ function onTreeAdd(items) {
624
+ const node = blankNode('metric')
625
+ items.push(node)
626
+ selectNode(node)
627
+ onStructureChange()
628
+ }
629
+
630
+ function onTreeDuplicate(parent, item) {
631
+ const copy = JSON.parse(JSON.stringify(item))
632
+ const walk = (n) => { n.uid = uid(); if (Array.isArray(n.items)) n.items.forEach(walk) }
633
+ walk(copy)
634
+ parent.splice(parent.indexOf(item) + 1, 0, copy)
635
+ selectNode(copy)
636
+ onStructureChange()
637
+ }
638
+
639
+ function onStructureChange() {
640
+ persist()
641
+ }
642
+
643
+ function removeFromTree(list, target) {
644
+ const i = list.findIndex(n => n.uid === target)
645
+ if (i >= 0) { list.splice(i, 1); return true }
646
+ return list.some(n => n.items && removeFromTree(n.items, target))
647
+ }
648
+
649
+ function removeNode(node) {
650
+ removeFromTree(structure.value, node.uid)
651
+ if (selectedNode.value?.uid === node.uid) selectedNode.value = null
652
+ onStructureChange()
653
+ }
654
+
655
+ /* ---------- Layout helpers (group nodes) ---------- */
656
+ function layoutMode(node) { return node.layout?.mode ?? 'grid' }
657
+ function layoutCols(node) { return node.layout?.cols ?? 2 }
658
+
659
+ function setLayoutMode(node, mode) {
660
+ if (!node.layout) node.layout = {}
661
+ node.layout.mode = mode
662
+ if (mode === 'grid' && !node.layout.cols) node.layout.cols = 2
663
+ }
664
+ function setLayoutCols(node, cols) {
665
+ if (!node.layout) node.layout = {}
666
+ node.layout.cols = cols
667
+ }
668
+
669
+ // Inline styles avoid Tailwind purge issues with dynamic column counts.
670
+ function groupStyle(node) {
671
+ const mode = layoutMode(node)
672
+ if (mode === 'flex') {
673
+ return { display: 'flex', flexWrap: 'wrap', gap: '1rem' }
674
+ }
675
+ const cols = Math.min(4, Math.max(1, layoutCols(node)))
676
+ return { display: 'grid', gap: '1rem', gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }
677
+ }
678
+
679
+ /* ---------- Inspector data accessors ---------- */
680
+ function metricData(node) { if (!node.data) node.data = {}; return node.data }
681
+ function query(node) { if (!node.query) node.query = {}; return node.query }
682
+
683
+ function currentDs(node) { return dsList.value.find(d => d.key === query(node).datasource) }
684
+ function fieldsOf(node) { return currentDs(node)?.fields ?? [] }
685
+
686
+ function setDatasource(node, ds) {
687
+ const q = query(node)
688
+ q.datasource = ds
689
+ q.filters = []
690
+ q.field = ''
691
+ if (!ds) return
692
+ // seed sensible query defaults per node type
693
+ if (node.type === 'metric' && !q.aggregation) q.aggregation = 'count'
694
+ if (node.type === 'table') {
695
+ if (q.pageSize == null) q.pageSize = 10
696
+ // default columns = all datasource fields
697
+ q.columns = fieldsOf(node).map(f => ({ key: f.key, label: f.label }))
698
+ }
699
+ onQueryEdit()
700
+ }
701
+
702
+ /* ---- Aggregations ---- */
703
+ function aggOptions(node) { return currentDs(node)?.aggregations ?? ['count', 'sum', 'avg', 'min', 'max'] }
704
+ function aggLabel(a) { return { count: 'Count', sum: 'Sum', avg: 'Average', min: 'Minimum', max: 'Maximum' }[a] ?? a }
705
+ // Fields eligible for the current aggregation (numeric for sum/avg; +date for min/max).
706
+ function aggFields(node) {
707
+ const agg = query(node).aggregation
708
+ const fs = fieldsOf(node)
709
+ if (['sum', 'avg'].includes(agg)) return fs.filter(f => f.type === 'number')
710
+ if (['min', 'max'].includes(agg)) return fs.filter(f => f.type === 'number' || f.type === 'date')
711
+ return fs
712
+ }
713
+
714
+ /* ---- Filters ---- */
715
+ function fieldType(node, key) { return fieldsOf(node).find(f => f.key === key)?.type ?? 'string' }
716
+ function opsFor(type) {
717
+ if (type === 'number' || type === 'date') return ['=', '!=', '>', '>=', '<', '<=']
718
+ return ['=', '!=', 'contains', 'in']
719
+ }
720
+ function opLabel(op) {
721
+ return { '=': '=', '!=': '≠', '>': '>', '>=': '≥', '<': '<', '<=': '≤', contains: 'has', in: 'in' }[op] ?? op
722
+ }
723
+ function filtersOf(node) { const q = query(node); if (!Array.isArray(q.filters)) q.filters = []; return q.filters }
724
+ function addFilter(node) { filtersOf(node).push({ field: '', op: '=', value: '' }); persist() }
725
+ function removeFilter(node, i) { filtersOf(node).splice(i, 1); onQueryEdit() }
726
+ // Keep the operator valid when the filter's field type changes.
727
+ function onFilterField(node, filter) {
728
+ const allowed = opsFor(fieldType(node, filter.field))
729
+ if (!allowed.includes(filter.op)) filter.op = allowed[0]
730
+ onQueryEdit()
731
+ }
732
+
733
+ // Re-hydrate (debounced) after query/filter edits, and persist the change.
734
+ let hydrateTimer = null
735
+ function queueHydrate() { clearTimeout(hydrateTimer); hydrateTimer = setTimeout(() => hydrateActive(), 450) }
736
+ function onQueryEdit() { queueHydrate(); persist() }
737
+
738
+ /* ---- Data tab ---- */
739
+ function toggleDs(key) { expandedDs.value = expandedDs.value === key ? null : key }
740
+ function newMetricFrom(ds) {
741
+ if (!activePreset.value) return
742
+ const node = blankNode('metric')
743
+ node.name = ds.label
744
+ node.query = { datasource: ds.key, aggregation: 'count', filters: [] }
745
+ const target = (selectedNode.value && selectedNode.value.type === 'group') ? selectedNode.value.items : structure.value
746
+ target.push(node)
747
+ selectNode(node)
748
+ config.params.leftTab = 'build'
749
+ onQueryEdit()
750
+ }
751
+
752
+ // Order for table query is stored as [[field, 'desc']]; expose the field only.
753
+ const tableOrderField = computed({
754
+ get() {
755
+ const q = selectedNode.value ? query(selectedNode.value) : {}
756
+ return Array.isArray(q.order) && q.order[0] ? q.order[0][0] : ''
757
+ },
758
+ set(field) {
759
+ if (!selectedNode.value) return
760
+ const q = query(selectedNode.value)
761
+ q.order = field ? [[field, 'desc']] : undefined
762
+ },
763
+ })
764
+
765
+ /* ---------- Reset from samples ---------- */
766
+ function resetSamples() {
767
+ if (!activePreset.value) return
768
+ activePreset.value.params.structure = (samples.structure || []).map(instantiate)
769
+ selectedNode.value = null
770
+ onStructureChange()
771
+ hydrateActive()
772
+ }
773
+
774
+ /* ---------- Server: hydrate + persist ---------- */
775
+ const hydrating = ref(false)
776
+
777
+ // Strip ephemeral `data` from query-backed nodes; keep static data otherwise.
778
+ function cleanNode(node) {
779
+ const { data, ...rest } = node
780
+ const out = { ...rest }
781
+ if (!node.query) out.data = data
782
+ if (Array.isArray(node.items)) out.items = node.items.map(cleanNode)
783
+ return out
784
+ }
785
+
786
+ function cleanConfig() {
787
+ return {
788
+ key: config.key,
789
+ name: config.name,
790
+ params: config.params,
791
+ presets: config.presets.map(p => ({
792
+ uid: p.uid,
793
+ name: p.name,
794
+ params: {
795
+ ...p.params,
796
+ structure: (p.params.structure || []).map(cleanNode),
797
+ messages: (p.params.messages || []).map(m => ({ ...m, isLoading: false })),
798
+ },
799
+ })),
800
+ }
801
+ }
802
+
803
+ let saveTimer = null
804
+ function persist() {
805
+ if (!connected.value || !autoSave) return
806
+ clearTimeout(saveTimer)
807
+ saveTimer = setTimeout(() => {
808
+ socket.send(`${controller}.save`, { config: cleanConfig() }).catch(() => {})
809
+ }, 700)
810
+ }
811
+
812
+ // Merge server-computed `data` into the live structure by uid.
813
+ function mergeData(list, hydrated) {
814
+ for (const h of hydrated) {
815
+ const node = list.find(n => n.uid === h.uid)
816
+ if (!node) continue
817
+ if (h.data) node.data = { ...node.data, ...h.data }
818
+ if (Array.isArray(node.items) && Array.isArray(h.items)) mergeData(node.items, h.items)
819
+ }
820
+ }
821
+
822
+ async function hydrateActive() {
823
+ if (!connected.value || !activePreset.value) return
824
+ hydrating.value = true
825
+ try {
826
+ const preset = activePreset.value
827
+ const res = await socket.send(`${controller}.load-dashboard`, {
828
+ preset: { uid: preset.uid, name: preset.name, params: { structure: preset.params.structure.map(cleanNode) } },
829
+ })
830
+ const hydrated = res?.params?.structure ?? res?.structure ?? []
831
+ mergeData(preset.params.structure, hydrated)
832
+ } catch (e) {
833
+ // leave existing data in place on failure
834
+ } finally {
835
+ hydrating.value = false
836
+ }
837
+ }
838
+
839
+ /* ---------- Chat (LLMChat panel, wired like web-scaffold List) ---------- */
840
+
841
+ /* ----- Offline canned generators (samples-driven) ----- */
842
+ function pickReply(text) {
843
+ const t = text.toLowerCase()
844
+ const has = (key) => !!(samples.widgets && samples.widgets[key])
845
+
846
+ if (t.includes('overview') || t.includes('dashboard') || t.includes('build')) {
847
+ return { body: 'Here\'s a sample dashboard with KPI cards, charts and a table.', full: true }
848
+ }
849
+ if ((t.includes('table') || t.includes('customer') || t.includes('list')) && has('table')) {
850
+ return { body: 'Added a **table** widget.', widget: 'table' }
851
+ }
852
+ if ((t.includes('chart') || t.includes('bar') || t.includes('revenue') || t.includes('trend') || t.includes('graph')) && has('chart')) {
853
+ return { body: 'Added a **bar chart**.', widget: 'chart' }
854
+ }
855
+ if ((t.includes('metric') || t.includes('kpi') || t.includes('card') || t.includes('number')) && has('metric')) {
856
+ return { body: 'Added a **metric** card.', widget: 'metric' }
857
+ }
858
+ if ((t.includes('group') || t.includes('section')) && has('group')) {
859
+ return { body: 'Added an empty **section** — ask me to add widgets to it.', widget: 'group' }
860
+ }
861
+ return {
862
+ body: 'I can generate ' + Object.keys(samples.widgets || {}).join(', ') +
863
+ ' widgets, or a full sample dashboard. Try "add a revenue chart" or "build a dashboard".',
864
+ }
865
+ }
866
+
867
+ function applyGenerated(reply) {
868
+ const preset = activePreset.value
869
+ if (!preset) return
870
+ if (reply.full) {
871
+ preset.params.structure = (samples.structure || []).map(instantiate)
872
+ } else if (reply.widget && samples.widgets?.[reply.widget]) {
873
+ const node = instantiate(samples.widgets[reply.widget])
874
+ const list = preset.params.structure
875
+ const lastGroup = [...list].reverse().find(n => n.type === 'group' && Array.isArray(n.items))
876
+ if (node.type === 'metric' && lastGroup) lastGroup.items.push(node)
877
+ else list.push(node)
878
+ }
879
+ }
880
+
881
+ // Assign uids to any AI-returned nodes that lack them.
882
+ function ensureUids(list) {
883
+ for (const n of list) {
884
+ if (!n.uid) n.uid = uid()
885
+ if (n.type === 'group' && !n.layout) n.layout = { mode: 'grid', cols: 2 }
886
+ if (Array.isArray(n.items)) ensureUids(n.items)
887
+ }
888
+ return list
889
+ }
890
+
891
+ // Context sent with every connected chat turn — the cleaned structure the
892
+ // server needs to compute preset actions (mirrors List.vue's getChatContext).
893
+ function getChatContext() {
894
+ return { structure: structure.value.map(cleanNode) }
895
+ }
896
+
897
+ /* ----- Offline chat driver (LLMChat `chatFn`): canned reply + widgets ----- */
898
+ async function offlineChat({ messages: msgs }) {
899
+ const prompt = [...msgs].reverse().find(m => m.direction === 1)?.body ?? ''
900
+ const reply = pickReply(prompt)
901
+ // brief delay so the typing indicator is visible
902
+ await new Promise(resolve => setTimeout(resolve, 350))
903
+ applyGenerated(reply)
904
+ return { reply: reply.body, model: 'offline-demo' }
905
+ }
906
+
907
+ // Apply the server's structure actions from the chat reply, then re-hydrate
908
+ // widget data and persist (mirrors List.vue's onChatResult).
909
+ async function onChatResult(res) {
910
+ const preset = activePreset.value
911
+ if (!preset) return
912
+ if (Array.isArray(res?.structure)) {
913
+ preset.params.structure = ensureUids(res.structure)
914
+ } else if (Array.isArray(res?.nodes) && res.nodes.length) {
915
+ const list = preset.params.structure
916
+ const lastGroup = [...list].reverse().find(n => n.type === 'group' && Array.isArray(n.items))
917
+ for (const node of ensureUids(res.nodes)) {
918
+ if (node.type === 'metric' && lastGroup) lastGroup.items.push(node)
919
+ else list.push(node)
920
+ }
921
+ }
922
+ await hydrateActive()
923
+ persist()
924
+ }
925
+
926
+ // Clear the active preset's chat history (falls back to no-confirm when the
927
+ // host app doesn't provide the `confirm` dialog).
928
+ const confirmDialog = inject('confirm', null)
929
+
930
+ function clearChat() {
931
+ const preset = activePreset.value
932
+ if (!preset) return
933
+ const doClear = () => {
934
+ preset.params.messages.splice(0, preset.params.messages.length)
935
+ persist()
936
+ }
937
+ confirmDialog ? confirmDialog('Clear chat history?', doClear) : doClear()
938
+ }
939
+
940
+ /* ---------- Server sync lifecycle ---------- */
941
+ onMounted(() => {
942
+ if (connected.value) { loadDatasources(); hydrateActive() }
943
+ })
944
+ watch(activeUid, () => { selectedNode.value = null; if (connected.value) hydrateActive() })
945
+ // persist structural/param edits (no-op unless connected + autoSave)
946
+ watch(() => config, () => persist(), { deep: true })
947
+
948
+ onUnmounted(() => {
949
+ clearTimeout(saveTimer)
950
+ clearTimeout(hydrateTimer)
951
+ })
952
+
953
+ /* ---------- Presentational widget — renders a node's data by type ---------- */
954
+ const Widget = defineComponent({
955
+ name: 'Widget',
956
+ props: { node: { type: Object, required: true } },
957
+ emits: ['remove'],
958
+ setup(props, { emit }) {
959
+ const data = computed(() => props.node.data || {})
960
+
961
+ function renderMetric() {
962
+ return h('div', { class: 'flex flex-col gap-1' }, [
963
+ h('div', { class: 'text-text-300 text-sm' }, data.value.label || props.node.name),
964
+ h('div', { class: 'text-4xl font-bold' }, formatNum(data.value.value)),
965
+ data.value.delta != null && data.value.delta !== ''
966
+ ? h('div', { class: Number(data.value.delta) >= 0 ? 'text-green-600 text-sm' : 'text-red-600 text-sm' },
967
+ `${Number(data.value.delta) >= 0 ? '▲' : '▼'} ${Math.abs(Number(data.value.delta))}%`)
968
+ : null,
969
+ ])
970
+ }
971
+
972
+ // Resolve a theme CSS variable to a concrete color (SSR-safe fallback).
973
+ function cssVar(name, fallback) {
974
+ if (typeof document === 'undefined') return fallback
975
+ const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
976
+ return v || fallback
977
+ }
978
+
979
+ // Bar chart via vue-chartjs. `data.series`/`data.rows` = [{ label, value }].
980
+ function renderChart() {
981
+ const rows = data.value.series || data.value.rows || []
982
+ if (!rows.length) return h('div', { class: 'text-text-400 text-sm' }, 'No data.')
983
+
984
+ const primary = cssVar('--primary', '#5D9CEC')
985
+ const grid = cssVar('--border-50', 'rgba(128,128,128,.15)')
986
+ const tick = cssVar('--text-300', '#8a8a8a')
987
+
988
+ const chartData = {
989
+ labels: rows.map(r => `${r.label}`),
990
+ datasets: [{
991
+ label: props.node.name,
992
+ data: rows.map(r => Number(r.value) || 0),
993
+ backgroundColor: primary,
994
+ hoverBackgroundColor: primary,
995
+ borderRadius: 4,
996
+ maxBarThickness: 46,
997
+ }],
998
+ }
999
+
1000
+ const options = {
1001
+ responsive: true,
1002
+ maintainAspectRatio: false,
1003
+ animation: { duration: 300 },
1004
+ plugins: {
1005
+ legend: { display: false },
1006
+ tooltip: { callbacks: { label: ctx => ` ${formatNum(ctx.parsed.y)}` } },
1007
+ },
1008
+ scales: {
1009
+ x: {
1010
+ grid: { display: false },
1011
+ ticks: {
1012
+ color: tick, font: { size: 11 }, autoSkip: true, maxRotation: 0,
1013
+ callback(value) {
1014
+ const label = `${this.getLabelForValue(value) ?? ''}`
1015
+ return label.length > 12 ? label.slice(0, 12) + '…' : label
1016
+ },
1017
+ },
1018
+ },
1019
+ y: {
1020
+ beginAtZero: true,
1021
+ grid: { color: grid },
1022
+ ticks: { color: tick, font: { size: 11 }, precision: 0 },
1023
+ },
1024
+ },
1025
+ }
1026
+
1027
+ return h('div', { style: { position: 'relative', height: '240px' } }, [
1028
+ h(Bar, { data: chartData, options }),
1029
+ ])
1030
+ }
1031
+
1032
+ // Table widgets render through the shared VirtualTable component.
1033
+ // Columns are passed BY REFERENCE from their persisted home — the node's
1034
+ // `query.columns` (query-backed tables) or `data.columns` (static tables) —
1035
+ // because VirtualTable's drag-resize writes `width` straight into the given
1036
+ // column objects. The config deep-watch then saves the widths with the
1037
+ // preset, so they survive a refresh. Normalization mutates in place (and is
1038
+ // idempotent) for the same reason: fresh copies would drop resizes on the
1039
+ // next render.
1040
+ function tableColumns() {
1041
+ const query = props.node.query
1042
+ const source = (Array.isArray(query?.columns) && query.columns.length)
1043
+ ? query.columns
1044
+ : (data.value.columns || [])
1045
+ source.forEach((c, i) => {
1046
+ if (typeof c === 'string') source[i] = { key: c, label: c }
1047
+ else {
1048
+ if (!c.key) c.key = c.label
1049
+ if (!c.label) c.label = c.key
1050
+ }
1051
+ })
1052
+ return source
1053
+ }
1054
+
1055
+ function renderTable() {
1056
+ const cols = tableColumns()
1057
+ const rows = data.value.rows || []
1058
+ if (!cols.length) return h('div', { class: 'text-text-400 text-sm' }, 'No columns configured.')
1059
+ return h('div', { style: { height: `${Math.min(320, 44 + rows.length * 40 + 8)}px` } }, [
1060
+ h(VirtualTable, { items: rows, columns: cols }),
1061
+ ])
1062
+ }
1063
+
1064
+ function formatNum(v) {
1065
+ const n = Number(v)
1066
+ if (Number.isNaN(n)) return `${v ?? ''}`
1067
+ return n.toLocaleString()
1068
+ }
1069
+
1070
+ return () => h('div', { class: 'group bg-base-300 rounded-xl p-5 flex flex-col gap-3 h-full' }, [
1071
+ h('div', { class: 'flex flex-row items-center gap-2' }, [
1072
+ h('div', { class: 'flex-1 text-sm text-text-300 truncate' }, props.node.name),
1073
+ h('button', {
1074
+ type: 'button',
1075
+ class: 'opacity-0 group-hover:opacity-60 hover:!opacity-100',
1076
+ title: 'Remove widget',
1077
+ onClick: () => emit('remove'),
1078
+ }, [
1079
+ h('svg', { width: 12, height: 12, class: 'fill-text-300 hover:fill-red-600', xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 384 512' }, [
1080
+ h('path', { d: 'M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z' }),
1081
+ ]),
1082
+ ]),
1083
+ ]),
1084
+ props.node.type === 'metric' ? renderMetric()
1085
+ : props.node.type === 'chart' ? renderChart()
1086
+ : props.node.type === 'table' ? renderTable()
1087
+ : h('div', { class: 'text-text-400 text-sm' }, 'Empty group — add child widgets.'),
1088
+ ])
1089
+ },
1090
+ })
1091
+ </script>
1092
+
1093
+ <style module>
1094
+ .comp {
1095
+ @apply flex-1 flex flex-row relative;
1096
+ @apply divide-x divide-border-50;
1097
+ }
1098
+
1099
+ .resizeL {
1100
+ @apply w-[3px] cursor-ew-resize absolute top-0 right-0 bottom-0 z-10;
1101
+ @apply border-r-[1px] border-transparent hover:border-primary;
1102
+ }
1103
+
1104
+ .resizeR {
1105
+ @apply w-[3px] cursor-ew-resize absolute top-0 left-0 bottom-0 z-10;
1106
+ @apply border-l-[1px] border-transparent hover:border-primary;
1107
+ }
1108
+
1109
+ .resizeT {
1110
+ @apply h-[3px] cursor-ns-resize absolute left-0 right-0 top-0 z-10;
1111
+ @apply border-t-[1px] border-transparent hover:border-primary;
1112
+ }
1113
+ </style>