@mixd-id/web-scaffold 0.1.240411124 → 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.
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.240411124",
4
+ "version": "0.1.240411125",
5
5
  "scripts": {
6
6
  "dev": "vite serve",
7
7
  "build": "vite build",
@@ -11,7 +11,10 @@
11
11
  <!-- Host controls left of the title, e.g. a "show dashboards" toggle -->
12
12
  <slot name="before-title" />
13
13
  <div class="flex-1 min-w-0">
14
- <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>
15
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>
16
19
  </div>
17
20
  </div>
@@ -81,14 +84,7 @@
81
84
 
82
85
  <div class="border-b-[1px] border-text-50 flex flex-row gap-4 items-start">
83
86
  <div class="flex-1 min-w-0 overflow-x-auto px-3 pt-3" :class="mobile ? 'px-6' : 'pt-6'">
84
- <Tabs :items="[
85
- { text: 'Views', value: 'views' },
86
- { text: 'Filters', value: 'filters' },
87
- { text: 'Datasource', value: 'datasource' },
88
- { text: 'AI Assistant', value: 'chat' },
89
- ...(connected ? [ { text: 'Sharing', value: 'sharing' } ] : []),
90
- { text: 'Settings', value: 'settings' },
91
- ]" v-model="config.params.rightTab" />
87
+ <Tabs :items="tabs" v-model="config.params.rightTab" />
92
88
  </div>
93
89
  <div v-if="mobile" class="shrink-0 flex flex-row items-center">
94
90
  <button type="button" class="py-5 px-2 text-text-300 hover:text-primary" :title="mobilePanelTall ? 'Shrink panel' : 'Expand panel'"
@@ -280,19 +276,19 @@
280
276
  <div v-else-if="sharing.error && !sharing.owner" class="text-red-600 text-sm text-center py-6">{{ sharing.error }}</div>
281
277
  <template v-else>
282
278
  <div v-if="sharing.canManage" class="flex flex-col gap-1">
283
- <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" />
284
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>
285
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"
286
- 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"
287
285
  @click="shareWith(user)">
288
- <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>
289
- <div class="flex-1 min-w-0">
290
- <div class="text-sm truncate">{{ user.name }}</div>
291
- <div class="text-[11px] text-text-400 truncate">{{ [ user.type, user.email ].filter(Boolean).join(' · ') }}</div>
292
- </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>
293
288
  <span class="text-xs text-primary shrink-0">Share</span>
294
289
  </button>
295
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>
296
292
  </div>
297
293
  <div v-if="sharing.error" class="text-red-600 text-xs">{{ sharing.error }}</div>
298
294
  </div>
@@ -300,16 +296,21 @@
300
296
  <div class="flex flex-col gap-1">
301
297
  <label class="text-xs text-text-400">Who has access</label>
302
298
  <div v-for="user in [ ...(sharing.owner ? [ sharing.owner ] : []), ...sharing.users ]" :key="user.id"
303
- class="flex flex-row items-center gap-2 p-2 rounded-lg bg-base-400">
304
- <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>
305
- <div class="flex-1 min-w-0">
306
- <div class="text-sm truncate">{{ user.name }}</div>
307
- <div class="text-[11px] text-text-400 truncate">{{ [ user.type, user.email ].filter(Boolean).join(' · ') }}</div>
308
- </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>
309
302
  <span v-if="user.id === sharing.owner?.id" class="text-[11px] text-text-400 shrink-0">Owner</span>
310
- <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)">
311
- <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>
312
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>
313
314
  </div>
314
315
  <div v-if="!sharing.users.length" class="text-text-400 text-xs px-1 pt-1">Not shared with anyone yet.</div>
315
316
  </div>
@@ -415,6 +416,22 @@
415
416
  </template>
416
417
  </LLMChat>
417
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
+
418
435
  <ContextMenu ref="viewMenu" position="bottom-right">
419
436
  <template #default="{ context }">
420
437
  <div class="flex flex-col min-w-[160px] p-1 py-2">
@@ -770,15 +787,20 @@ import AIDashboardFilterSetting from './AIDashboardFilterSetting.vue'
770
787
  // `${controller}.load-dashboard` hydrates node.data from datasources and
771
788
  // `${controller}.ai-chat` drives the assistant. `datasources` describes the
772
789
  // query-builder options: { [key]: { label, fields: [{ key, label }] } }.
773
- const { controller, socket, config, datasources } = defineProps({
790
+ const { controller, socket, config, datasources, readonly } = defineProps({
774
791
  controller: { type: String, default: '' },
775
792
  socket: { type: Object, default: null },
776
793
  config: { type: Object, required: true },
777
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 },
778
799
  })
779
800
 
780
801
  const addMenu = useTemplateRef('addMenu')
781
802
  const viewMenu = useTemplateRef('viewMenu')
803
+ const accessMenu = useTemplateRef('accessMenu')
782
804
  const renameCard = useTemplateRef('renameCard')
783
805
  const dsCard = useTemplateRef('dsCard')
784
806
  const dashboardFilters = useTemplateRef('dashboardFilters')
@@ -900,6 +922,18 @@ const panelOpen = computed({
900
922
  if (config.params.inspectorHeight == null) config.params.inspectorHeight = 300
901
923
  if (config.params.rightTab === 'data') config.params.rightTab = 'datasource'
902
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 })
903
937
  if (!Array.isArray(config.items)) config.items = []
904
938
  config.items.forEach(normalizeItem)
905
939
 
@@ -1746,7 +1780,7 @@ function getChatContext() {
1746
1780
 
1747
1781
  const chatFocus = computed(() => selectedNode.value)
1748
1782
 
1749
- const focusable = computed(() => panelOpen.value && [ 'views', 'chat' ].includes(config.params.rightTab))
1783
+ const focusable = computed(() => !readonly && panelOpen.value && [ 'views', 'chat' ].includes(config.params.rightTab))
1750
1784
 
1751
1785
  function pickFocus(e, node) {
1752
1786
  if (!focusable.value || e.target.closest('button, canvas, [data-breakdown] td, [data-map] [data-point], [data-map][data-dragging]')) return
@@ -1756,7 +1790,7 @@ function pickFocus(e, node) {
1756
1790
  }
1757
1791
 
1758
1792
  function focusClass(node) {
1759
- if (!panelOpen.value || ![ 'views', 'chat' ].includes(config.params.rightTab)) return ''
1793
+ if (readonly || !panelOpen.value || ![ 'views', 'chat' ].includes(config.params.rightTab)) return ''
1760
1794
  return selectedNode.value === node ? 'cursor-pointer ring-2 ring-primary' : 'cursor-pointer hover:ring-1 hover:ring-text-200'
1761
1795
  }
1762
1796
 
@@ -1917,12 +1951,12 @@ watch(() => [ activeItem.value?.uid, JSON.stringify((activeItem.value?.props?.fi
1917
1951
  })
1918
1952
  watch(activeUid, () => { selectedNode.value = null; updatedAt.value = null; if (connected.value) hydrateActive() })
1919
1953
 
1920
- 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: [] })
1921
1955
 
1922
1956
  async function loadSharing() {
1923
1957
  const uid = activeItem.value?.uid
1924
1958
  if (!connected.value || !uid) return
1925
- 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: [] }
1926
1960
  try {
1927
1961
  const res = await socket.send(`${controller}.load-sharing`, { uid })
1928
1962
  if (sharing.value.uid === uid) sharing.value = { ...sharing.value, ...res, loaded: true, error: null }
@@ -1936,9 +1970,11 @@ watch(() => [ config.params.rightTab, activeItem.value?.uid ], () => {
1936
1970
  }, { immediate: true })
1937
1971
 
1938
1972
  let searchTimer = null
1973
+ // Matches the server's minimum; shorter input clears the results.
1974
+ const SEARCH_MIN_LENGTH = 3
1939
1975
  watch(() => sharing.value.search, (search) => {
1940
1976
  clearTimeout(searchTimer)
1941
- if (!search?.trim()) return
1977
+ if ((search?.trim().length ?? 0) < SEARCH_MIN_LENGTH) { sharing.value.results = []; return }
1942
1978
  searchTimer = setTimeout(async () => {
1943
1979
  try {
1944
1980
  const res = await socket.send(`${controller}.search-users`, { search })
@@ -1949,10 +1985,19 @@ watch(() => sharing.value.search, (search) => {
1949
1985
  }, 250)
1950
1986
  })
1951
1987
 
1952
- 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') {
1953
1998
  const uid = sharing.value.uid
1954
1999
  try {
1955
- const res = await socket.send(`${controller}.share`, { uid, userId: user.id })
2000
+ const res = await socket.send(`${controller}.share`, { uid, userId: user.id, access })
1956
2001
  if (sharing.value.uid === uid) sharing.value = { ...sharing.value, ...res, error: null, search: '', results: [] }
1957
2002
  } catch (e) {
1958
2003
  sharing.value.error = e?.message ?? `${e}`
@@ -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>