@mixd-id/web-scaffold 0.1.240411123 → 0.1.240411125
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.
|
@@ -84,10 +84,15 @@ needs `<controller>.ai-chat`).
|
|
|
84
84
|
dragged left).
|
|
85
85
|
- The right panel can be hidden and shown with the toggle at the right end of
|
|
86
86
|
the dashboard title (`config.params.rightCollapsed`).
|
|
87
|
-
- On mobile (`$device.type === 'mobile'`, under 768px) the
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
- On mobile (`$device.type === 'mobile'`, under 768px) the two stack: the
|
|
88
|
+
right panel becomes a bottom section 50vh tall (70vh with the chevron button
|
|
89
|
+
left of its close button; back to 50vh on every load), the dashboard taking
|
|
90
|
+
the rest (a divider between). It
|
|
91
|
+
starts closed on every load, leaving the dashboard full height, and its open
|
|
92
|
+
state is not persisted; `rightCollapsed` only applies to wider screens.
|
|
93
|
+
Its **Views** tab is two pages there: the component tree, and the selected
|
|
94
|
+
component's settings, which slide in from the right when one is picked (tree,
|
|
95
|
+
dashboard tap or a new component) with a back button to the tree.
|
|
91
96
|
- Slot **`before-title`** renders host controls left of the title (admin's
|
|
92
97
|
`pages/Dashboard.vue` puts its "Show dashboards" toggle there while its
|
|
93
98
|
dashboards list is hidden).
|
package/package.json
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
|
|
2
|
+
<!-- Dashboard and panel side by side; on mobile stacked, the panel below
|
|
3
|
+
taking half the height while open. -->
|
|
4
|
+
<div :class="[ $style.comp, mobile ? 'flex-col divide-y' : 'flex-row divide-x' ]">
|
|
3
5
|
|
|
4
6
|
<!-- ============================ LEFT: dashboard ============================ -->
|
|
5
|
-
<div ref="snapshotRoot" class="flex-1 flex flex-col min-w-0">
|
|
7
|
+
<div ref="snapshotRoot" class="flex-1 flex flex-col min-w-0" :class="mobile ? 'min-h-0 basis-0' : ''">
|
|
6
8
|
|
|
7
9
|
<div class="flex flex-row items-start gap-4 md:gap-8 p-4 md:p-6">
|
|
8
10
|
<div class="flex-1 min-w-0 flex flex-row items-start gap-2">
|
|
9
11
|
<!-- Host controls left of the title, e.g. a "show dashboards" toggle -->
|
|
10
12
|
<slot name="before-title" />
|
|
11
13
|
<div class="flex-1 min-w-0">
|
|
12
|
-
<
|
|
14
|
+
<div class="flex flex-row items-center gap-2 min-w-0">
|
|
15
|
+
<h3 class="truncate" @click="log(activeItem)">{{ activeItem ? activeItem.name : 'Dashboard' }}</h3>
|
|
16
|
+
<span v-if="readonly && activeItem" class="shrink-0 text-[11px] px-2 py-0.5 rounded-full bg-text-50 text-text-400">View only</span>
|
|
17
|
+
</div>
|
|
13
18
|
<p v-if="activeItem?.props?.description" class="mt-1 text-sm text-text-300 line-clamp-2 max-w-[420px]">{{ activeItem.props.description }}</p>
|
|
14
19
|
</div>
|
|
15
20
|
</div>
|
|
@@ -69,32 +74,34 @@
|
|
|
69
74
|
</div>
|
|
70
75
|
|
|
71
76
|
<!-- ============================ RIGHT: panel ==============================
|
|
72
|
-
A column beside the dashboard; on mobile a
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
class="relative flex-1 min-h-0 flex flex-col overflow-hidden">
|
|
77
|
+
A column beside the dashboard; on mobile a bottom section under it,
|
|
78
|
+
50vh or 70vh tall (toggle left of the close button). -->
|
|
79
|
+
<Transition :name="mobile ? 'aidPanelV' : 'aidPanelH'">
|
|
80
|
+
<div v-if="panelOpen" :style="mobile ? { height: mobilePanelTall ? '70vh' : '50vh' } : { width: config.params.rightWidth + 'px' }"
|
|
81
|
+
class="relative shrink-0 flex flex-col overflow-hidden" :class="mobile ? $style.panelHeight : ''">
|
|
78
82
|
|
|
79
83
|
<div v-if="!mobile" :class="$style.resizeR" @mousedown="(e) => $util.dragResize(e, resizeRight)" />
|
|
80
84
|
|
|
81
85
|
<div class="border-b-[1px] border-text-50 flex flex-row gap-4 items-start">
|
|
82
86
|
<div class="flex-1 min-w-0 overflow-x-auto px-3 pt-3" :class="mobile ? 'px-6' : 'pt-6'">
|
|
83
|
-
<Tabs :items="
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
<Tabs :items="tabs" v-model="config.params.rightTab" />
|
|
88
|
+
</div>
|
|
89
|
+
<div v-if="mobile" class="shrink-0 flex flex-row items-center">
|
|
90
|
+
<button type="button" class="py-5 px-2 text-text-300 hover:text-primary" :title="mobilePanelTall ? 'Shrink panel' : 'Expand panel'"
|
|
91
|
+
@click="mobilePanelTall = !mobilePanelTall">
|
|
92
|
+
<svg width="16" height="16" class="fill-none stroke-current transition-transform" :class="mobilePanelTall ? 'rotate-180' : ''" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m17 11-5-5-5 5"/><path d="m17 18-5-5-5 5"/></svg>
|
|
93
|
+
</button>
|
|
94
|
+
<button type="button" class="shrink-0 p-5 text-text-300 hover:text-red-600" title="Close" @click="panelOpen = false">
|
|
95
|
+
<svg width="16" height="16" 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>
|
|
96
|
+
</button>
|
|
91
97
|
</div>
|
|
92
|
-
<button v-if="mobile" type="button" class="shrink-0 p-5 text-text-300 hover:text-red-600" title="Close" @click="panelOpen = false">
|
|
93
|
-
<svg width="16" height="16" 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>
|
|
94
|
-
</button>
|
|
95
98
|
</div>
|
|
96
99
|
|
|
97
|
-
|
|
100
|
+
<!-- Views tab. Desktop: the component tree with the selected one's settings
|
|
101
|
+
below it. Mobile: two pages — the tree, and the settings sliding in
|
|
102
|
+
from the right with a back button. -->
|
|
103
|
+
<div v-if="config.params.rightTab === 'views'" class="flex-1 min-h-0 flex flex-col" :class="mobile ? 'relative overflow-hidden' : ''">
|
|
104
|
+
<div class="flex-1 min-h-0 flex flex-col" :class="mobile ? [ $style.page, mobileSettings && selectedNode ? '-translate-x-full' : '' ] : ''">
|
|
98
105
|
<div class="flex flex-row items-center gap-2 p-6 pb-1">
|
|
99
106
|
<strong>Components</strong>
|
|
100
107
|
<div class="flex-1"></div>
|
|
@@ -134,16 +141,22 @@
|
|
|
134
141
|
Select a view first.
|
|
135
142
|
</div>
|
|
136
143
|
</div>
|
|
144
|
+
</div>
|
|
137
145
|
|
|
138
|
-
<div v-if="selectedNode"
|
|
139
|
-
:
|
|
146
|
+
<div v-if="selectedNode"
|
|
147
|
+
:class="mobile ? [ $style.page, 'absolute inset-0', mobileSettings ? '' : 'translate-x-full' ] : 'relative shrink-0 max-h-[75%] border-t-[1px] border-text-50'"
|
|
148
|
+
:style="mobile ? null : { height: config.params.inspectorHeight + 'px' }">
|
|
140
149
|
|
|
141
|
-
<div :class="$style.resizeT" @mousedown="(e) => $util.dragResize(e, resizeInspector)" />
|
|
150
|
+
<div v-if="!mobile" :class="$style.resizeT" @mousedown="(e) => $util.dragResize(e, resizeInspector)" />
|
|
142
151
|
|
|
143
152
|
<div class="h-full p-6 flex flex-col gap-3">
|
|
144
153
|
|
|
145
154
|
<div class="flex flex-row items-center gap-2">
|
|
146
|
-
<
|
|
155
|
+
<button v-if="mobile" type="button" class="-ml-2 p-2 shrink-0 text-text-400 hover:text-primary" title="Back to components" @click="mobileSettings = false">
|
|
156
|
+
<svg width="14" height="14" class="fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l192 192c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256 246.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192z"/></svg>
|
|
157
|
+
</button>
|
|
158
|
+
<strong v-if="mobile" class="flex-1 truncate">{{ selectedNode.name || labelFor(selectedNode.type) }}<span class="font-normal text-text-400"> · {{ labelFor(selectedNode.type) }}</span></strong>
|
|
159
|
+
<strong v-else class="flex-1">{{ labelFor(selectedNode.type) }} settings</strong>
|
|
147
160
|
</div>
|
|
148
161
|
|
|
149
162
|
<div class="flex-1 overflow-y-auto">
|
|
@@ -218,7 +231,7 @@
|
|
|
218
231
|
|
|
219
232
|
</div>
|
|
220
233
|
</div>
|
|
221
|
-
</
|
|
234
|
+
</div>
|
|
222
235
|
|
|
223
236
|
<!-- ---------- Data sources tab ---------- -->
|
|
224
237
|
<template v-else-if="config.params.rightTab === 'datasource'">
|
|
@@ -263,19 +276,19 @@
|
|
|
263
276
|
<div v-else-if="sharing.error && !sharing.owner" class="text-red-600 text-sm text-center py-6">{{ sharing.error }}</div>
|
|
264
277
|
<template v-else>
|
|
265
278
|
<div v-if="sharing.canManage" class="flex flex-col gap-1">
|
|
266
|
-
<Textbox v-model="sharing.search" placeholder="Add people by name or
|
|
279
|
+
<Textbox v-model="sharing.search" placeholder="Add people by name or code" />
|
|
267
280
|
<div v-if="sharing.search.trim()" class="rounded-lg border-[1px] border-text-50 bg-base-400 p-1 flex flex-col">
|
|
281
|
+
<div v-if="sharing.search.trim().length < SEARCH_MIN_LENGTH" class="text-text-400 text-xs p-2">Type at least {{ SEARCH_MIN_LENGTH }} characters.</div>
|
|
282
|
+
<template v-else>
|
|
268
283
|
<button v-for="user in sharing.results.filter(u => u.id !== sharing.owner?.id && !sharing.users.some(s => s.id === u.id))" :key="user.id"
|
|
269
|
-
type="button" class="flex flex-row items-center gap-2
|
|
284
|
+
type="button" class="flex flex-row items-center gap-2 px-2 py-1.5 rounded-md text-left hover:bg-base-500"
|
|
270
285
|
@click="shareWith(user)">
|
|
271
|
-
<div class="w-[
|
|
272
|
-
<div class="flex-1 min-w-0">
|
|
273
|
-
<div class="text-sm truncate">{{ user.name }}</div>
|
|
274
|
-
<div class="text-[11px] text-text-400 truncate">{{ [ user.type, user.email ].filter(Boolean).join(' · ') }}</div>
|
|
275
|
-
</div>
|
|
286
|
+
<div class="w-[22px] h-[22px] rounded-full bg-base-500 text-[11px] flex items-center justify-center shrink-0">{{ (user.name || '?').slice(0, 1).toUpperCase() }}</div>
|
|
287
|
+
<div class="flex-1 min-w-0 text-sm truncate">{{ user.name }}</div>
|
|
276
288
|
<span class="text-xs text-primary shrink-0">Share</span>
|
|
277
289
|
</button>
|
|
278
290
|
<div v-if="!sharing.results.some(u => u.id !== sharing.owner?.id && !sharing.users.some(s => s.id === u.id))" class="text-text-400 text-xs p-2">No one found.</div>
|
|
291
|
+
</template>
|
|
279
292
|
</div>
|
|
280
293
|
<div v-if="sharing.error" class="text-red-600 text-xs">{{ sharing.error }}</div>
|
|
281
294
|
</div>
|
|
@@ -283,16 +296,21 @@
|
|
|
283
296
|
<div class="flex flex-col gap-1">
|
|
284
297
|
<label class="text-xs text-text-400">Who has access</label>
|
|
285
298
|
<div v-for="user in [ ...(sharing.owner ? [ sharing.owner ] : []), ...sharing.users ]" :key="user.id"
|
|
286
|
-
class="flex flex-row items-center gap-2
|
|
287
|
-
<div class="w-[
|
|
288
|
-
<div class="flex-1 min-w-0">
|
|
289
|
-
<div class="text-sm truncate">{{ user.name }}</div>
|
|
290
|
-
<div class="text-[11px] text-text-400 truncate">{{ [ user.type, user.email ].filter(Boolean).join(' · ') }}</div>
|
|
291
|
-
</div>
|
|
299
|
+
class="flex flex-row items-center gap-2 px-2 py-1 rounded-lg bg-base-400">
|
|
300
|
+
<div class="w-[22px] h-[22px] rounded-full bg-base-500 text-[11px] flex items-center justify-center shrink-0">{{ (user.name || '?').slice(0, 1).toUpperCase() }}</div>
|
|
301
|
+
<div class="flex-1 min-w-0 text-sm truncate">{{ user.name }}</div>
|
|
292
302
|
<span v-if="user.id === sharing.owner?.id" class="text-[11px] text-text-400 shrink-0">Owner</span>
|
|
293
|
-
|
|
294
|
-
|
|
303
|
+
<!-- Access as an icon; the owner clicks it for a menu to change or remove it -->
|
|
304
|
+
<button v-else-if="sharing.canManage" type="button"
|
|
305
|
+
class="shrink-0 flex flex-row items-center gap-0.5 p-1.5 rounded-md text-text-400 hover:bg-base-500 hover:text-text-600"
|
|
306
|
+
v-tooltip.bottom="shareAccess(user.access).text"
|
|
307
|
+
@click="accessMenu.open($event.currentTarget, { user })">
|
|
308
|
+
<svg width="14" height="14" class="fill-current" xmlns="http://www.w3.org/2000/svg" :viewBox="shareAccess(user.access).box"><path :d="shareAccess(user.access).path"/></svg>
|
|
309
|
+
<svg width="8" height="8" class="fill-current opacity-70" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M233.4 406.6c12.5 12.5 32.8 12.5 45.3 0l192-192c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 338.7 86.6 169.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l192 192z"/></svg>
|
|
295
310
|
</button>
|
|
311
|
+
<span v-else class="shrink-0 p-1.5 text-text-400" v-tooltip.bottom="shareAccess(user.access).text">
|
|
312
|
+
<svg width="14" height="14" class="fill-current" xmlns="http://www.w3.org/2000/svg" :viewBox="shareAccess(user.access).box"><path :d="shareAccess(user.access).path"/></svg>
|
|
313
|
+
</span>
|
|
296
314
|
</div>
|
|
297
315
|
<div v-if="!sharing.users.length" class="text-text-400 text-xs px-1 pt-1">Not shared with anyone yet.</div>
|
|
298
316
|
</div>
|
|
@@ -398,6 +416,22 @@
|
|
|
398
416
|
</template>
|
|
399
417
|
</LLMChat>
|
|
400
418
|
|
|
419
|
+
<ContextMenu ref="accessMenu" position="bottom-right">
|
|
420
|
+
<template #default="{ context }">
|
|
421
|
+
<div v-if="context?.user" class="flex flex-col min-w-[160px] p-1 py-2">
|
|
422
|
+
<button v-for="opt in SHARE_ACCESS" :key="opt.value" type="button"
|
|
423
|
+
class="w-full p-2 rounded-md text-left px-3 menu-item flex flex-row items-center gap-2"
|
|
424
|
+
@click="accessMenu.close(); opt.value !== shareAccess(context.user.access).value && shareWith(context.user, opt.value)">
|
|
425
|
+
<svg width="13" height="13" class="fill-text-400 shrink-0" xmlns="http://www.w3.org/2000/svg" :viewBox="opt.box"><path :d="opt.path"/></svg>
|
|
426
|
+
<span class="flex-1">{{ opt.text }}</span>
|
|
427
|
+
<svg v-if="opt.value === shareAccess(context.user.access).value" width="11" height="11" class="fill-primary shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M438.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 338.7 393.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"/></svg>
|
|
428
|
+
</button>
|
|
429
|
+
<div class="my-1 border-t-[1px] border-text-50" />
|
|
430
|
+
<button type="button" class="w-full p-2 rounded-md text-left px-3 menu-item text-red-600" @click="accessMenu.close(); unshareWith(context.user)">Remove access</button>
|
|
431
|
+
</div>
|
|
432
|
+
</template>
|
|
433
|
+
</ContextMenu>
|
|
434
|
+
|
|
401
435
|
<ContextMenu ref="viewMenu" position="bottom-right">
|
|
402
436
|
<template #default="{ context }">
|
|
403
437
|
<div class="flex flex-col min-w-[160px] p-1 py-2">
|
|
@@ -423,7 +457,7 @@
|
|
|
423
457
|
</template>
|
|
424
458
|
</ContextMenu>
|
|
425
459
|
</div>
|
|
426
|
-
</
|
|
460
|
+
</Transition>
|
|
427
461
|
|
|
428
462
|
<Card ref="drillCard" :float="true" :dismissable="true" width="900" height="560" @dismiss="$refs.drillCard.close()">
|
|
429
463
|
<div v-if="breakdown?.drill" class="flex-1 flex flex-col gap-2 min-h-0">
|
|
@@ -753,15 +787,20 @@ import AIDashboardFilterSetting from './AIDashboardFilterSetting.vue'
|
|
|
753
787
|
// `${controller}.load-dashboard` hydrates node.data from datasources and
|
|
754
788
|
// `${controller}.ai-chat` drives the assistant. `datasources` describes the
|
|
755
789
|
// query-builder options: { [key]: { label, fields: [{ key, label }] } }.
|
|
756
|
-
const { controller, socket, config, datasources } = defineProps({
|
|
790
|
+
const { controller, socket, config, datasources, readonly } = defineProps({
|
|
757
791
|
controller: { type: String, default: '' },
|
|
758
792
|
socket: { type: Object, default: null },
|
|
759
793
|
config: { type: Object, required: true },
|
|
760
794
|
datasources: { type: [ Object, Array ], default: () => ({}) },
|
|
795
|
+
// View-only: the dashboard can be looked at, filtered and reloaded, but its
|
|
796
|
+
// components, datasource, assistant and settings are hidden. The host is
|
|
797
|
+
// expected not to save the item while this is set.
|
|
798
|
+
readonly: { type: Boolean, default: false },
|
|
761
799
|
})
|
|
762
800
|
|
|
763
801
|
const addMenu = useTemplateRef('addMenu')
|
|
764
802
|
const viewMenu = useTemplateRef('viewMenu')
|
|
803
|
+
const accessMenu = useTemplateRef('accessMenu')
|
|
765
804
|
const renameCard = useTemplateRef('renameCard')
|
|
766
805
|
const dsCard = useTemplateRef('dsCard')
|
|
767
806
|
const dashboardFilters = useTemplateRef('dashboardFilters')
|
|
@@ -870,24 +909,31 @@ if (config.params.rightWidth == null) config.params.rightWidth = 360
|
|
|
870
909
|
if (config.params.rightCollapsed == null) config.params.rightCollapsed = false
|
|
871
910
|
|
|
872
911
|
// Right panel: a column on wider screens (open state persisted in
|
|
873
|
-
// `rightCollapsed`),
|
|
874
|
-
// a phone opens on the dashboard
|
|
912
|
+
// `rightCollapsed`), the bottom half on mobile — closed on every load there so
|
|
913
|
+
// a phone opens on the whole dashboard.
|
|
875
914
|
const device = getCurrentInstance()?.appContext.config.globalProperties.$device
|
|
876
915
|
const mobile = computed(() => device?.type === 'mobile')
|
|
877
916
|
const mobilePanelOpen = ref(false)
|
|
917
|
+
const mobilePanelTall = ref(false) // mobile panel height: 50vh, or 70vh when tall
|
|
878
918
|
const panelOpen = computed({
|
|
879
919
|
get: () => mobile.value ? mobilePanelOpen.value : !config.params.rightCollapsed,
|
|
880
920
|
set: (open) => { if (mobile.value) mobilePanelOpen.value = open; else config.params.rightCollapsed = !open },
|
|
881
921
|
})
|
|
882
|
-
const panelCard = useTemplateRef('panelCard')
|
|
883
|
-
watch([ mobile, mobilePanelOpen ], ([ isMobile, open ]) => {
|
|
884
|
-
if (!isMobile) return
|
|
885
|
-
if (open) panelCard.value?.open()
|
|
886
|
-
else panelCard.value?.close()
|
|
887
|
-
}, { flush: 'post' })
|
|
888
922
|
if (config.params.inspectorHeight == null) config.params.inspectorHeight = 300
|
|
889
923
|
if (config.params.rightTab === 'data') config.params.rightTab = 'datasource'
|
|
890
924
|
if (![ 'views', 'filters', 'datasource', 'chat', 'sharing', 'settings' ].includes(config.params.rightTab)) config.params.rightTab = 'views'
|
|
925
|
+
|
|
926
|
+
const tabs = computed(() => [
|
|
927
|
+
...(readonly ? [] : [ { text: 'Views', value: 'views' } ]),
|
|
928
|
+
{ text: 'Filters', value: 'filters' },
|
|
929
|
+
...(readonly ? [] : [ { text: 'Datasource', value: 'datasource' }, { text: 'AI Assistant', value: 'chat' } ]),
|
|
930
|
+
...(connected.value ? [ { text: 'Sharing', value: 'sharing' } ] : []),
|
|
931
|
+
...(readonly ? [] : [ { text: 'Settings', value: 'settings' } ]),
|
|
932
|
+
])
|
|
933
|
+
// A view-only dashboard keeps the panel on a tab it still shows.
|
|
934
|
+
watch(() => [ readonly, config.params.rightTab ], () => {
|
|
935
|
+
if (readonly && !tabs.value.some(tab => tab.value === config.params.rightTab)) config.params.rightTab = 'filters'
|
|
936
|
+
}, { immediate: true })
|
|
891
937
|
if (!Array.isArray(config.items)) config.items = []
|
|
892
938
|
config.items.forEach(normalizeItem)
|
|
893
939
|
|
|
@@ -961,8 +1007,14 @@ const selectedNode = ref(null)
|
|
|
961
1007
|
|
|
962
1008
|
function selectNode(node) {
|
|
963
1009
|
selectedNode.value = node
|
|
1010
|
+
if (node) mobileSettings.value = true
|
|
964
1011
|
}
|
|
965
1012
|
|
|
1013
|
+
// Mobile Views tab: the settings page slides in whenever a component gets
|
|
1014
|
+
// selected (tree, dashboard tap or a newly added one); back slides it out.
|
|
1015
|
+
const mobileSettings = ref(false)
|
|
1016
|
+
watch(selectedNode, (node) => { mobileSettings.value = !!node })
|
|
1017
|
+
|
|
966
1018
|
function metricOnly(node) {
|
|
967
1019
|
const items = node.items || []
|
|
968
1020
|
return items.length > 0 && items.every(n => n.type === 'metric')
|
|
@@ -1728,7 +1780,7 @@ function getChatContext() {
|
|
|
1728
1780
|
|
|
1729
1781
|
const chatFocus = computed(() => selectedNode.value)
|
|
1730
1782
|
|
|
1731
|
-
const focusable = computed(() => panelOpen.value && [ 'views', 'chat' ].includes(config.params.rightTab))
|
|
1783
|
+
const focusable = computed(() => !readonly && panelOpen.value && [ 'views', 'chat' ].includes(config.params.rightTab))
|
|
1732
1784
|
|
|
1733
1785
|
function pickFocus(e, node) {
|
|
1734
1786
|
if (!focusable.value || e.target.closest('button, canvas, [data-breakdown] td, [data-map] [data-point], [data-map][data-dragging]')) return
|
|
@@ -1738,7 +1790,7 @@ function pickFocus(e, node) {
|
|
|
1738
1790
|
}
|
|
1739
1791
|
|
|
1740
1792
|
function focusClass(node) {
|
|
1741
|
-
if (!panelOpen.value || ![ 'views', 'chat' ].includes(config.params.rightTab)) return ''
|
|
1793
|
+
if (readonly || !panelOpen.value || ![ 'views', 'chat' ].includes(config.params.rightTab)) return ''
|
|
1742
1794
|
return selectedNode.value === node ? 'cursor-pointer ring-2 ring-primary' : 'cursor-pointer hover:ring-1 hover:ring-text-200'
|
|
1743
1795
|
}
|
|
1744
1796
|
|
|
@@ -1899,12 +1951,12 @@ watch(() => [ activeItem.value?.uid, JSON.stringify((activeItem.value?.props?.fi
|
|
|
1899
1951
|
})
|
|
1900
1952
|
watch(activeUid, () => { selectedNode.value = null; updatedAt.value = null; if (connected.value) hydrateActive() })
|
|
1901
1953
|
|
|
1902
|
-
const sharing = ref({ uid: null, loaded: false, error: null, owner: null, canManage: false, users: [], search: '', results: [] })
|
|
1954
|
+
const sharing = ref({ uid: null, loaded: false, error: null, owner: null, access: null, canManage: false, users: [], search: '', results: [] })
|
|
1903
1955
|
|
|
1904
1956
|
async function loadSharing() {
|
|
1905
1957
|
const uid = activeItem.value?.uid
|
|
1906
1958
|
if (!connected.value || !uid) return
|
|
1907
|
-
if (sharing.value.uid !== uid) sharing.value = { uid, loaded: false, error: null, owner: null, canManage: false, users: [], search: '', results: [] }
|
|
1959
|
+
if (sharing.value.uid !== uid) sharing.value = { uid, loaded: false, error: null, owner: null, access: null, canManage: false, users: [], search: '', results: [] }
|
|
1908
1960
|
try {
|
|
1909
1961
|
const res = await socket.send(`${controller}.load-sharing`, { uid })
|
|
1910
1962
|
if (sharing.value.uid === uid) sharing.value = { ...sharing.value, ...res, loaded: true, error: null }
|
|
@@ -1918,9 +1970,11 @@ watch(() => [ config.params.rightTab, activeItem.value?.uid ], () => {
|
|
|
1918
1970
|
}, { immediate: true })
|
|
1919
1971
|
|
|
1920
1972
|
let searchTimer = null
|
|
1973
|
+
// Matches the server's minimum; shorter input clears the results.
|
|
1974
|
+
const SEARCH_MIN_LENGTH = 3
|
|
1921
1975
|
watch(() => sharing.value.search, (search) => {
|
|
1922
1976
|
clearTimeout(searchTimer)
|
|
1923
|
-
if (
|
|
1977
|
+
if ((search?.trim().length ?? 0) < SEARCH_MIN_LENGTH) { sharing.value.results = []; return }
|
|
1924
1978
|
searchTimer = setTimeout(async () => {
|
|
1925
1979
|
try {
|
|
1926
1980
|
const res = await socket.send(`${controller}.search-users`, { search })
|
|
@@ -1931,10 +1985,19 @@ watch(() => sharing.value.search, (search) => {
|
|
|
1931
1985
|
}, 250)
|
|
1932
1986
|
})
|
|
1933
1987
|
|
|
1934
|
-
|
|
1988
|
+
const SHARE_ACCESS = [
|
|
1989
|
+
{ value: 'view', text: 'Can view', box: '0 0 576 512', path: 'M288 32c-80.8 0-145.5 36.8-192.6 80.6C48.6 156 17.3 208 2.5 243.7c-3.3 7.9-3.3 16.7 0 24.6C17.3 304 48.6 356 95.4 399.4C142.5 443.2 207.2 480 288 480s145.5-36.8 192.6-80.6c46.8-43.5 78.1-95.4 93-131.1c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C433.5 68.8 368.8 32 288 32zM144 256a144 144 0 1 1 288 0 144 144 0 1 1 -288 0zm144-64c0 35.3-28.7 64-64 64c-7.1 0-13.9-1.2-20.3-3.3c-5.5-1.8-11.9 1.6-11.7 7.4c.3 6.9 1.3 13.8 3.2 20.7c13.7 51.2 66.4 81.6 117.6 67.9s81.6-66.4 67.9-117.6c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3z' },
|
|
1990
|
+
{ value: 'edit', text: 'Can edit', box: '0 0 512 512', path: 'M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z' },
|
|
1991
|
+
]
|
|
1992
|
+
// Anything but 'edit' reads as view, as on the server.
|
|
1993
|
+
const shareAccess = (access) => SHARE_ACCESS.find(opt => opt.value === access) ?? SHARE_ACCESS[0]
|
|
1994
|
+
|
|
1995
|
+
// Shares as view-only by default; sharing an existing person again with
|
|
1996
|
+
// another `access` changes theirs.
|
|
1997
|
+
async function shareWith(user, access = 'view') {
|
|
1935
1998
|
const uid = sharing.value.uid
|
|
1936
1999
|
try {
|
|
1937
|
-
const res = await socket.send(`${controller}.share`, { uid, userId: user.id })
|
|
2000
|
+
const res = await socket.send(`${controller}.share`, { uid, userId: user.id, access })
|
|
1938
2001
|
if (sharing.value.uid === uid) sharing.value = { ...sharing.value, ...res, error: null, search: '', results: [] }
|
|
1939
2002
|
} catch (e) {
|
|
1940
2003
|
sharing.value.error = e?.message ?? `${e}`
|
|
@@ -1967,8 +2030,16 @@ onUnmounted(() => {
|
|
|
1967
2030
|
}
|
|
1968
2031
|
|
|
1969
2032
|
.comp {
|
|
1970
|
-
@apply flex-1 flex
|
|
1971
|
-
@apply divide-
|
|
2033
|
+
@apply flex-1 flex relative min-h-0;
|
|
2034
|
+
@apply divide-text-100 dark:divide-text-50;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
.panelHeight {
|
|
2038
|
+
transition: height 250ms cubic-bezier(0.25, 1, 0.5, 1);
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
.page {
|
|
2042
|
+
transition: transform 250ms cubic-bezier(0.25, 1, 0.5, 1);
|
|
1972
2043
|
}
|
|
1973
2044
|
|
|
1974
2045
|
.resizeR {
|
|
@@ -1981,3 +2052,20 @@ onUnmounted(() => {
|
|
|
1981
2052
|
@apply border-t-[1px] border-transparent hover:border-primary;
|
|
1982
2053
|
}
|
|
1983
2054
|
</style>
|
|
2055
|
+
|
|
2056
|
+
<style>
|
|
2057
|
+
/* AIDashboard right panel show / hide: width on desktop, height on mobile
|
|
2058
|
+
(`!important` beats the inline width / height while animating). */
|
|
2059
|
+
.aidPanelH-enter-active, .aidPanelH-leave-active {
|
|
2060
|
+
transition: width 250ms cubic-bezier(0.25, 1, 0.5, 1);
|
|
2061
|
+
}
|
|
2062
|
+
.aidPanelH-enter-from, .aidPanelH-leave-to {
|
|
2063
|
+
width: 0 !important;
|
|
2064
|
+
}
|
|
2065
|
+
.aidPanelV-enter-active, .aidPanelV-leave-active {
|
|
2066
|
+
transition: height 250ms cubic-bezier(0.25, 1, 0.5, 1);
|
|
2067
|
+
}
|
|
2068
|
+
.aidPanelV-enter-from, .aidPanelV-leave-to {
|
|
2069
|
+
height: 0 !important;
|
|
2070
|
+
}
|
|
2071
|
+
</style>
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
<input v-if="editing?.uid === view.uid" :ref="el => el && el.focus()" v-model="editing.name" maxlength="100"
|
|
22
22
|
:class="$style.renameInput" @click.stop @keydown.enter.prevent="commitRename" @keydown.esc.prevent="editing = null" @blur="commitRename" />
|
|
23
23
|
<span v-else class="flex-1 truncate" :title="view.name" @dblclick.stop="!view.shared && startRename(view)">{{ view.name }}</span>
|
|
24
|
-
<svg v-if="view.shared && !grouped" width="12" height="12" class="fill-text-400 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><title>Shared with you</title><path :d="SHARED_PATH"/></svg>
|
|
24
|
+
<svg v-if="view.shared && !grouped" width="12" height="12" class="fill-text-400 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><title>{{ view.access === 'view' ? 'Shared with you · view only' : 'Shared with you' }}</title><path :d="SHARED_PATH"/></svg>
|
|
25
25
|
<button type="button" :class="$style.rowMenu"
|
|
26
26
|
@click.stop="viewMenu.open($event.currentTarget, { view })">
|
|
27
27
|
<svg width="14" height="14" class="fill-text-400 hover:fill-text" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path :d="ELLIPSIS_PATH"/></svg>
|