@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 right panel is a
88
- floating `Card` (full width, 85vh, dismissable, a close button beside the
89
- tabs) instead of a column. It starts closed on every load and its open state
90
- is not persisted; `rightCollapsed` only applies to wider screens.
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,7 +1,7 @@
1
1
  {
2
2
  "name": "@mixd-id/web-scaffold",
3
3
  "private": false,
4
- "version": "0.1.240411123",
4
+ "version": "0.1.240411125",
5
5
  "scripts": {
6
6
  "dev": "vite serve",
7
7
  "build": "vite build",
@@ -1,15 +1,20 @@
1
1
  <template>
2
- <div :class="$style.comp">
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
- <h3 class="truncate" @click="log(activeItem)">{{ activeItem ? activeItem.name : 'Dashboard' }}</h3>
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 floating Card over it. -->
73
- <Card v-if="mobile || panelOpen" ref="panelCard" :float="mobile" :dismissable="true" width="100%" height="85vh"
74
- class="shrink-0 min-h-0" @dismiss="panelOpen = false">
75
- <template #header><span class="hidden" /></template>
76
- <div :style="mobile ? null : { width: config.params.rightWidth + 'px' }"
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
- { text: 'Views', value: 'views' },
85
- { text: 'Filters', value: 'filters' },
86
- { text: 'Datasource', value: 'datasource' },
87
- { text: 'AI Assistant', value: 'chat' },
88
- ...(connected ? [ { text: 'Sharing', value: 'sharing' } ] : []),
89
- { text: 'Settings', value: 'settings' },
90
- ]" v-model="config.params.rightTab" />
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
- <template v-if="config.params.rightTab === 'views'">
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" class="relative shrink-0 max-h-[75%] border-t-[1px] border-text-50"
139
- :style="{ height: config.params.inspectorHeight + 'px' }">
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
- <strong class="flex-1">{{ labelFor(selectedNode.type) }} settings</strong>
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
- </template>
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 email" />
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 p-2 rounded-md text-left hover:bg-base-500"
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-[26px] h-[26px] rounded-full bg-base-500 text-xs flex items-center justify-center shrink-0">{{ (user.name || '?').slice(0, 1).toUpperCase() }}</div>
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 p-2 rounded-lg bg-base-400">
287
- <div class="w-[26px] h-[26px] rounded-full bg-base-500 text-xs flex items-center justify-center shrink-0">{{ (user.name || '?').slice(0, 1).toUpperCase() }}</div>
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
- <button v-else-if="sharing.canManage" type="button" class="p-1 text-text-300 hover:text-red-600 shrink-0" title="Stop sharing" @click="unshareWith(user)">
294
- <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>
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
- </Card>
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`), a floating Card on mobile — closed on every load there so
874
- // a phone opens on the dashboard, not on the panel.
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 (!search?.trim()) return
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
- async function shareWith(user) {
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 flex-row relative;
1971
- @apply divide-x divide-text-100 dark:divide-text-50;
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>