@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.
- package/docs/components/AIDashboard.md +472 -0
- package/docs/components/WebPageBuilder.md +125 -0
- package/package.json +8 -30
- package/src/components/AIChat.vue +479 -0
- package/src/components/AIChatItem.vue +168 -0
- package/src/components/AIChatTextbox.vue +56 -0
- package/src/components/AIDashboard.vue +1113 -0
- package/src/components/AccountIcon.vue +19 -0
- package/src/components/Ahref.vue +200 -0
- package/src/components/Alert.vue +257 -0
- package/src/components/Article.vue +387 -0
- package/src/components/BarChart.vue +87 -0
- package/src/components/Block.vue +31 -0
- package/src/components/Button.vue +258 -0
- package/src/components/Card.vue +255 -0
- package/src/components/Carousel.vue +404 -0
- package/src/components/Cart.vue +192 -0
- package/src/components/CartIcon.vue +89 -0
- package/src/components/Chart.vue +486 -0
- package/src/components/ChatTyping.vue +69 -0
- package/src/components/Checkbox.vue +200 -0
- package/src/components/CodeEditor.vue +158 -0
- package/src/components/CollapsiblePanel.vue +70 -0
- package/src/components/ColorPicker.vue +653 -0
- package/src/components/Confirm.vue +180 -0
- package/src/components/ContextMenu.vue +265 -0
- package/src/components/ContextMenuItem.vue +53 -0
- package/src/components/CopyToClipboard.vue +34 -0
- package/src/components/Countdown.vue +264 -0
- package/src/components/Dashboard.vue +1151 -0
- package/src/components/Datepicker.vue +412 -0
- package/src/components/Datetimepicker.vue +355 -0
- package/src/components/DoughnutChart.vue +68 -0
- package/src/components/Dropdown.vue +314 -0
- package/src/components/FilterCard.vue +177 -0
- package/src/components/Flex.vue +44 -0
- package/src/components/Gmaps.vue +251 -0
- package/src/components/Grid.vue +35 -0
- package/src/components/HTMLEditor.vue +786 -0
- package/src/components/IconMenu2.vue +89 -0
- package/src/components/Image.vue +311 -0
- package/src/components/ImagePreview.vue +93 -0
- package/src/components/ImageUploader.vue +111 -0
- package/src/components/LineChart.vue +104 -0
- package/src/components/Link.vue +95 -0
- package/src/components/List.vue +1549 -0
- package/src/components/ListItem.vue +160 -0
- package/src/components/MarkdownEdit.vue +346 -0
- package/src/components/MarkdownPreview.vue +102 -0
- package/src/components/MenuItem1.vue +36 -0
- package/src/components/Metric.vue +105 -0
- package/src/components/MultiDropdown.vue +124 -0
- package/src/components/OTPField.vue +139 -0
- package/src/components/Paragraph.vue +40 -0
- package/src/components/PieChart.vue +67 -0
- package/src/components/PresetSelectorFilterItem.vue +204 -0
- package/src/components/Radio.vue +109 -0
- package/src/components/SearchModal.vue +153 -0
- package/src/components/Slider.vue +407 -0
- package/src/components/Svg.vue +33 -0
- package/src/components/SvgEditor.vue +173 -0
- package/src/components/Switch.vue +101 -0
- package/src/components/Table.vue +56 -0
- package/src/components/Tabs.vue +131 -0
- package/src/components/Testimonial.vue +86 -0
- package/src/components/Text.vue +34 -0
- package/src/components/TextBlock.vue +50 -0
- package/src/components/TextWithTag.vue +298 -0
- package/src/components/Textarea.vue +195 -0
- package/src/components/Textbox.vue +216 -0
- package/src/components/Thumbnail.vue +27 -0
- package/src/components/Timepicker.vue +223 -0
- package/src/components/Toast.vue +117 -0
- package/src/components/TreeMenu.vue +122 -0
- package/src/components/TreeView.vue +107 -0
- package/src/components/TreeViewItem.vue +452 -0
- package/src/components/Uploader.vue +45 -0
- package/src/components/Video.vue +119 -0
- package/src/components/VirtualGrid.vue +296 -0
- package/src/components/VirtualTable.vue +1047 -0
- package/src/components/YoutubeVideo.vue +196 -0
- package/src/configs/web-page-builder.js +118 -0
- package/src/directives/intersect.js +26 -0
- package/src/hooks/device.js +14 -0
- package/src/index.css +3 -0
- package/src/index.js +635 -0
- package/src/instructions/dashboard.js +203 -0
- package/src/mixin/component.js +306 -0
- package/src/themes/default/index.js +303 -0
- package/src/utils/ai-chat-answer.mjs +240 -0
- package/src/utils/ai-chat.js +167 -0
- package/src/utils/ai-dashboard-chat.mjs +371 -0
- package/src/utils/ai-dashboard.js +321 -0
- package/src/utils/ai-list-chat.mjs +407 -0
- package/src/utils/dashboard.js +317 -0
- package/src/utils/helpers.js +635 -0
- package/src/utils/helpers.mjs +722 -0
- package/src/utils/list.mjs +1564 -0
- package/src/utils/preset-selector.mjs +390 -0
- package/src/utils/selection.js +43 -0
- package/src/utils/wss.js +311 -0
- package/src/utils/wss.mjs +286 -0
- package/src/widgets/AhrefSetting.vue +97 -0
- package/src/widgets/ArticleSetting.vue +457 -0
- package/src/widgets/BackgroundColorSetting.vue +149 -0
- package/src/widgets/BlockSetting.vue +49 -0
- package/src/widgets/BorderColorSetting.vue +53 -0
- package/src/widgets/ButtonSetting.vue +113 -0
- package/src/widgets/CarouselSetting.vue +181 -0
- package/src/widgets/CartSetting.vue +46 -0
- package/src/widgets/CollapsiblePanelSetting.vue +46 -0
- package/src/widgets/ColumnSelector.vue +93 -0
- package/src/widgets/ComponentSetting.vue +2220 -0
- package/src/widgets/ComponentSetting2.vue +927 -0
- package/src/widgets/ContactForm.vue +193 -0
- package/src/widgets/ContactFormSetting.vue +395 -0
- package/src/widgets/CountdownSetting.vue +76 -0
- package/src/widgets/DataListSetting.vue +102 -0
- package/src/widgets/EmbeddedVideo.vue +60 -0
- package/src/widgets/EmbeddedVideoSetting.vue +50 -0
- package/src/widgets/FAQ.vue +98 -0
- package/src/widgets/FAQSetting.vue +94 -0
- package/src/widgets/FeatureList.vue +131 -0
- package/src/widgets/FeatureListSetting.vue +233 -0
- package/src/widgets/FlexSetting.vue +174 -0
- package/src/widgets/GridSetting.vue +165 -0
- package/src/widgets/Header2.vue +65 -0
- package/src/widgets/Header2Setting.vue +193 -0
- package/src/widgets/IconList.vue +51 -0
- package/src/widgets/IconListSetting.vue +188 -0
- package/src/widgets/ImageSetting.vue +119 -0
- package/src/widgets/LLMChat.vue +327 -0
- package/src/widgets/LinkSetting.vue +209 -0
- package/src/widgets/LinkSettingModal.vue +173 -0
- package/src/widgets/MenuItem1Setting.vue +78 -0
- package/src/widgets/ModalSetting.vue +94 -0
- package/src/widgets/MultiValueSetting.vue +121 -0
- package/src/widgets/MultiValueSetting2.vue +147 -0
- package/src/widgets/OGSettingModal.vue +103 -0
- package/src/widgets/ParagraphSetting.vue +89 -0
- package/src/widgets/PositionSetting.vue +209 -0
- package/src/widgets/PresetBar.vue +1011 -0
- package/src/widgets/PresetBarPivot.vue +330 -0
- package/src/widgets/PresetBarPivotColumnEdit.vue +186 -0
- package/src/widgets/PresetBarPivotValueEdit.vue +172 -0
- package/src/widgets/PresetSelector.vue +713 -0
- package/src/widgets/Review.vue +131 -0
- package/src/widgets/ReviewSetting.vue +201 -0
- package/src/widgets/SearchModalSetting.vue +70 -0
- package/src/widgets/Share.vue +164 -0
- package/src/widgets/ShareSetting.vue +99 -0
- package/src/widgets/SvgSetting.vue +133 -0
- package/src/widgets/TableSetting.vue +172 -0
- package/src/widgets/TestimonialSetting.vue +161 -0
- package/src/widgets/TextBlockSetting.vue +81 -0
- package/src/widgets/ThumbnailSetting.vue +49 -0
- package/src/widgets/WebComponentSelector.vue +205 -0
- package/src/widgets/WebDatasourceSelector.vue +153 -0
- package/src/widgets/WebLayoutSelector.vue +139 -0
- package/src/widgets/WebPageBuilder.vue +1962 -0
- package/src/widgets/YoutubeVideoSetting.vue +68 -0
- package/docs/components/ArrayList.md +0 -39
- package/docs/components/Box.md +0 -15
- package/docs/components/ChartBar.md +0 -34
- package/docs/components/DayTimeRange.md +0 -20
- package/docs/components/ErrorText.md +0 -15
- package/docs/components/FBLogin.md +0 -21
- package/docs/components/GHeatMaps.md +0 -16
- package/docs/components/GSignIn.md +0 -21
- package/docs/components/GmapsDirection.md +0 -20
- 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>
|