@kinetis/vector-ui 0.0.0-stage → 0.9.1

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 +34 -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 +83 -5
@@ -0,0 +1,13 @@
1
+ <script setup lang="ts">
2
+ import type { VectorField } from '#vector/app/types/vector'
3
+
4
+ const props = defineProps<{ field: VectorField, value: unknown, context: 'index' | 'show' }>()
5
+ const text = computed(() => props.value === null || props.value === undefined ? '' : String(props.value))
6
+ const truncated = computed(() => props.context === 'index' && text.value.length > 80 ? text.value.slice(0, 77) + '…' : text.value)
7
+ const { t } = useVectorT()
8
+ </script>
9
+
10
+ <template>
11
+ <span v-if="text === ''" class="text-disabled">{{ t('common.none') }}</span>
12
+ <span v-else :title="truncated !== text ? text : undefined">{{ truncated }}</span>
13
+ </template>
@@ -0,0 +1,11 @@
1
+ <script setup lang="ts">
2
+ defineProps<{ value: unknown }>()
3
+ const { t } = useVectorT()
4
+ </script>
5
+
6
+ <template>
7
+ <a v-if="typeof value === 'string' && value && allowedHref(value)" :href="value" target="_blank" rel="noopener noreferrer" @click.stop>{{ value }}</a>
8
+ <!-- a value with another scheme is shown, never followed -->
9
+ <span v-else-if="typeof value === 'string' && value">{{ value }}</span>
10
+ <span v-else class="text-disabled">{{ t('common.none') }}</span>
11
+ </template>
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ import type { VectorField, VectorResource } from '#vector/app/types/vector'
3
+
4
+ // Dispatches a value to the component the field registry names. A
5
+ // built-in arrives as a component; an app override arrives as the name
6
+ // of a globally registered one and is resolved here, in setup.
7
+ const props = defineProps<{
8
+ field: VectorField
9
+ value: unknown
10
+ record: Record<string, unknown>
11
+ resource: VectorResource
12
+ context: 'index' | 'show'
13
+ }>()
14
+
15
+ const { cellFor } = useVectorFields()
16
+ const component = computed(() => {
17
+ const cell = cellFor(props.field)
18
+
19
+ return typeof cell === 'string' ? resolveComponent(cell) : cell
20
+ })
21
+ </script>
22
+
23
+ <template>
24
+ <component :is="component" :field="field" :value="value" :record="record" :resource="resource" :context="context" />
25
+ </template>
@@ -0,0 +1,118 @@
1
+ <script setup lang="ts">
2
+ import { fieldProps, type VectorSearchResult } from '#vector/app/types/vector'
3
+
4
+ // Ctrl/Cmd+K: search every globally searchable resource, jump to a
5
+ // record or a resource. A dialog and a list — no Labs dependency.
6
+ const appConfig = useAppConfig()
7
+ const api = useVectorApi()
8
+ const { menu, resource: resourceOf } = useVectorSchema()
9
+ const open = ref(false)
10
+ const query = ref<string | null>('')
11
+ const results = ref<VectorSearchResult[]>([])
12
+ const loading = ref(false)
13
+ let requestId = 0
14
+ const { t } = useVectorT()
15
+
16
+ const search = useDebounceFn(async (q: string) => {
17
+ const id = ++requestId
18
+ const term = q.trim()
19
+
20
+ if (term === '') {
21
+ results.value = []
22
+ return
23
+ }
24
+
25
+ loading.value = true
26
+ try {
27
+ const response = await api<{ results: VectorSearchResult[] }>('/vector/search', { query: { q: term } })
28
+ if (id === requestId) results.value = response.results
29
+ } finally {
30
+ if (id === requestId) loading.value = false
31
+ }
32
+ }, 200)
33
+
34
+ watch(query, q => search(q ?? ''))
35
+ watch(open, (o) => { if (!o) { query.value = ''; results.value = [] } })
36
+
37
+ const keyOf = (r: VectorSearchResult) => `${r.resource}-${r.id}`
38
+
39
+ /** The subtitle as its field shows it — an enum by its label — then the resource. */
40
+ function subtitleOf(r: VectorSearchResult): string {
41
+ const resource = resourceOf(r.resource)
42
+ const field = resource?.fields.find(f => f.name === resource.searchResult?.subtitle)
43
+ let text = r.subtitle ?? null
44
+ if (text !== null && field?.type === 'enum') text = field.options?.find(o => String(o.value) === text)?.label ?? text
45
+ else if (text !== null && field?.type === 'number' && text !== '' && !Number.isNaN(Number(text))) {
46
+ const decimals = fieldProps(field).decimals
47
+ text = formatNumber(Number(text), typeof decimals === 'number' ? decimals : undefined)
48
+ }
49
+ return [text, r.resourceLabel].filter(Boolean).join(' · ')
50
+ }
51
+
52
+ // Thumbnails come through the API client, like the image cells, so a
53
+ // bearer-authenticated admin sees them too; a missing file keeps the icon.
54
+ const thumbs = reactive<Record<string, string>>({})
55
+ watch(results, (list) => {
56
+ for (const key of Object.keys(thumbs)) {
57
+ delete thumbs[key]
58
+ }
59
+ for (const r of list) {
60
+ if (!r.image) continue
61
+ api<Blob>(`/vector/r/${encodeURIComponent(r.resource)}/${encodeURIComponent(String(r.id))}/files/${encodeURIComponent(r.image)}`, { responseType: 'blob' })
62
+ .then(dataUrl)
63
+ .then((url) => { if (results.value.includes(r)) thumbs[keyOf(r)] = url })
64
+ .catch(() => {})
65
+ }
66
+ })
67
+
68
+ const pages = useVectorPages()
69
+ const pageItems = computed(() => Object.entries(pages.value).filter(([, p]) => (query.value ?? '').trim() === '' || p.title.toLowerCase().includes((query.value ?? '').trim().toLowerCase())))
70
+ const resources = computed(() => menu.value.flatMap(g => g.items).filter(r => (query.value ?? '').trim() === '' || r.label.toLowerCase().includes((query.value ?? '').trim().toLowerCase())))
71
+
72
+ function go(path: string) {
73
+ open.value = false
74
+ navigateTo(`${appConfig.vector.basePath}${path}`)
75
+ }
76
+
77
+ if (import.meta.client) {
78
+ useEventListener(window, 'keydown', (event: KeyboardEvent) => {
79
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
80
+ event.preventDefault()
81
+ open.value = !open.value
82
+ }
83
+ })
84
+ }
85
+
86
+ defineExpose({ open })
87
+ </script>
88
+
89
+ <template>
90
+ <v-dialog v-model="open" max-width="600" scrollable>
91
+ <v-card>
92
+ <v-text-field v-model="query" autofocus variant="solo" flat :placeholder="t('shell.palette.placeholder')" :aria-label="t('common.search')" prepend-inner-icon="mdi-magnify" :loading="loading" hide-details clearable class="flex-grow-0" />
93
+ <v-divider />
94
+ <v-card-text class="pa-0" style="max-height: 420px">
95
+ <v-list density="compact">
96
+ <template v-if="results.length">
97
+ <v-list-subheader>{{ t('shell.palette.records') }}</v-list-subheader>
98
+ <v-list-item v-for="r in results" :key="keyOf(r)" :title="r.title" :subtitle="subtitleOf(r)" @click="go(`/r/${r.resource}/${encodeURIComponent(String(r.id))}`)">
99
+ <template #prepend>
100
+ <v-avatar v-if="thumbs[keyOf(r)]" rounded size="32" class="me-4">
101
+ <v-img :src="thumbs[keyOf(r)]" alt="" cover />
102
+ </v-avatar>
103
+ <v-icon v-else icon="mdi-file-document-outline" />
104
+ </template>
105
+ </v-list-item>
106
+ </template>
107
+ <v-list-subheader>{{ t('shell.palette.resources') }}</v-list-subheader>
108
+ <v-list-item v-for="r in resources" :key="r.slug" :title="r.label" :prepend-icon="r.icon ?? 'mdi-table'" @click="go(`/r/${r.slug}`)" />
109
+ <template v-if="pageItems.length">
110
+ <v-list-subheader>{{ t('shell.nav.pages') }}</v-list-subheader>
111
+ <v-list-item v-for="[name, p] in pageItems" :key="name" :title="p.title" :prepend-icon="p.icon ?? 'mdi-file-document-outline'" @click="go(`/p/${name}`)" />
112
+ </template>
113
+ <v-list-item v-if="(query ?? '').trim() && !loading && results.length === 0" :title="t('shell.palette.noMatch')" disabled />
114
+ </v-list>
115
+ </v-card-text>
116
+ </v-card>
117
+ </v-dialog>
118
+ </template>
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ const { t } = useVectorT()
3
+ const { pending, settle } = useConfirm()
4
+ const open = computed({ get: () => pending.value !== null, set: (value) => { if (!value) settle(false) } })
5
+ </script>
6
+
7
+ <template>
8
+ <v-dialog v-model="open" max-width="440" persistent>
9
+ <v-card v-if="pending">
10
+ <v-card-title>{{ pending.title }}</v-card-title>
11
+ <v-card-text v-if="pending.text">{{ pending.text }}</v-card-text>
12
+ <v-card-actions>
13
+ <v-spacer />
14
+ <v-btn variant="text" @click="settle(false)">{{ pending.cancelText ?? t('common.cancel') }}</v-btn>
15
+ <v-btn :color="pending.destructive ? 'error' : 'primary'" @click="settle(true)">{{ pending.confirmText ?? t('common.confirm') }}</v-btn>
16
+ </v-card-actions>
17
+ </v-card>
18
+ </v-dialog>
19
+ </template>
@@ -0,0 +1,27 @@
1
+ <script setup lang="ts">
2
+ import type { VectorDashboard } from '#vector/app/types/vector'
3
+
4
+ // One dashboard page: its cards in a responsive grid, each loading its own data.
5
+ // The server translates its labels, so it is fetched again when the language changes.
6
+ const props = defineProps<{ name: string }>()
7
+ const dashboards = useVectorDashboards()
8
+ const { t } = useVectorT()
9
+ const { current: locale } = useVectorLocale()
10
+ const { data, error } = await useAsyncData(() => `vector.dashboard.${props.name}`, () => dashboards.show(props.name), { watch: [locale] })
11
+ const dashboard = computed<VectorDashboard | null>(() => data.value ?? null)
12
+ </script>
13
+
14
+ <template>
15
+ <div>
16
+ <div class="d-flex align-center mb-4">
17
+ <v-icon v-if="dashboard?.icon" :icon="dashboard.icon" class="me-2" />
18
+ <h1 class="text-h4">{{ dashboard?.label ?? t('dashboard.title') }}</h1>
19
+ </div>
20
+ <v-alert v-if="error" type="error" variant="tonal" :text="vectorErrorMessage(error)" />
21
+ <v-row v-else-if="dashboard" density="compact">
22
+ <v-col v-for="widget in dashboard.widgets" :key="widget.name" cols="12" :md="widget.width">
23
+ <VxWidgetCard :widget="widget" :load="(range: string | undefined) => dashboards.widget(props.name, widget.name, range)" />
24
+ </v-col>
25
+ </v-row>
26
+ </div>
27
+ </template>
@@ -0,0 +1,46 @@
1
+ <script setup lang="ts">
2
+ import type { VectorField, VectorFieldState } from '#vector/app/types/vector'
3
+
4
+ // A block of inputs for a loose field list — an action's dialog, a
5
+ // settings group — sharing the record form's registry, rules and 422
6
+ // mapping. The owner holds the values and the server errors.
7
+ const props = defineProps<{
8
+ fields: VectorField[]
9
+ values: Record<string, unknown>
10
+ serverErrors: Record<string, string[]>
11
+ resource?: string
12
+ }>()
13
+
14
+ const emit = defineEmits<{ clear: [name: string] }>()
15
+ const { inputFor } = useVectorInputs()
16
+ const wide = (field: VectorField) => ['markdown', 'richtext', 'text', 'json'].includes(field.type)
17
+
18
+ // Conditions (visibleWhen, requiredWhen, disabledWhen) follow the values as they are edited.
19
+ const states = computed<Record<string, VectorFieldState>>(() => Object.fromEntries(props.fields.map(f => [f.name, fieldState(f, props.values)])))
20
+ const shownFields = computed(() => props.fields.filter(f => states.value[f.name]?.visible !== false))
21
+
22
+ function set(name: string, value: unknown) {
23
+ // The parent owns the values object and hands it down to be filled in.
24
+ // eslint-disable-next-line vue/no-mutating-props
25
+ props.values[name] = value
26
+ emit('clear', name)
27
+ }
28
+ </script>
29
+
30
+ <template>
31
+ <v-row density="compact">
32
+ <v-col v-for="field in shownFields" :key="field.name" cols="12" :md="wide(field) || fields.length === 1 ? 12 : 6">
33
+ <component
34
+ :is="inputFor(field)"
35
+ :model-value="values[field.name]"
36
+ :field="field"
37
+ :disabled="states[field.name]?.disabled === true"
38
+ :rules="rulesFor(field, 'create', states[field.name])"
39
+ :error-messages="serverErrors[field.name] ?? []"
40
+ :record="values"
41
+ :resource="resource ?? ''"
42
+ @update:model-value="(v: unknown) => set(field.name, v)"
43
+ />
44
+ </v-col>
45
+ </v-row>
46
+ </template>
@@ -0,0 +1,131 @@
1
+ <script setup lang="ts">
2
+ import type { VectorImportMode, VectorImportPreview, VectorResource } from '#vector/app/types/vector'
3
+
4
+ // CSV import in two steps: upload for a preview and a suggested
5
+ // mapping, adjust the mapping, then queue the import and follow the job.
6
+ // An import creates records, or updates the records a key column finds
7
+ // (the key or a unique field), or does either row by row.
8
+ const props = defineProps<{ resource: VectorResource }>()
9
+ const emit = defineEmits<{ queued: [jobId: number], cancel: [] }>()
10
+
11
+ const appConfig = useAppConfig()
12
+ const client = useVectorResource(() => props.resource.slug)
13
+ const { t, noun } = useVectorT()
14
+ const notifications = useVectorNotifications()
15
+ const file = ref<File | null>(null)
16
+ const preview = ref<VectorImportPreview | null>(null)
17
+ const mapping = reactive<Record<string, string>>({})
18
+ const busy = ref(false)
19
+ const error = ref<string | null>(null)
20
+ const mode = ref<VectorImportMode>(props.resource.permissions.create ? 'create' : 'update')
21
+ const key = ref('')
22
+
23
+ const modes = computed(() => (['create', 'update', 'upsert'] as const)
24
+ .filter(m => m === 'create' ? props.resource.permissions.create : m === 'update' ? props.resource.permissions.update : props.resource.permissions.create && props.resource.permissions.update)
25
+ .map(m => ({ title: t(`list.import.modes.${m}`), value: m })))
26
+
27
+ // what the mode writes; an update also maps the column it matches rows by
28
+ const fields = computed(() => {
29
+ const views = mode.value === 'create' ? ['create'] : mode.value === 'update' ? ['update'] : ['create', 'update']
30
+ const writable = props.resource.fields.filter(f => !f.readonly && f.views.some(v => views.includes(v)) && !['id', 'hasOne', 'hasMany', 'computed'].includes(f.type))
31
+ const keys = mode.value === 'create' ? [] : keyFields.value.filter(f => !writable.includes(f))
32
+
33
+ return [...keys, ...writable].map(f => ({ title: f.label, value: f.name }))
34
+ })
35
+ const keyFields = computed(() => props.resource.fields.filter(f => f.name === props.resource.primaryKey.field || f.unique))
36
+ const keyChoices = computed(() => keyFields.value.filter(f => Object.values(mapping).includes(f.name)).map(f => ({ title: f.label, value: f.name })))
37
+
38
+ watch(keyChoices, choices => {
39
+ if (!choices.some(c => c.value === key.value)) key.value = choices[0]?.value ?? ''
40
+ })
41
+ // the suggestion maps the key column too; a mode that does not take a field skips its column
42
+ const suggested = ref<Record<string, string>>({})
43
+ watch(mode, applyMapping)
44
+
45
+ function applyMapping() {
46
+ const allowed = new Set(fields.value.map(f => f.value))
47
+ for (const key of Object.keys(mapping)) delete mapping[key]
48
+ for (const header of preview.value?.headers ?? []) {
49
+ const field = suggested.value[header] ?? ''
50
+ mapping[header] = allowed.has(field) ? field : ''
51
+ }
52
+ }
53
+
54
+ async function analyse() {
55
+ if (!file.value) return
56
+ busy.value = true
57
+ error.value = null
58
+
59
+ try {
60
+ preview.value = await client.importPreview(file.value)
61
+ suggested.value = preview.value.mapping
62
+ applyMapping()
63
+ } catch (caught) {
64
+ error.value = vectorErrorMessage(caught)
65
+ } finally {
66
+ busy.value = false
67
+ }
68
+ }
69
+
70
+ async function start() {
71
+ if (!file.value) return
72
+ busy.value = true
73
+ error.value = null
74
+
75
+ try {
76
+ const chosen = Object.fromEntries(Object.entries(mapping).filter(([, v]) => v))
77
+ const { jobId, rows } = await client.importCsv(file.value, chosen, mode.value, key.value)
78
+ notifications.success(t('list.import.queued', { rows }))
79
+ emit('queued', jobId)
80
+ await navigateTo(`${appConfig.vector.basePath}/jobs/${jobId}`)
81
+ } catch (caught) {
82
+ error.value = vectorErrorMessage(caught)
83
+ } finally {
84
+ busy.value = false
85
+ }
86
+ }
87
+ </script>
88
+
89
+ <template>
90
+ <v-dialog :model-value="true" max-width="720" persistent scrollable @update:model-value="emit('cancel')">
91
+ <v-card :title="t('list.import.title', { label: noun(resource.label) })">
92
+ <v-card-text>
93
+ <v-alert v-if="error" type="error" variant="tonal" class="mb-4" :text="error" />
94
+ <v-file-input v-model="file" :label="t('list.import.file')" accept=".csv,text/csv" prepend-icon="mdi-file-delimited-outline" :disabled="busy" @update:model-value="preview = null" />
95
+ <v-btn v-if="!preview" color="primary" :disabled="!file" :loading="busy" @click="analyse">{{ t('list.import.analyse') }}</v-btn>
96
+ <template v-else>
97
+ <p class="text-body-2 mb-3">{{ t('list.import.instructions', { rows: preview.rows, pipe: '|' }) }}</p>
98
+ <div class="d-flex flex-wrap ga-3 mb-3">
99
+ <v-select v-model="mode" :items="modes" :label="t('list.import.mode')" density="compact" hide-details style="min-width: 220px" />
100
+ <v-select
101
+ v-if="mode !== 'create'"
102
+ v-model="key"
103
+ :items="keyChoices"
104
+ :label="t('list.import.key')"
105
+ :no-data-text="t('list.import.noKey')"
106
+ density="compact"
107
+ hide-details
108
+ style="min-width: 220px"
109
+ />
110
+ </div>
111
+ <p v-if="mode !== 'create'" class="text-body-2 text-medium-emphasis mb-3">{{ t(`list.import.modeHelp.${mode}`) }}</p>
112
+ <v-table density="compact">
113
+ <thead><tr><th>{{ t('list.import.csvColumn') }}</th><th>{{ t('list.import.sample') }}</th><th style="width: 260px">{{ t('list.field') }}</th></tr></thead>
114
+ <tbody>
115
+ <tr v-for="header in preview.headers" :key="header">
116
+ <td>{{ header }}</td>
117
+ <td class="text-medium-emphasis text-truncate" style="max-width: 200px">{{ preview.sample[0]?.[header] ?? '' }}</td>
118
+ <td><v-select v-model="mapping[header]" :items="[{ title: t('list.import.skip'), value: '' }, ...fields]" density="compact" hide-details /></td>
119
+ </tr>
120
+ </tbody>
121
+ </v-table>
122
+ </template>
123
+ </v-card-text>
124
+ <v-card-actions>
125
+ <v-spacer />
126
+ <v-btn variant="text" :disabled="busy" @click="emit('cancel')">{{ t('common.cancel') }}</v-btn>
127
+ <v-btn v-if="preview" color="primary" :loading="busy" :disabled="mode !== 'create' && !key" @click="start">{{ t('list.import.start', { rows: preview.rows }) }}</v-btn>
128
+ </v-card-actions>
129
+ </v-card>
130
+ </v-dialog>
131
+ </template>
@@ -0,0 +1,49 @@
1
+ <script setup lang="ts">
2
+ // The bell: unread count polled every 30 seconds, the latest messages in a menu.
3
+ const appConfig = useAppConfig()
4
+ const inbox = useVectorNotificationsInbox()
5
+ const open = ref(false)
6
+ const { t } = useVectorT()
7
+
8
+ onMounted(() => inbox.refresh().catch(() => undefined))
9
+ const { pause } = useIntervalFn(() => inbox.refresh().catch(() => undefined), 30000)
10
+ onUnmounted(pause)
11
+ watch(open, o => { if (o) inbox.refresh().catch(() => undefined) })
12
+
13
+ async function go(id: number, link: string | null) {
14
+ await inbox.markRead(id)
15
+ open.value = false
16
+ if (link) navigateTo(`${appConfig.vector.basePath}${link}`)
17
+ }
18
+ </script>
19
+
20
+ <template>
21
+ <v-menu v-model="open" :close-on-content-click="false" location="bottom end" width="380">
22
+ <template #activator="{ props: activator }">
23
+ <v-btn v-bind="activator" icon variant="text" :aria-label="t('shell.inbox.title')">
24
+ <v-badge :content="inbox.unread.value" :model-value="inbox.unread.value > 0" color="error">
25
+ <v-icon icon="mdi-bell-outline" />
26
+ </v-badge>
27
+ </v-btn>
28
+ </template>
29
+ <v-card>
30
+ <v-card-title class="d-flex align-center text-subtitle-1">
31
+ {{ t('shell.inbox.title') }}
32
+ <v-spacer />
33
+ <v-btn v-if="inbox.unread.value" size="small" variant="text" @click="inbox.markAllRead()">{{ t('shell.inbox.markAllRead') }}</v-btn>
34
+ </v-card-title>
35
+ <v-divider />
36
+ <v-list density="compact" max-height="420" style="overflow-y: auto">
37
+ <v-list-item v-for="n in inbox.items.value" :key="n.id" :title="n.title" :subtitle="n.body ?? undefined" :class="{ 'bg-surface-light': !n.readAt }" @click="go(n.id, n.link)">
38
+ <template #prepend>
39
+ <v-icon :icon="{ info: 'mdi-information-outline', success: 'mdi-check-circle-outline', warning: 'mdi-alert-outline', error: 'mdi-alert-circle-outline' }[n.kind]" :color="n.kind === 'info' ? undefined : n.kind" />
40
+ </template>
41
+ <template #append>
42
+ <span class="text-caption text-medium-emphasis">{{ formatDateTime(n.createdAt) }}</span>
43
+ </template>
44
+ </v-list-item>
45
+ <v-list-item v-if="inbox.items.value.length === 0" :title="t('shell.inbox.empty')" disabled />
46
+ </v-list>
47
+ </v-card>
48
+ </v-menu>
49
+ </template>
@@ -0,0 +1,10 @@
1
+ <script setup lang="ts">
2
+ import type { VectorField } from '#vector/app/types/vector'
3
+
4
+ defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record?: Record<string, unknown>, resource?: string }>()
5
+ const model = defineModel<boolean | null>()
6
+ </script>
7
+
8
+ <template>
9
+ <v-switch v-model="model" :label="field.label" :error-messages="errorMessages" :hint="field.help ?? undefined" :persistent-hint="!!field.help" color="primary" inset />
10
+ </template>
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ import type { VectorField } from '#vector/app/types/vector'
3
+
4
+ // A currency chosen from the field's codes, each named in the UI's
5
+ // language and searchable by code or name.
6
+ const props = defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record?: Record<string, unknown>, resource?: string }>()
7
+ const model = defineModel<string | null>()
8
+ const { locale } = useVectorT()
9
+
10
+ const items = computed(() => (props.field.options ?? []).map(o => ({ value: String(o.value), title: currencyLabel(String(o.value), locale.value) })))
11
+ </script>
12
+
13
+ <template>
14
+ <v-autocomplete
15
+ v-model="model"
16
+ :label="field.label"
17
+ :items="items"
18
+ :rules="rules"
19
+ :error-messages="errorMessages"
20
+ :clearable="field.nullable === true"
21
+ :hint="field.help ?? undefined"
22
+ :persistent-hint="!!field.help"
23
+ auto-select-first
24
+ />
25
+ </template>
@@ -0,0 +1,38 @@
1
+ <script setup lang="ts">
2
+ import type { VectorField } from '#vector/app/types/vector'
3
+
4
+ // A native date/datetime control: the wire value is ISO 8601 in UTC, the
5
+ // control edits local time, and the conversion lives here.
6
+ const props = defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record?: Record<string, unknown>, resource?: string }>()
7
+ const model = defineModel<string | null>()
8
+ const dateOnly = computed(() => props.field.type === 'date')
9
+
10
+ function pad(n: number) { return String(n).padStart(2, '0') }
11
+
12
+ const local = computed({
13
+ get: () => {
14
+ if (!model.value) return ''
15
+ if (dateOnly.value) return model.value.slice(0, 10)
16
+ const d = new Date(model.value)
17
+ if (Number.isNaN(d.getTime())) return ''
18
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
19
+ },
20
+ set: (value: string) => {
21
+ if (!value) { model.value = null; return }
22
+ model.value = dateOnly.value ? value : new Date(value).toISOString()
23
+ },
24
+ })
25
+ </script>
26
+
27
+ <template>
28
+ <v-text-field
29
+ v-model="local"
30
+ :label="field.label"
31
+ :type="dateOnly ? 'date' : 'datetime-local'"
32
+ :rules="rules"
33
+ :error-messages="errorMessages"
34
+ :clearable="field.nullable === true"
35
+ :hint="field.help ?? undefined"
36
+ :persistent-hint="!!field.help"
37
+ />
38
+ </template>
@@ -0,0 +1,21 @@
1
+ <script setup lang="ts">
2
+ import type { VectorField } from '#vector/app/types/vector'
3
+
4
+ defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record?: Record<string, unknown>, resource?: string }>()
5
+ const model = defineModel<string | number | null>()
6
+ </script>
7
+
8
+ <template>
9
+ <v-select
10
+ v-model="model"
11
+ :label="field.label"
12
+ :items="field.options ?? []"
13
+ item-title="label"
14
+ item-value="value"
15
+ :rules="rules"
16
+ :error-messages="errorMessages"
17
+ :clearable="field.nullable === true"
18
+ :hint="field.help ?? undefined"
19
+ :persistent-hint="!!field.help"
20
+ />
21
+ </template>
@@ -0,0 +1,78 @@
1
+ <script setup lang="ts">
2
+ import { fieldProps, type VectorField } from '#vector/app/types/vector'
3
+
4
+ // A key-value field's entries as rows of two text inputs. The rows are the
5
+ // editing state — a row may be half typed — and the model is the object
6
+ // they make: a row without a key is left out, a repeated key's last row wins.
7
+ const props = defineProps<{ field: VectorField, rules?: unknown[], errorMessages: string[], record?: Record<string, unknown>, resource?: string, disabled?: boolean }>()
8
+ const model = defineModel<Record<string, string> | null>()
9
+
10
+ const { t } = useVectorT()
11
+ const rows = ref<{ key: string, value: string }[]>([])
12
+ const max = computed(() => Number(fieldProps(props.field).maxEntries ?? 50))
13
+ const keyLabel = computed(() => String(fieldProps(props.field).keyLabel ?? t('fields.keyValue.key')))
14
+ const valueLabel = computed(() => String(fieldProps(props.field).valueLabel ?? t('fields.keyValue.value')))
15
+
16
+ // Adopt a value set from outside (a record loaded), not the one these rows
17
+ // just made — which comes back as the form's reactive copy, so by content.
18
+ let emitted = ''
19
+ watch(model, (value) => {
20
+ if (JSON.stringify(value ?? {}) === emitted) return
21
+ rows.value = value && typeof value === 'object' ? Object.entries(value).map(([key, v]) => ({ key, value: String(v ?? '') })) : []
22
+ }, { immediate: true })
23
+
24
+ function sync() {
25
+ const made = Object.fromEntries(rows.value.filter(r => r.key.trim() !== '').map(r => [r.key.trim(), r.value]))
26
+ emitted = JSON.stringify(made)
27
+ model.value = made
28
+ }
29
+
30
+ function add() {
31
+ rows.value = [...rows.value, { key: '', value: '' }]
32
+ }
33
+
34
+ function remove(index: number) {
35
+ rows.value = rows.value.filter((_, i) => i !== index)
36
+ sync()
37
+ }
38
+
39
+ const repeated = computed(() => {
40
+ const seen = new Set<string>()
41
+ const twice = new Set<string>()
42
+ for (const row of rows.value) {
43
+ const key = row.key.trim()
44
+ if (key && seen.has(key)) twice.add(key)
45
+ seen.add(key)
46
+ }
47
+ return [...twice]
48
+ })
49
+ const messages = computed(() => [...(repeated.value.length ? [t('fields.keyValue.repeated', { keys: repeated.value.join(', ') })] : []), ...props.errorMessages])
50
+ </script>
51
+
52
+ <template>
53
+ <div role="group" :aria-label="field.label">
54
+ <div class="text-body-2 text-medium-emphasis mb-1">{{ field.label }}</div>
55
+ <div v-for="(row, index) in rows" :key="index" class="d-flex ga-2 align-start mb-1" role="group" :aria-label="t('fields.keyValue.entry', { n: index + 1 })">
56
+ <v-text-field
57
+ v-model="row.key"
58
+ :label="keyLabel"
59
+ density="compact"
60
+ hide-details
61
+ :disabled="disabled"
62
+ @update:model-value="sync"
63
+ />
64
+ <v-text-field
65
+ v-model="row.value"
66
+ :label="valueLabel"
67
+ density="compact"
68
+ hide-details
69
+ :disabled="disabled"
70
+ @update:model-value="sync"
71
+ />
72
+ <v-btn icon="mdi-close" variant="text" size="small" class="mt-1" :disabled="disabled" :aria-label="t('fields.keyValue.remove', { n: index + 1 })" @click="remove(index)" />
73
+ </div>
74
+ <v-btn variant="text" size="small" prepend-icon="mdi-plus" :disabled="disabled || rows.length >= max" @click="add">{{ t('fields.keyValue.add') }}</v-btn>
75
+ <div v-for="message in messages" :key="message" class="text-error text-caption" role="alert">{{ message }}</div>
76
+ <div v-if="field.help" class="text-caption text-medium-emphasis">{{ field.help }}</div>
77
+ </div>
78
+ </template>