@kinetis/vector-ui 0.0.0-stage → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +18 -0
  3. package/README.md +72 -2
  4. package/app/app.config.ts +63 -0
  5. package/app/app.vue +11 -0
  6. package/app/components/Vx/Action/Dialog.vue +107 -0
  7. package/app/components/Vx/Cell/Boolean.vue +10 -0
  8. package/app/components/Vx/Cell/Color.vue +12 -0
  9. package/app/components/Vx/Cell/Count.vue +12 -0
  10. package/app/components/Vx/Cell/Date.vue +10 -0
  11. package/app/components/Vx/Cell/DateTime.vue +10 -0
  12. package/app/components/Vx/Cell/Email.vue +9 -0
  13. package/app/components/Vx/Cell/Enum.vue +14 -0
  14. package/app/components/Vx/Cell/File.vue +30 -0
  15. package/app/components/Vx/Cell/Image.vue +38 -0
  16. package/app/components/Vx/Cell/Json.vue +18 -0
  17. package/app/components/Vx/Cell/KeyValue.vue +25 -0
  18. package/app/components/Vx/Cell/Markdown.vue +18 -0
  19. package/app/components/Vx/Cell/Money.vue +16 -0
  20. package/app/components/Vx/Cell/Number.vue +16 -0
  21. package/app/components/Vx/Cell/Relation.vue +16 -0
  22. package/app/components/Vx/Cell/RichText.vue +41 -0
  23. package/app/components/Vx/Cell/Text.vue +13 -0
  24. package/app/components/Vx/Cell/Url.vue +11 -0
  25. package/app/components/Vx/Cell.vue +25 -0
  26. package/app/components/Vx/CommandPalette.vue +118 -0
  27. package/app/components/Vx/Confirm.vue +19 -0
  28. package/app/components/Vx/Dashboard.vue +27 -0
  29. package/app/components/Vx/FieldsForm.vue +46 -0
  30. package/app/components/Vx/Import/Dialog.vue +131 -0
  31. package/app/components/Vx/Inbox.vue +49 -0
  32. package/app/components/Vx/Input/Boolean.vue +10 -0
  33. package/app/components/Vx/Input/Currency.vue +25 -0
  34. package/app/components/Vx/Input/DateTime.vue +38 -0
  35. package/app/components/Vx/Input/Enum.vue +21 -0
  36. package/app/components/Vx/Input/KeyValue.vue +78 -0
  37. package/app/components/Vx/Input/Markdown.vue +120 -0
  38. package/app/components/Vx/Input/Money.vue +47 -0
  39. package/app/components/Vx/Input/Number.vue +24 -0
  40. package/app/components/Vx/Input/Relation.vue +127 -0
  41. package/app/components/Vx/Input/Repeater.vue +134 -0
  42. package/app/components/Vx/Input/RichText.vue +182 -0
  43. package/app/components/Vx/Input/Text.vue +21 -0
  44. package/app/components/Vx/Input/Textarea.vue +20 -0
  45. package/app/components/Vx/Input/Upload.vue +89 -0
  46. package/app/components/Vx/Layout.vue +111 -0
  47. package/app/components/Vx/List/BulkBar.vue +44 -0
  48. package/app/components/Vx/List/BulkEdit.vue +91 -0
  49. package/app/components/Vx/List/ColumnManager.vue +74 -0
  50. package/app/components/Vx/List/FilterBar.vue +224 -0
  51. package/app/components/Vx/List/ViewMenu.vue +115 -0
  52. package/app/components/Vx/Notifications.vue +9 -0
  53. package/app/components/Vx/Resource/Audit.vue +82 -0
  54. package/app/components/Vx/Resource/Form.vue +351 -0
  55. package/app/components/Vx/Resource/RelationPanel.vue +217 -0
  56. package/app/components/Vx/Resource/Show.vue +35 -0
  57. package/app/components/Vx/Resource/Table.vue +513 -0
  58. package/app/components/Vx/Widget/Card.vue +80 -0
  59. package/app/components/Vx/Widget/Partition.vue +21 -0
  60. package/app/components/Vx/Widget/Progress.vue +22 -0
  61. package/app/components/Vx/Widget/Stat.vue +22 -0
  62. package/app/components/Vx/Widget/Table.vue +31 -0
  63. package/app/components/Vx/Widget/Trend.vue +37 -0
  64. package/app/composables/useConfirm.ts +34 -0
  65. package/app/composables/useLeaveGuard.ts +21 -0
  66. package/app/composables/useVectorApi.ts +74 -0
  67. package/app/composables/useVectorAuthStore.ts +252 -0
  68. package/app/composables/useVectorBranding.ts +45 -0
  69. package/app/composables/useVectorDashboards.ts +15 -0
  70. package/app/composables/useVectorExtensions.ts +44 -0
  71. package/app/composables/useVectorFields.ts +68 -0
  72. package/app/composables/useVectorForm.ts +36 -0
  73. package/app/composables/useVectorIdle.ts +39 -0
  74. package/app/composables/useVectorImpersonation.ts +78 -0
  75. package/app/composables/useVectorInputs.ts +61 -0
  76. package/app/composables/useVectorJobs.ts +62 -0
  77. package/app/composables/useVectorLocale.ts +70 -0
  78. package/app/composables/useVectorNotifications.ts +24 -0
  79. package/app/composables/useVectorNotificationsInbox.ts +26 -0
  80. package/app/composables/useVectorPreferences.ts +30 -0
  81. package/app/composables/useVectorResource.ts +305 -0
  82. package/app/composables/useVectorRuntime.ts +42 -0
  83. package/app/composables/useVectorSchema.ts +56 -0
  84. package/app/composables/useVectorT.ts +9 -0
  85. package/app/composables/useVectorViews.ts +25 -0
  86. package/app/i18n/ar/actions.ts +11 -0
  87. package/app/i18n/ar/common.ts +23 -0
  88. package/app/i18n/ar/dashboard.ts +28 -0
  89. package/app/i18n/ar/fields.ts +77 -0
  90. package/app/i18n/ar/index.ts +12 -0
  91. package/app/i18n/ar/jobs.ts +36 -0
  92. package/app/i18n/ar/list.ts +160 -0
  93. package/app/i18n/ar/record.ts +62 -0
  94. package/app/i18n/ar/settings.ts +10 -0
  95. package/app/i18n/ar/shell.ts +54 -0
  96. package/app/i18n/de/actions.ts +11 -0
  97. package/app/i18n/de/common.ts +23 -0
  98. package/app/i18n/de/dashboard.ts +28 -0
  99. package/app/i18n/de/fields.ts +77 -0
  100. package/app/i18n/de/index.ts +12 -0
  101. package/app/i18n/de/jobs.ts +36 -0
  102. package/app/i18n/de/list.ts +160 -0
  103. package/app/i18n/de/record.ts +62 -0
  104. package/app/i18n/de/settings.ts +10 -0
  105. package/app/i18n/de/shell.ts +54 -0
  106. package/app/i18n/en/actions.ts +11 -0
  107. package/app/i18n/en/common.ts +23 -0
  108. package/app/i18n/en/dashboard.ts +28 -0
  109. package/app/i18n/en/fields.ts +77 -0
  110. package/app/i18n/en/index.ts +12 -0
  111. package/app/i18n/en/jobs.ts +36 -0
  112. package/app/i18n/en/list.ts +160 -0
  113. package/app/i18n/en/record.ts +62 -0
  114. package/app/i18n/en/settings.ts +10 -0
  115. package/app/i18n/en/shell.ts +54 -0
  116. package/app/i18n/es/actions.ts +11 -0
  117. package/app/i18n/es/common.ts +23 -0
  118. package/app/i18n/es/dashboard.ts +28 -0
  119. package/app/i18n/es/fields.ts +77 -0
  120. package/app/i18n/es/index.ts +12 -0
  121. package/app/i18n/es/jobs.ts +36 -0
  122. package/app/i18n/es/list.ts +160 -0
  123. package/app/i18n/es/record.ts +62 -0
  124. package/app/i18n/es/settings.ts +10 -0
  125. package/app/i18n/es/shell.ts +54 -0
  126. package/app/i18n/fr/actions.ts +11 -0
  127. package/app/i18n/fr/common.ts +23 -0
  128. package/app/i18n/fr/dashboard.ts +28 -0
  129. package/app/i18n/fr/fields.ts +77 -0
  130. package/app/i18n/fr/index.ts +12 -0
  131. package/app/i18n/fr/jobs.ts +36 -0
  132. package/app/i18n/fr/list.ts +160 -0
  133. package/app/i18n/fr/record.ts +62 -0
  134. package/app/i18n/fr/settings.ts +10 -0
  135. package/app/i18n/fr/shell.ts +54 -0
  136. package/app/i18n/he/actions.ts +11 -0
  137. package/app/i18n/he/common.ts +23 -0
  138. package/app/i18n/he/dashboard.ts +28 -0
  139. package/app/i18n/he/fields.ts +77 -0
  140. package/app/i18n/he/index.ts +12 -0
  141. package/app/i18n/he/jobs.ts +36 -0
  142. package/app/i18n/he/list.ts +160 -0
  143. package/app/i18n/he/record.ts +62 -0
  144. package/app/i18n/he/settings.ts +10 -0
  145. package/app/i18n/he/shell.ts +54 -0
  146. package/app/i18n/index.ts +111 -0
  147. package/app/i18n/it/actions.ts +11 -0
  148. package/app/i18n/it/common.ts +23 -0
  149. package/app/i18n/it/dashboard.ts +28 -0
  150. package/app/i18n/it/fields.ts +77 -0
  151. package/app/i18n/it/index.ts +12 -0
  152. package/app/i18n/it/jobs.ts +36 -0
  153. package/app/i18n/it/list.ts +160 -0
  154. package/app/i18n/it/record.ts +62 -0
  155. package/app/i18n/it/settings.ts +10 -0
  156. package/app/i18n/it/shell.ts +54 -0
  157. package/app/i18n/ja/actions.ts +11 -0
  158. package/app/i18n/ja/common.ts +23 -0
  159. package/app/i18n/ja/dashboard.ts +28 -0
  160. package/app/i18n/ja/fields.ts +77 -0
  161. package/app/i18n/ja/index.ts +12 -0
  162. package/app/i18n/ja/jobs.ts +36 -0
  163. package/app/i18n/ja/list.ts +160 -0
  164. package/app/i18n/ja/record.ts +62 -0
  165. package/app/i18n/ja/settings.ts +10 -0
  166. package/app/i18n/ja/shell.ts +54 -0
  167. package/app/i18n/ko/actions.ts +11 -0
  168. package/app/i18n/ko/common.ts +23 -0
  169. package/app/i18n/ko/dashboard.ts +28 -0
  170. package/app/i18n/ko/fields.ts +77 -0
  171. package/app/i18n/ko/index.ts +12 -0
  172. package/app/i18n/ko/jobs.ts +36 -0
  173. package/app/i18n/ko/list.ts +160 -0
  174. package/app/i18n/ko/record.ts +62 -0
  175. package/app/i18n/ko/settings.ts +10 -0
  176. package/app/i18n/ko/shell.ts +54 -0
  177. package/app/i18n/nl/actions.ts +11 -0
  178. package/app/i18n/nl/common.ts +23 -0
  179. package/app/i18n/nl/dashboard.ts +28 -0
  180. package/app/i18n/nl/fields.ts +77 -0
  181. package/app/i18n/nl/index.ts +12 -0
  182. package/app/i18n/nl/jobs.ts +36 -0
  183. package/app/i18n/nl/list.ts +160 -0
  184. package/app/i18n/nl/record.ts +62 -0
  185. package/app/i18n/nl/settings.ts +10 -0
  186. package/app/i18n/nl/shell.ts +54 -0
  187. package/app/i18n/pl/actions.ts +11 -0
  188. package/app/i18n/pl/common.ts +23 -0
  189. package/app/i18n/pl/dashboard.ts +28 -0
  190. package/app/i18n/pl/fields.ts +77 -0
  191. package/app/i18n/pl/index.ts +12 -0
  192. package/app/i18n/pl/jobs.ts +36 -0
  193. package/app/i18n/pl/list.ts +160 -0
  194. package/app/i18n/pl/record.ts +62 -0
  195. package/app/i18n/pl/settings.ts +10 -0
  196. package/app/i18n/pl/shell.ts +54 -0
  197. package/app/i18n/pt/actions.ts +11 -0
  198. package/app/i18n/pt/common.ts +23 -0
  199. package/app/i18n/pt/dashboard.ts +28 -0
  200. package/app/i18n/pt/fields.ts +77 -0
  201. package/app/i18n/pt/index.ts +12 -0
  202. package/app/i18n/pt/jobs.ts +36 -0
  203. package/app/i18n/pt/list.ts +160 -0
  204. package/app/i18n/pt/record.ts +62 -0
  205. package/app/i18n/pt/settings.ts +10 -0
  206. package/app/i18n/pt/shell.ts +54 -0
  207. package/app/i18n/pt-BR/actions.ts +11 -0
  208. package/app/i18n/pt-BR/common.ts +23 -0
  209. package/app/i18n/pt-BR/dashboard.ts +28 -0
  210. package/app/i18n/pt-BR/fields.ts +77 -0
  211. package/app/i18n/pt-BR/index.ts +12 -0
  212. package/app/i18n/pt-BR/jobs.ts +36 -0
  213. package/app/i18n/pt-BR/list.ts +160 -0
  214. package/app/i18n/pt-BR/record.ts +62 -0
  215. package/app/i18n/pt-BR/settings.ts +10 -0
  216. package/app/i18n/pt-BR/shell.ts +54 -0
  217. package/app/i18n/ru/actions.ts +11 -0
  218. package/app/i18n/ru/common.ts +23 -0
  219. package/app/i18n/ru/dashboard.ts +28 -0
  220. package/app/i18n/ru/fields.ts +77 -0
  221. package/app/i18n/ru/index.ts +12 -0
  222. package/app/i18n/ru/jobs.ts +36 -0
  223. package/app/i18n/ru/list.ts +160 -0
  224. package/app/i18n/ru/record.ts +62 -0
  225. package/app/i18n/ru/settings.ts +10 -0
  226. package/app/i18n/ru/shell.ts +54 -0
  227. package/app/i18n/tr/actions.ts +11 -0
  228. package/app/i18n/tr/common.ts +23 -0
  229. package/app/i18n/tr/dashboard.ts +28 -0
  230. package/app/i18n/tr/fields.ts +77 -0
  231. package/app/i18n/tr/index.ts +12 -0
  232. package/app/i18n/tr/jobs.ts +36 -0
  233. package/app/i18n/tr/list.ts +160 -0
  234. package/app/i18n/tr/record.ts +62 -0
  235. package/app/i18n/tr/settings.ts +10 -0
  236. package/app/i18n/tr/shell.ts +54 -0
  237. package/app/i18n/uk/actions.ts +11 -0
  238. package/app/i18n/uk/common.ts +23 -0
  239. package/app/i18n/uk/dashboard.ts +28 -0
  240. package/app/i18n/uk/fields.ts +77 -0
  241. package/app/i18n/uk/index.ts +12 -0
  242. package/app/i18n/uk/jobs.ts +36 -0
  243. package/app/i18n/uk/list.ts +160 -0
  244. package/app/i18n/uk/record.ts +62 -0
  245. package/app/i18n/uk/settings.ts +10 -0
  246. package/app/i18n/uk/shell.ts +54 -0
  247. package/app/i18n/zh-Hans/actions.ts +11 -0
  248. package/app/i18n/zh-Hans/common.ts +23 -0
  249. package/app/i18n/zh-Hans/dashboard.ts +28 -0
  250. package/app/i18n/zh-Hans/fields.ts +77 -0
  251. package/app/i18n/zh-Hans/index.ts +12 -0
  252. package/app/i18n/zh-Hans/jobs.ts +36 -0
  253. package/app/i18n/zh-Hans/list.ts +160 -0
  254. package/app/i18n/zh-Hans/record.ts +62 -0
  255. package/app/i18n/zh-Hans/settings.ts +10 -0
  256. package/app/i18n/zh-Hans/shell.ts +54 -0
  257. package/app/layouts/default.vue +8 -0
  258. package/app/layouts/vector.vue +238 -0
  259. package/app/middleware/vector-auth.ts +16 -0
  260. package/app/pages/d/[name].vue +12 -0
  261. package/app/pages/index.vue +22 -0
  262. package/app/pages/jobs/[id].vue +52 -0
  263. package/app/pages/jobs/index.vue +42 -0
  264. package/app/pages/login.vue +109 -0
  265. package/app/pages/p/[name].vue +37 -0
  266. package/app/pages/r/[resource]/[id]/edit.vue +54 -0
  267. package/app/pages/r/[resource]/[id]/index.vue +143 -0
  268. package/app/pages/r/[resource]/create.vue +53 -0
  269. package/app/pages/r/[resource]/index.vue +29 -0
  270. package/app/pages/settings.vue +104 -0
  271. package/app/plugins/vector-csp.client.ts +14 -0
  272. package/app/plugins/vector-extensions.client.ts +84 -0
  273. package/app/plugins/vector-i18n.ts +76 -0
  274. package/app/types/vector.ts +438 -0
  275. package/app/utils/files.ts +14 -0
  276. package/app/utils/format.ts +100 -0
  277. package/app/utils/richtext.ts +305 -0
  278. package/app/utils/rules.ts +113 -0
  279. package/app/utils/translate.ts +45 -0
  280. package/app/utils/widgets.ts +11 -0
  281. package/nuxt.config.ts +83 -0
  282. package/package.json +82 -5
@@ -0,0 +1,22 @@
1
+ <script setup lang="ts">
2
+ import type { VectorWidgetData } from '#vector/app/types/vector'
3
+
4
+ const props = defineProps<{ data: VectorWidgetData }>()
5
+ const { t } = useVectorT()
6
+ const delta = computed(() => {
7
+ const { value, previous } = props.data
8
+ if (value === null || value === undefined || previous === null || previous === undefined || previous === 0) return null
9
+ return ((value - previous) / Math.abs(previous)) * 100
10
+ })
11
+ </script>
12
+
13
+ <template>
14
+ <div>
15
+ <div class="text-h4 font-weight-medium">{{ formatWidgetValue(data.value, data.format) }}</div>
16
+ <div class="d-flex align-center ga-2 mt-1">
17
+ <v-chip v-if="delta !== null" size="x-small" :color="delta >= 0 ? 'success' : 'error'" variant="tonal" :prepend-icon="delta >= 0 ? 'mdi-arrow-up' : 'mdi-arrow-down'">{{ t('dashboard.percent', { value: Math.abs(delta).toFixed(1) }) }}</v-chip>
18
+ <span v-if="data.previous !== null && data.previous !== undefined" class="text-caption text-medium-emphasis">{{ t('dashboard.stat.previous', { value: formatWidgetValue(data.previous, data.format) }) }}</span>
19
+ </div>
20
+ <div v-if="data.caption" class="text-caption text-medium-emphasis mt-1">{{ data.caption }}</div>
21
+ </div>
22
+ </template>
@@ -0,0 +1,31 @@
1
+ <script setup lang="ts">
2
+ import type { VectorWidgetData } from '#vector/app/types/vector'
3
+
4
+ const props = defineProps<{ data: VectorWidgetData }>()
5
+ const appConfig = useAppConfig()
6
+ const { t } = useVectorT()
7
+ const rows = computed(() => props.data.rows ?? [])
8
+
9
+ function open(row: Record<string, unknown>) {
10
+ if (typeof row._link === 'string') navigateTo(`${appConfig.vector.basePath}${row._link}`)
11
+ }
12
+ </script>
13
+
14
+ <template>
15
+ <div>
16
+ <v-table density="compact" hover>
17
+ <thead>
18
+ <tr><th v-for="col in data.columns ?? []" :key="col.key" :class="col.align === 'end' ? 'text-end' : ''">{{ col.label }}</th></tr>
19
+ </thead>
20
+ <tbody>
21
+ <tr v-for="(row, i) in rows" :key="i" :style="typeof row._link === 'string' ? 'cursor: pointer' : ''" @click="open(row)">
22
+ <td v-for="col in data.columns ?? []" :key="col.key" :class="col.align === 'end' ? 'text-end' : ''">{{ row[col.key] }}</td>
23
+ </tr>
24
+ <tr v-if="rows.length === 0"><td :colspan="(data.columns ?? []).length" class="text-medium-emphasis">{{ t('dashboard.table.empty') }}</td></tr>
25
+ </tbody>
26
+ </v-table>
27
+ <div v-if="data.link" class="text-end mt-2">
28
+ <v-btn size="small" variant="text" :to="`${appConfig.vector.basePath}${data.link}`" append-icon="mdi-arrow-right">{{ t('dashboard.table.seeAll') }}</v-btn>
29
+ </div>
30
+ </div>
31
+ </template>
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ import type { VectorWidgetData } from '#vector/app/types/vector'
3
+
4
+ // A sparkline drawn as inline SVG: no chart library, CSP-clean.
5
+ const props = defineProps<{ data: VectorWidgetData }>()
6
+ const { t } = useVectorT()
7
+ const width = 600
8
+ const height = 140
9
+ const points = computed(() => props.data.points ?? [])
10
+ const max = computed(() => Math.max(1, ...points.value.map(p => p.y)))
11
+ const path = computed(() => {
12
+ const n = points.value.length
13
+ if (n === 0) return ''
14
+ const step = n > 1 ? width / (n - 1) : width
15
+ return points.value.map((p, i) => `${i === 0 ? 'M' : 'L'}${(i * step).toFixed(1)},${(height - (p.y / max.value) * (height - 10) - 5).toFixed(1)}`).join(' ')
16
+ })
17
+ const area = computed(() => path.value ? `${path.value} L${width},${height} L0,${height} Z` : '')
18
+ const hover = ref<number | null>(null)
19
+ </script>
20
+
21
+ <template>
22
+ <div>
23
+ <div class="d-flex justify-space-between text-caption text-medium-emphasis mb-1">
24
+ <span>{{ points[0]?.x ?? '' }}</span>
25
+ <span v-if="data.total !== null && data.total !== undefined" class="font-weight-medium">{{ t('dashboard.trend.total', { value: formatWidgetValue(data.total, data.format) }) }}</span>
26
+ <span>{{ points[points.length - 1]?.x ?? '' }}</span>
27
+ </div>
28
+ <svg :viewBox="`0 0 ${width} ${height}`" preserveAspectRatio="none" class="w-100" style="height: 140px" @mouseleave="hover = null">
29
+ <path :d="area" fill="rgb(var(--v-theme-primary))" fill-opacity="0.12" />
30
+ <path :d="path" fill="none" stroke="rgb(var(--v-theme-primary))" stroke-width="2" vector-effect="non-scaling-stroke" />
31
+ <rect v-for="(p, i) in points" :key="p.x" :x="(i * (width / Math.max(1, points.length - 1))) - width / Math.max(1, points.length - 1) / 2" y="0" :width="width / Math.max(1, points.length - 1)" :height="height" fill="transparent" @mouseenter="hover = i" />
32
+ </svg>
33
+ <div class="text-caption text-center" style="min-height: 1.2em">
34
+ <template v-if="hover !== null && points[hover]">{{ points[hover]!.x }}: <strong>{{ formatWidgetValue(points[hover]!.y, data.format) }}</strong></template>
35
+ </div>
36
+ </div>
37
+ </template>
@@ -0,0 +1,34 @@
1
+ export interface ConfirmOptions {
2
+ title: string
3
+ text?: string
4
+ confirmText?: string
5
+ cancelText?: string
6
+ destructive?: boolean
7
+ }
8
+
9
+ interface PendingConfirm extends ConfirmOptions {
10
+ resolve: (confirmed: boolean) => void
11
+ }
12
+
13
+ /**
14
+ * A promise-based confirmation. Vuetify ships no programmatic dialog;
15
+ * VxConfirm, mounted once in the layout, renders whatever is pending.
16
+ */
17
+ export function useConfirm() {
18
+ const pending = useState<PendingConfirm | null>('vector.confirm', () => null)
19
+
20
+ function confirm(options: ConfirmOptions): Promise<boolean> {
21
+ pending.value?.resolve(false) // whoever asked first is answered, not left waiting
22
+
23
+ return new Promise((resolve) => {
24
+ pending.value = { ...options, resolve }
25
+ })
26
+ }
27
+
28
+ function settle(confirmed: boolean) {
29
+ pending.value?.resolve(confirmed)
30
+ pending.value = null
31
+ }
32
+
33
+ return { pending, confirm, settle }
34
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Asks before leaving a page while $dirty says it holds unsaved changes:
3
+ * a route change through the confirm dialog, a reload or a closed tab
4
+ * through the browser's own prompt.
5
+ */
6
+ export function useLeaveGuard(dirty: () => boolean) {
7
+ const { confirm } = useConfirm()
8
+ const { t } = useVectorT()
9
+
10
+ const mayLeave = async () => !dirty() || await confirm({ title: t('record.leave.title'), text: t('record.leave.text'), confirmText: t('record.leave.discard'), destructive: true })
11
+
12
+ onBeforeRouteLeave(mayLeave)
13
+ onBeforeRouteUpdate((to, from) => to.path === from.path || mayLeave())
14
+
15
+ function warnBeforeUnload(event: BeforeUnloadEvent) {
16
+ if (dirty()) event.preventDefault()
17
+ }
18
+
19
+ onMounted(() => window.addEventListener('beforeunload', warnBeforeUnload))
20
+ onBeforeUnmount(() => window.removeEventListener('beforeunload', warnBeforeUnload))
21
+ }
@@ -0,0 +1,74 @@
1
+ import type { $Fetch } from 'nitropack'
2
+
3
+ /**
4
+ * The one HTTP client every Vector request goes through. Bearer mode
5
+ * attaches the in-memory token — renewed first when it has under two
6
+ * minutes left — and on a 401 renews it once and repeats the request,
7
+ * sending the user to the login page only when that fails too (a 401
8
+ * means the request did nothing, so repeating even a POST is safe);
9
+ * cookie mode (hosted) rides the application's session cookie,
10
+ * sends the CSRF token on every unsafe method, and hands a 401 to the
11
+ * application's own login URL.
12
+ */
13
+ export function useVectorApi(): $Fetch {
14
+ const config = useRuntimeConfig()
15
+ const appConfig = useAppConfig()
16
+ const runtime = useVectorRuntime()
17
+ const auth = useVectorAuthStore()
18
+ const router = useRouter()
19
+
20
+ return $fetch.create({
21
+ baseURL: runtime.hosted ? (runtime.apiBase ?? '') : config.public.vector.apiBase,
22
+ credentials: runtime.hosted ? 'same-origin' : 'omit',
23
+ async onRequest({ options }) {
24
+ if (runtime.hosted) {
25
+ const method = String(options.method ?? 'GET').toUpperCase()
26
+
27
+ if (runtime.csrfToken && !['GET', 'HEAD', 'OPTIONS'].includes(method)) {
28
+ options.headers.set('X-CSRF-Token', runtime.csrfToken)
29
+ }
30
+ } else {
31
+ const token = await auth.fresh()
32
+ if (token) options.headers.set('Authorization', `Bearer ${token}`)
33
+ }
34
+ },
35
+ async onResponseError({ response, options }) {
36
+ const once = options as typeof options & { vectorRenewed?: boolean }
37
+
38
+ if (response.status === 401 && !runtime.hosted && !once.vectorRenewed && auth.canRefresh && await auth.refresh()) {
39
+ // repeated once, with the renewed token (onRequest sets it)
40
+ once.vectorRenewed = true
41
+ options.retry = 1
42
+ options.retryStatusCodes = [401]
43
+
44
+ return
45
+ }
46
+
47
+ if (response.status === 401) {
48
+ if (runtime.hosted && runtime.loginUrl && import.meta.client) {
49
+ window.location.assign(runtime.loginUrl)
50
+
51
+ return
52
+ }
53
+
54
+ auth.clear()
55
+ // back to this page after signing in again, as the route middleware would send a visitor
56
+ const current = router.currentRoute.value.fullPath
57
+ await navigateTo({ path: `${appConfig.vector.basePath || ''}/login`, query: current.startsWith(`${appConfig.vector.basePath || ''}/login`) ? {} : { redirect: current } })
58
+ }
59
+ },
60
+ }) as $Fetch
61
+ }
62
+
63
+ /** The message a failed request should show, from either Kinetis error shape. */
64
+ export function vectorErrorMessage(error: unknown): string {
65
+ const failure = error as { data?: { error?: string, detail?: string }, message?: string } | null
66
+ const data = failure?.data
67
+
68
+ if (data && typeof data === 'object') {
69
+ if (typeof data.error === 'string') return data.error
70
+ if (typeof data.detail === 'string') return data.detail
71
+ }
72
+
73
+ return typeof failure?.message === 'string' ? failure.message : String(error)
74
+ }
@@ -0,0 +1,252 @@
1
+ import { defineStore } from 'pinia'
2
+
3
+ export interface VectorUser {
4
+ id: number | string
5
+ name: string
6
+ email?: string
7
+ }
8
+
9
+ interface TokenResponse {
10
+ accessToken: string
11
+ tokenType: string
12
+ expiresIn: number
13
+ /** Present when the application issues refresh tokens (POST /auth/refresh renews both, once). */
14
+ refreshToken?: string
15
+ refreshExpiresIn?: number
16
+ user: VectorUser
17
+ }
18
+
19
+ interface Saved {
20
+ token: string
21
+ expiresAt: number
22
+ user: VectorUser
23
+ refreshToken?: string | null
24
+ refreshExpiresAt?: number
25
+ refreshSubject?: string | null
26
+ }
27
+
28
+ const STORAGE_KEY = 'vector.session'
29
+ /** An access token with less than this left is renewed before it is used. */
30
+ export const REFRESH_MARGIN_MS = 120_000
31
+
32
+ // The pending end (or renewal) of the session, and a renewal in flight. The
33
+ // UI runs in the browser only (ssr: false), one app per page, so one of
34
+ // each per module is one per session.
35
+ let expiryTimer: ReturnType<typeof setTimeout> | null = null
36
+ let refreshing: Promise<boolean> | null = null
37
+ // setTimeout's longest delay; a later expiry is re-checked when it fires
38
+ const MAX_DELAY = 2 ** 31 - 1
39
+
40
+ /**
41
+ * Bearer-mode session. The access token lives in memory for the tab and
42
+ * is mirrored to sessionStorage — never localStorage — beside the refresh
43
+ * token when the application issues one (`/auth/login` answering
44
+ * `refreshToken`). The session renews itself through `POST /auth/refresh`
45
+ * when the access token has under two minutes left, and once more on a
46
+ * 401; a failed renewal signs the user out. Logout revokes the refresh
47
+ * token (`POST /auth/logout`). An impersonation token is never renewed:
48
+ * the refresh token is the signed-in user's own, used only while the
49
+ * session is theirs. Hosted mode rides the application's session instead.
50
+ */
51
+ export const useVectorAuthStore = defineStore('vectorAuth', {
52
+ state: () => ({
53
+ token: null as string | null,
54
+ expiresAt: 0,
55
+ user: null as VectorUser | null,
56
+ refreshToken: null as string | null,
57
+ refreshExpiresAt: 0,
58
+ /** Whose refresh token it is: the user who signed in. */
59
+ refreshSubject: null as string | null,
60
+ restored: false,
61
+ hosted: false,
62
+ }),
63
+
64
+ getters: {
65
+ /** Whether the session can renew itself: a live refresh token, and the session is its owner's. */
66
+ canRefresh: state => state.refreshToken !== null
67
+ && state.refreshExpiresAt > Date.now()
68
+ && state.user !== null
69
+ && String(state.user.id) === state.refreshSubject,
70
+ // A getter is cached until the state changes, and the clock is not
71
+ // state: the session's own timer (expireAt) renews or clears the
72
+ // token when it runs out, which is the change that updates this.
73
+ isAuthenticated(state): boolean {
74
+ return state.hosted || (state.token !== null && state.expiresAt > Date.now()) || this.canRefresh
75
+ },
76
+ },
77
+
78
+ actions: {
79
+ restore() {
80
+ if (this.restored) {
81
+ return
82
+ }
83
+
84
+ this.restored = true
85
+ const runtime = useVectorRuntime()
86
+
87
+ if (runtime.hosted) {
88
+ this.hosted = true
89
+ this.user = runtime.user ? { id: runtime.user.id, name: runtime.user.name ?? vt('shell.account') } : null
90
+
91
+ return
92
+ }
93
+
94
+ try {
95
+ const raw = sessionStorage.getItem(STORAGE_KEY)
96
+
97
+ if (raw) {
98
+ const saved = JSON.parse(raw) as Saved
99
+ const refreshable = !!saved.refreshToken && (saved.refreshExpiresAt ?? 0) > Date.now()
100
+
101
+ if (saved.expiresAt > Date.now() || refreshable) {
102
+ this.token = saved.token
103
+ this.expiresAt = saved.expiresAt
104
+ this.user = saved.user
105
+ this.refreshToken = refreshable ? saved.refreshToken! : null
106
+ this.refreshExpiresAt = refreshable ? saved.refreshExpiresAt! : 0
107
+ this.refreshSubject = refreshable ? saved.refreshSubject ?? null : null
108
+ this.expireAt(saved.expiresAt)
109
+ }
110
+ }
111
+ } catch {
112
+ // Storage unavailable or unreadable: the user simply logs in again.
113
+ }
114
+ },
115
+
116
+ async login(email: string, password: string) {
117
+ const config = useRuntimeConfig()
118
+ const response = await $fetch<TokenResponse>('/auth/login', {
119
+ baseURL: config.public.vector.apiBase,
120
+ method: 'POST',
121
+ body: { email, password },
122
+ })
123
+
124
+ this.take(response)
125
+ },
126
+
127
+ /**
128
+ * Renews both tokens, once at a time however many callers ask.
129
+ * False, and the session ended, when the refresh token is refused.
130
+ */
131
+ refresh(): Promise<boolean> {
132
+ if (!this.canRefresh) return Promise.resolve(false)
133
+
134
+ refreshing ??= (async () => {
135
+ try {
136
+ const config = useRuntimeConfig()
137
+ const response = await $fetch<TokenResponse>('/auth/refresh', {
138
+ baseURL: config.public.vector.apiBase,
139
+ method: 'POST',
140
+ body: { refreshToken: this.refreshToken },
141
+ })
142
+ this.take(response)
143
+
144
+ return true
145
+ } catch {
146
+ this.clear()
147
+
148
+ return false
149
+ } finally {
150
+ refreshing = null
151
+ }
152
+ })()
153
+
154
+ return refreshing
155
+ },
156
+
157
+ /** A token fit to send: renewed first when it has under two minutes left and the session can renew. */
158
+ async fresh(): Promise<string | null> {
159
+ if (this.token !== null && this.expiresAt - Date.now() < REFRESH_MARGIN_MS && this.canRefresh) {
160
+ await this.refresh()
161
+ }
162
+
163
+ return this.token
164
+ },
165
+
166
+ /** Takes a token the server issued outside the login form: impersonation. It is not renewed. */
167
+ adopt(token: string, expiresIn: number, user: VectorUser) {
168
+ this.token = token
169
+ this.expiresAt = Date.now() + expiresIn * 1000
170
+ this.user = user
171
+ this.expireAt(this.expiresAt)
172
+ this.save()
173
+ },
174
+
175
+ /** Signs out: the refresh token is revoked where the application issued it, then the session is forgotten. */
176
+ async logout() {
177
+ const refreshToken = this.refreshToken
178
+
179
+ if (refreshToken) {
180
+ const config = useRuntimeConfig()
181
+ await $fetch('/auth/logout', { baseURL: config.public.vector.apiBase, method: 'POST', body: { refreshToken } }).catch(() => undefined)
182
+ }
183
+
184
+ this.clear()
185
+ },
186
+
187
+ /** Renews the session shortly before its token runs out, or ends it when it cannot. */
188
+ expireAt(expiresAt: number) {
189
+ if (expiryTimer !== null) clearTimeout(expiryTimer)
190
+
191
+ const due = this.canRefresh ? expiresAt - REFRESH_MARGIN_MS : expiresAt
192
+
193
+ expiryTimer = setTimeout(() => {
194
+ expiryTimer = null
195
+
196
+ if (this.expiresAt !== expiresAt) return // a newer token has its own timer
197
+ if (Date.now() < due) this.expireAt(expiresAt) // past setTimeout's range: wait again
198
+ else if (this.canRefresh) void this.refresh()
199
+ else this.clear()
200
+ }, Math.min(MAX_DELAY, Math.max(0, due - Date.now())))
201
+ },
202
+
203
+ clear() {
204
+ if (expiryTimer !== null) {
205
+ clearTimeout(expiryTimer)
206
+ expiryTimer = null
207
+ }
208
+
209
+ this.token = null
210
+ this.expiresAt = 0
211
+ this.user = null
212
+ this.refreshToken = null
213
+ this.refreshExpiresAt = 0
214
+ this.refreshSubject = null
215
+
216
+ try {
217
+ sessionStorage.removeItem(STORAGE_KEY)
218
+ } catch {
219
+ // Nothing to remove.
220
+ }
221
+ },
222
+
223
+ /** A login or refresh answer: the tokens, the user, and the timer that renews them. */
224
+ take(response: TokenResponse) {
225
+ this.token = response.accessToken
226
+ this.expiresAt = Date.now() + response.expiresIn * 1000
227
+ this.user = response.user
228
+
229
+ if (response.refreshToken) {
230
+ this.refreshToken = response.refreshToken
231
+ this.refreshExpiresAt = Date.now() + (response.refreshExpiresIn ?? 0) * 1000
232
+ this.refreshSubject = String(response.user.id)
233
+ } else {
234
+ this.refreshToken = null
235
+ this.refreshExpiresAt = 0
236
+ this.refreshSubject = null
237
+ }
238
+
239
+ this.expireAt(this.expiresAt)
240
+ this.save()
241
+ },
242
+
243
+ save() {
244
+ try {
245
+ const saved: Saved = { token: this.token!, expiresAt: this.expiresAt, user: this.user!, refreshToken: this.refreshToken, refreshExpiresAt: this.refreshExpiresAt, refreshSubject: this.refreshSubject }
246
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(saved))
247
+ } catch {
248
+ // Fine without the mirror.
249
+ }
250
+ },
251
+ },
252
+ })
@@ -0,0 +1,45 @@
1
+ import { useTheme } from 'vuetify'
2
+ import type { VectorBranding } from '#vector/app/types/vector'
3
+
4
+ /**
5
+ * What the shell shows about the application: the Branding settings
6
+ * group, published with the descriptor (and, hosted, injected before
7
+ * the first paint). Falls back to app.config while nothing loaded.
8
+ */
9
+ export function useVectorBranding() {
10
+ const appConfig = useAppConfig()
11
+ const { schema } = useVectorSchema()
12
+ const theme = useTheme()
13
+ const applied = useState<string>('vector.branding.applied', () => '')
14
+
15
+ const hosted = import.meta.client ? window.__VECTOR__?.branding ?? null : null
16
+
17
+ const branding = computed<VectorBranding>(() => ({
18
+ title: appConfig.vector.brand.title,
19
+ logoUrl: null,
20
+ primaryColor: null,
21
+ primaryColorDark: null,
22
+ theme: null,
23
+ footerText: null,
24
+ ...(schema.value?.branding ?? hosted ?? {}),
25
+ }))
26
+
27
+ /** Pushes the colours into Vuetify's theme and picks the default theme once per session. */
28
+ function apply() {
29
+ const b = branding.value
30
+ const signature = JSON.stringify(b)
31
+
32
+ if (applied.value === signature) return
33
+
34
+ if (b.primaryColor) theme.themes.value.light!.colors.primary = b.primaryColor
35
+ if (b.primaryColorDark) theme.themes.value.dark!.colors.primary = b.primaryColorDark
36
+
37
+ if (applied.value === '' && b.theme && b.theme !== 'system') {
38
+ theme.change(b.theme)
39
+ }
40
+
41
+ applied.value = signature
42
+ }
43
+
44
+ return { branding, apply }
45
+ }
@@ -0,0 +1,15 @@
1
+ import type { VectorDashboard, VectorWidgetData } from '#vector/app/types/vector'
2
+
3
+ export function useVectorDashboards() {
4
+ const api = useVectorApi()
5
+
6
+ async function show(name: string): Promise<VectorDashboard> {
7
+ return (await api<{ data: VectorDashboard }>(`/vector/dashboards/${encodeURIComponent(name)}`)).data
8
+ }
9
+
10
+ async function widget(dashboard: string, name: string, range?: string): Promise<VectorWidgetData> {
11
+ return (await api<{ data: VectorWidgetData }>(`/vector/dashboards/${encodeURIComponent(dashboard)}/widgets/${encodeURIComponent(name)}`, { query: range ? { range } : {} })).data
12
+ }
13
+
14
+ return { show, widget }
15
+ }
@@ -0,0 +1,44 @@
1
+ import type { Component } from 'vue'
2
+ import type { AnyExtension } from '@tiptap/core'
3
+
4
+ /** A custom page: a menu entry and a route, `/p/{name}`, rendering the component. */
5
+ export interface VectorPageDefinition {
6
+ title: string
7
+ icon?: string
8
+ /** The menu section it appears under; the "Pages" section when omitted. */
9
+ group?: string
10
+ component: Component | string
11
+ }
12
+
13
+ /**
14
+ * The registries runtime extensions fill through `window.Vector`:
15
+ * cells, inputs, widget renderers and custom pages by name. Reactive, so
16
+ * something registered after boot shows up without a reload. `ready`
17
+ * turns true once every extension module has loaded (or failed to).
18
+ */
19
+ export function useVectorExtensions() {
20
+ const cells = useState<Map<string, Component>>('vector.ext.cells', () => shallowReactive(new Map()))
21
+ const inputs = useState<Map<string, Component>>('vector.ext.inputs', () => shallowReactive(new Map()))
22
+ const widgets = useState<Map<string, Component>>('vector.ext.widgets', () => shallowReactive(new Map()))
23
+ const pages = useState<Map<string, VectorPageDefinition>>('vector.ext.pages', () => shallowReactive(new Map()))
24
+ // rich-text node and mark types, by the feature name a field's `features` uses
25
+ const richText = useState<Map<string, AnyExtension>>('vector.ext.richText', () => shallowReactive(new Map()))
26
+ const ready = useState<boolean>('vector.ext.ready', () => false)
27
+
28
+ return { cells: cells.value, inputs: inputs.value, widgets: widgets.value, pages: pages.value, richText: richText.value, ready }
29
+ }
30
+
31
+ /**
32
+ * Every custom page: the application's own from app config
33
+ * `vector.pages` (a globally registered component by name) and the ones
34
+ * runtime extensions defined.
35
+ */
36
+ export function useVectorPages() {
37
+ const appConfig = useAppConfig()
38
+ const { pages } = useVectorExtensions()
39
+
40
+ return computed<Record<string, VectorPageDefinition>>(() => ({
41
+ ...(appConfig.vector.pages ?? {}),
42
+ ...Object.fromEntries(pages.entries()),
43
+ }))
44
+ }
@@ -0,0 +1,68 @@
1
+ import type { Component } from 'vue'
2
+ import type { VectorField } from '#vector/app/types/vector'
3
+
4
+ /**
5
+ * The field registry: a descriptor field resolves to the component that
6
+ * renders it as a table cell or a show-page value.
7
+ *
8
+ * Built-in cells are imported explicitly — a dynamic `<component :is>`
9
+ * defeats template-time auto-import, so nothing here relies on it. A
10
+ * consuming app adds or replaces entries through `app.config.vector.fields`
11
+ * (keyed by a field's explicit `component` name or by its `type`), naming
12
+ * a component it registered globally in a plugin.
13
+ */
14
+ const cells: Record<string, Component> = {
15
+ id: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Text.vue')),
16
+ string: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Text.vue')),
17
+ text: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Text.vue')),
18
+ markdown: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Markdown.vue')),
19
+ richtext: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/RichText.vue')),
20
+ number: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Number.vue')),
21
+ money: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Money.vue')),
22
+ boolean: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Boolean.vue')),
23
+ enum: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Enum.vue')),
24
+ date: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Date.vue')),
25
+ datetime: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/DateTime.vue')),
26
+ email: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Email.vue')),
27
+ url: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Url.vue')),
28
+ password: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Text.vue')),
29
+ slug: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Text.vue')),
30
+ color: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Color.vue')),
31
+ json: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Json.vue')),
32
+ keyValue: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/KeyValue.vue')),
33
+ file: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/File.vue')),
34
+ image: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Image.vue')),
35
+ belongsTo: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Relation.vue')),
36
+ hasOne: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Relation.vue')),
37
+ hasMany: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Count.vue')),
38
+ manyToMany: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Count.vue')),
39
+ }
40
+
41
+ export function useVectorFields() {
42
+ const appConfig = useAppConfig()
43
+
44
+ /** The cell for a field: an app override by name, then the built-in for its type. */
45
+ function cellFor(field: VectorField): Component | string {
46
+ const overrides = appConfig.vector.fields
47
+ const runtime = useVectorExtensions().cells
48
+
49
+ if (field.component && overrides[field.component]) {
50
+ return overrides[field.component]!
51
+ }
52
+
53
+ if (field.component && runtime.has(field.component)) {
54
+ return runtime.get(field.component)!
55
+ }
56
+
57
+ if (overrides[field.type]) {
58
+ return overrides[field.type]!
59
+ }
60
+
61
+ // a computed value is shown by the cell of what it is computed as
62
+ const type = field.type === 'computed' ? (field.computed?.as === 'text' ? 'string' : field.computed?.as ?? 'string') : field.type
63
+
64
+ return cells[type] ?? cells.string!
65
+ }
66
+
67
+ return { cellFor }
68
+ }