@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,143 @@
1
+ <script setup lang="ts">
2
+ import type { VectorAction } from '#vector/app/types/vector'
3
+
4
+ definePageMeta({ layout: 'vector', middleware: 'vector-auth', key: route => route.fullPath })
5
+
6
+ const { t, noun } = useVectorT()
7
+ const route = useRoute()
8
+ const appConfig = useAppConfig()
9
+ const { load, resource: find } = useVectorSchema()
10
+ await load()
11
+
12
+ const slug = computed(() => String(route.params.resource))
13
+ const id = computed(() => String(route.params.id))
14
+ const resource = computed(() => find(slug.value))
15
+ const client = useVectorResource(slug)
16
+ const base = computed(() => `${appConfig.vector.basePath}/r/${slug.value}`)
17
+ const { confirm } = useConfirm()
18
+ const notifications = useVectorNotifications()
19
+
20
+ const { data: response, status, error, refresh } = await useAsyncData(() => `vector.${slug.value}.${id.value}`, () => client.show(id.value))
21
+
22
+ const title = computed(() => {
23
+ const value = response.value?.data?.[resource.value?.titleField ?? 'id']
24
+ return value === undefined || value === null ? `#${id.value}` : String(value)
25
+ })
26
+
27
+ const trashed = computed(() => response.value?.data?._trashed === true)
28
+ const { schema } = useVectorSchema()
29
+ const impersonation = useVectorImpersonation()
30
+ const canImpersonate = computed(() => appConfig.vector.usersResource === slug.value && schema.value?.capabilities.impersonation === true && String(response.value?.data.id) !== String(useVectorAuthStore().user?.id))
31
+
32
+ async function impersonate() {
33
+ if (!resource.value || !response.value) return
34
+
35
+ if (!await confirm({ title: t('record.impersonate.title', { title: title.value }), text: t('record.impersonate.text'), confirmText: t('record.impersonate.action') })) return
36
+
37
+ try {
38
+ await impersonation.start(response.value.data.id)
39
+ notifications.success(t('record.impersonate.started', { title: title.value }))
40
+ await navigateTo(appConfig.vector.basePath || '/')
41
+ } catch (caught) {
42
+ notifications.error(vectorErrorMessage(caught))
43
+ }
44
+ }
45
+ // Every collection the show page shows gets a relation panel.
46
+ const collections = computed(() => (resource.value?.fields ?? []).filter(f => (f.type === 'hasMany' || f.type === 'manyToMany') && f.views.includes('show')))
47
+ const actions = computed<VectorAction[]>(() => (resource.value?.actions ?? []).filter(a => a.showOnDetail && !a.standalone && (response.value?.permissions.actions ?? []).includes(a.name)))
48
+ const dialogAction = ref<VectorAction | null>(null)
49
+
50
+ useHead({ title: () => resource.value ? `${title.value} · ${resource.value.label}` : '' })
51
+
52
+ async function remove(force = false) {
53
+ if (!resource.value) return
54
+ const soft = resource.value.softDelete !== null && !force
55
+
56
+ const ok = await confirm({
57
+ title: t(force ? 'record.deleteForeverTitle' : 'record.deleteTitle', { noun: noun(resource.value.labelSingular), title: title.value }),
58
+ text: soft ? t('record.deleteSoftText') : t('record.deleteHardText'),
59
+ confirmText: t('common.delete'),
60
+ destructive: true,
61
+ })
62
+
63
+ if (!ok) return
64
+
65
+ try {
66
+ if (force) await client.forceRemove(id.value)
67
+ else await client.remove(id.value)
68
+ notifications.success(t(soft ? 'record.trashed' : 'record.deleted', { label: resource.value.labelSingular }))
69
+
70
+ if (soft) await refresh()
71
+ else await navigateTo(base.value)
72
+ } catch (caught) {
73
+ notifications.error(vectorErrorMessage(caught))
74
+ }
75
+ }
76
+
77
+ async function restore() {
78
+ if (!resource.value) return
79
+
80
+ try {
81
+ await client.restore(id.value)
82
+ notifications.success(t('record.restored', { label: resource.value.labelSingular }))
83
+ await refresh()
84
+ } catch (caught) {
85
+ notifications.error(vectorErrorMessage(caught))
86
+ }
87
+ }
88
+ </script>
89
+
90
+ <template>
91
+ <div v-if="resource">
92
+ <v-breadcrumbs class="pa-0 mb-2" :items="[{ title: resource.label, to: base }, { title }]" />
93
+ <div class="d-flex align-center flex-wrap ga-2 mb-4">
94
+ <h1 class="text-h4">{{ title }}</h1>
95
+ <v-chip size="small" variant="tonal">{{ resource.labelSingular }}</v-chip>
96
+ <v-chip v-if="trashed" size="small" color="error" variant="tonal" prepend-icon="mdi-delete-clock-outline">{{ t('record.inTrash') }}</v-chip>
97
+ <v-spacer />
98
+ <template v-if="response">
99
+ <v-menu v-if="actions.length">
100
+ <template #activator="{ props: activator }">
101
+ <v-btn v-bind="activator" variant="tonal" append-icon="mdi-menu-down">{{ t('common.actions') }}</v-btn>
102
+ </template>
103
+ <v-list density="compact">
104
+ <v-list-item v-for="action in actions" :key="action.name" :title="action.label" :prepend-icon="action.icon ?? undefined" :base-color="action.destructive ? 'error' : undefined" @click="dialogAction = action" />
105
+ </v-list>
106
+ </v-menu>
107
+ <v-btn v-if="canImpersonate" prepend-icon="mdi-account-switch-outline" variant="tonal" @click="impersonate">{{ t('record.impersonate.action') }}</v-btn>
108
+ <v-btn v-if="resource.permissions.create && resource.replicable !== false && !trashed" :to="`${base}/create?from=${encodeURIComponent(id)}`" prepend-icon="mdi-content-copy" variant="tonal">{{ t('record.duplicate') }}</v-btn>
109
+ <v-btn v-if="response.permissions.update" :to="`${base}/${id}/edit`" prepend-icon="mdi-pencil-outline" color="primary">{{ t('common.edit') }}</v-btn>
110
+ <v-btn v-if="response.permissions.restore" prepend-icon="mdi-restore" color="primary" variant="tonal" @click="restore">{{ t('common.restore') }}</v-btn>
111
+ <v-btn v-if="response.permissions.forceDelete" prepend-icon="mdi-delete-forever-outline" color="error" variant="tonal" @click="remove(true)">{{ t('common.deleteForever') }}</v-btn>
112
+ <v-btn v-if="response.permissions.delete" prepend-icon="mdi-delete-outline" color="error" variant="tonal" @click="remove()">{{ t('common.delete') }}</v-btn>
113
+ </template>
114
+ </div>
115
+
116
+ <v-skeleton-loader v-if="status === 'pending'" type="article" />
117
+ <v-alert v-else-if="error" type="error" variant="tonal" :text="vectorErrorMessage(error)" />
118
+ <template v-else-if="response">
119
+ <VxResourceShow :resource="resource" :record="response.data" />
120
+ <VxResourceRelationPanel
121
+ v-for="field in collections"
122
+ :key="field.name"
123
+ :owner="resource"
124
+ :record="response.data"
125
+ :field="field"
126
+ :can-update="response.permissions.update"
127
+ @changed="refresh()"
128
+ />
129
+ <VxResourceAudit :id="id" :resource="resource" :can-update="response.permissions.update" @reverted="refresh()" />
130
+ </template>
131
+
132
+ <VxActionDialog
133
+ v-if="dialogAction && response"
134
+ :resource="resource"
135
+ :action="dialogAction"
136
+ :selection="{ ids: [response.data.id] }"
137
+ :count="1"
138
+ @done="dialogAction = null; refresh()"
139
+ @cancel="dialogAction = null"
140
+ />
141
+ </div>
142
+ <v-empty-state v-else icon="mdi-help-circle-outline" :title="t('record.unknownResource')" />
143
+ </template>
@@ -0,0 +1,53 @@
1
+ <script setup lang="ts">
2
+ import type { VectorRecord } from '#vector/app/types/vector'
3
+ import type { SaveVariant } from '#vector/app/components/Vx/Resource/Form.vue'
4
+
5
+ definePageMeta({ layout: 'vector', middleware: 'vector-auth', key: route => route.fullPath })
6
+
7
+ const { t, noun } = useVectorT()
8
+ const route = useRoute()
9
+ const appConfig = useAppConfig()
10
+ const { load, resource: find } = useVectorSchema()
11
+ await load()
12
+
13
+ const slug = computed(() => String(route.params.resource))
14
+ const resource = computed(() => find(slug.value))
15
+
16
+ // ?from={id}: a copy of that record (Duplicate on its page)
17
+ const from = computed(() => typeof route.query.from === 'string' && route.query.from !== '' ? route.query.from : null)
18
+ const client = useVectorResource(slug)
19
+ const { data: source, error: sourceError } = await useAsyncData(() => `vector.${slug.value}.copy.${from.value}`, () => from.value ? client.show(from.value) : Promise.resolve(null))
20
+ const base = computed(() => `${appConfig.vector.basePath}/r/${slug.value}`)
21
+
22
+ useHead({ title: () => resource.value ? `${t('record.newTitle', { label: noun(resource.value.labelSingular) })} · ${resource.value.label}` : '' })
23
+
24
+ const form = ref<{ dirty: boolean, reset: () => void } | null>(null)
25
+ useLeaveGuard(() => form.value?.dirty === true)
26
+
27
+ function saved(record: VectorRecord, variant: SaveVariant) {
28
+ if (variant === 'another') {
29
+ // another one starts empty, even after a copy
30
+ if (from.value) return navigateTo(`${base.value}/create`)
31
+ form.value?.reset()
32
+ window.scrollTo({ top: 0 })
33
+ return
34
+ }
35
+
36
+ const at = `${base.value}/${encodeURIComponent(String(record.id))}`
37
+ navigateTo(variant === 'continue' ? `${at}/edit` : at)
38
+ }
39
+ </script>
40
+
41
+ <template>
42
+ <div v-if="resource">
43
+ <v-breadcrumbs class="pa-0 mb-2" :items="[{ title: resource.label, to: base }, { title: t('record.new') }]" />
44
+ <h1 class="text-h4 mb-4">{{ t('record.newTitle', { label: noun(resource.labelSingular) }) }}</h1>
45
+ <v-alert v-if="!resource.permissions.create" type="warning" variant="tonal" :text="t('record.createNotAllowed')" />
46
+ <v-alert v-else-if="sourceError" type="error" variant="tonal" :text="vectorErrorMessage(sourceError)" />
47
+ <template v-else>
48
+ <v-alert v-if="source" type="info" variant="tonal" density="compact" class="mb-4" :text="t('record.copyOf', { title: String(source.data[resource.titleField] ?? `#${source.data.id}`) })" />
49
+ <VxResourceForm ref="form" :resource="resource" operation="create" :copy-of="source?.data" variants @saved="saved" @cancel="navigateTo(base)" />
50
+ </template>
51
+ </div>
52
+ <v-empty-state v-else icon="mdi-help-circle-outline" :title="t('record.unknownResource')" />
53
+ </template>
@@ -0,0 +1,29 @@
1
+ <script setup lang="ts">
2
+ definePageMeta({ layout: 'vector', middleware: 'vector-auth', key: route => route.fullPath })
3
+
4
+ const route = useRoute()
5
+ const { load, resource: find } = useVectorSchema()
6
+ await load()
7
+
8
+ const { t, noun } = useVectorT()
9
+ const appConfig = useAppConfig()
10
+ const slug = computed(() => String(route.params.resource))
11
+ const resource = computed(() => find(slug.value))
12
+
13
+ useHead({ title: () => resource.value?.label ?? '' })
14
+ </script>
15
+
16
+ <template>
17
+ <div v-if="resource">
18
+ <div class="d-flex align-center mb-4">
19
+ <v-icon v-if="resource.icon" :icon="resource.icon" class="me-2" />
20
+ <h1 class="text-h4">{{ resource.label }}</h1>
21
+ <v-spacer />
22
+ <v-btn v-if="resource.permissions.create" :to="`${appConfig.vector.basePath}/r/${resource.slug}/create`" prepend-icon="mdi-plus" color="primary">
23
+ {{ t('list.page.create', { noun: noun(resource.labelSingular) }) }}
24
+ </v-btn>
25
+ </div>
26
+ <VxResourceTable :key="resource.slug" :resource="resource" />
27
+ </div>
28
+ <v-empty-state v-else icon="mdi-help-circle-outline" :title="t('list.page.unknown')" :text="t('list.page.unknownText', { slug })" />
29
+ </template>
@@ -0,0 +1,104 @@
1
+ <script setup lang="ts">
2
+ import { languageName } from '#vector/app/i18n'
3
+ import type { SubmitEventPromise } from 'vuetify'
4
+ import type { VectorSettingsGroup } from '#vector/app/types/vector'
5
+
6
+ definePageMeta({ layout: 'vector', middleware: 'vector-auth' })
7
+
8
+ // Every settings group this user may manage, one tab each, rendered
9
+ // from the group's field list through the same registry as a form.
10
+ const api = useVectorApi()
11
+ const notifications = useVectorNotifications()
12
+ const { t } = useVectorT()
13
+ const { load: loadSchema } = useVectorSchema()
14
+
15
+ // fetched again in a new language, whose labels the server translates
16
+ const { current: locale } = useVectorLocale()
17
+ const { data, refresh } = await useAsyncData('vector.settings', () => api<{ data: VectorSettingsGroup[] }>('/vector/settings'), { watch: [locale] })
18
+ // Vector's own Languages group: each language keeps its own name, with its name in the admin's language beneath
19
+ const groups = computed(() => (data.value?.data ?? []).map(group => group.name === 'languages' ? withLanguageNames(group, locale.value) : group))
20
+ // what each group held when it was loaded: a group edited since keeps its edits when the page is fetched again
21
+ const loaded: Record<string, string> = {}
22
+ const tab = ref<string | null>(null)
23
+ const values = reactive<Record<string, Record<string, unknown>>>({})
24
+ const serverErrors = reactive<Record<string, Record<string, string[]>>>({})
25
+ const formError = ref<string | null>(null)
26
+ const saving = ref(false)
27
+
28
+ watch(groups, (list) => {
29
+ for (const group of list) {
30
+ const edited = values[group.name] !== undefined && loaded[group.name] !== undefined && JSON.stringify(values[group.name]) !== loaded[group.name]
31
+ if (!edited) values[group.name] = { ...group.values }
32
+ loaded[group.name] = JSON.stringify(group.values)
33
+ serverErrors[group.name] ??= {}
34
+ }
35
+ tab.value ??= list[0]?.name ?? null
36
+ }, { immediate: true })
37
+
38
+ useHead({ title: () => t('settings.pageTitle') })
39
+
40
+ async function save(group: VectorSettingsGroup, event: SubmitEventPromise) {
41
+ const { valid } = await event
42
+ if (!valid) return
43
+
44
+ saving.value = true
45
+ formError.value = null
46
+ const errors = serverErrors[group.name]!
47
+ for (const key of Object.keys(errors)) delete errors[key]
48
+
49
+ try {
50
+ await api(`/vector/settings/${encodeURIComponent(group.name)}`, { method: 'PUT', body: values[group.name] })
51
+ delete loaded[group.name] // saved: the server's answer replaces the form, normalised as stored
52
+ notifications.success(t('settings.saved', { label: group.label }))
53
+ await refresh()
54
+ await loadSchema(true) // branding rides with the descriptor
55
+ } catch (error) {
56
+ formError.value = applyViolations(error, values[group.name]!, errors)
57
+ } finally {
58
+ saving.value = false
59
+ }
60
+ }
61
+
62
+ /** The Languages group as shown: a switch's hint and the default's options name each language in the admin's language too. */
63
+ function withLanguageNames(group: VectorSettingsGroup, inLanguage: string): VectorSettingsGroup {
64
+ return {
65
+ ...group,
66
+ fields: group.fields.map((field) => {
67
+ if (field.type === 'boolean') {
68
+ return { ...field, help: languageName(field.name, inLanguage, field.label) ?? field.help }
69
+ }
70
+ if (field.name === 'default' && field.options) {
71
+ return { ...field, options: field.options.map(option => ({ ...option, label: [option.label, languageName(String(option.value), inLanguage, option.label)].filter(Boolean).join(' — ') })) }
72
+ }
73
+ return field
74
+ }),
75
+ }
76
+ }
77
+ </script>
78
+
79
+ <template>
80
+ <div>
81
+ <h1 class="text-h4 mb-4">{{ t('settings.heading') }}</h1>
82
+ <v-empty-state v-if="groups.length === 0" icon="mdi-cog-off-outline" :title="t('settings.empty.title')" :text="t('settings.empty.text')" />
83
+ <v-card v-else>
84
+ <v-tabs v-model="tab" color="primary">
85
+ <v-tab v-for="group in groups" :key="group.name" :value="group.name" :prepend-icon="group.icon ?? undefined">{{ group.label }}</v-tab>
86
+ </v-tabs>
87
+ <v-divider />
88
+ <v-tabs-window v-model="tab">
89
+ <v-tabs-window-item v-for="group in groups" :key="group.name" :value="group.name">
90
+ <v-form validate-on="blur lazy" @submit.prevent="(e: SubmitEventPromise) => save(group, e)">
91
+ <v-card-text>
92
+ <v-alert v-if="formError && tab === group.name" type="error" variant="tonal" class="mb-4" :text="formError" />
93
+ <VxFieldsForm v-if="values[group.name]" :fields="group.fields" :values="values[group.name]!" :server-errors="serverErrors[group.name] ?? {}" @clear="(name: string) => delete serverErrors[group.name]![name]" />
94
+ </v-card-text>
95
+ <v-card-actions>
96
+ <v-spacer />
97
+ <v-btn type="submit" color="primary" :loading="saving">{{ t('common.save') }}</v-btn>
98
+ </v-card-actions>
99
+ </v-form>
100
+ </v-tabs-window-item>
101
+ </v-tabs-window>
102
+ </v-card>
103
+ </div>
104
+ </template>
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Hosted under a strict Content-Security-Policy, the shell hands the
3
+ * bundle the response's nonce; Vuetify puts it on the style element it
4
+ * creates for the theme, so `style-src 'nonce-…'` admits it.
5
+ */
6
+ export default defineNuxtPlugin((nuxtApp) => {
7
+ nuxtApp.hook('vuetify:before-create', ({ vuetifyOptions }) => {
8
+ const nonce = window.__VECTOR__?.cspNonce
9
+
10
+ if (nonce) {
11
+ vuetifyOptions.theme = { ...(typeof vuetifyOptions.theme === 'object' ? vuetifyOptions.theme : {}), cspNonce: nonce }
12
+ }
13
+ })
14
+ })
@@ -0,0 +1,84 @@
1
+ import * as vue from 'vue'
2
+ import { Extension as TiptapExtension, Mark as TiptapMark, Node as TiptapNode, mergeAttributes } from '@tiptap/core'
3
+ import { VAlert, VAutocomplete, VBtn, VCard, VCheckbox, VChip, VIcon, VProgressLinear, VRating, VSelect, VSwitch, VTable, VTextField, VTextarea } from 'vuetify/components'
4
+
5
+ /**
6
+ * Runtime UI extensions (§4.15 tier 2). `window.Vector` is the
7
+ * registration API an extension module calls; the URLs come from the
8
+ * hosted shell (`VECTOR_UI_EXTENSIONS`) or from runtime config in
9
+ * standalone mode, and are imported after the app is created. Vue and a
10
+ * curated set of Vuetify components are handed over so an extension
11
+ * needs no bundler and no copy of either library.
12
+ */
13
+ export default defineNuxtPlugin(() => {
14
+ const registries = useVectorExtensions()
15
+ const i18n = useNuxtApp().$vectorI18n
16
+ const api$ = useVectorApi()
17
+ const notifications = useVectorNotifications()
18
+ const appConfig = useAppConfig()
19
+ const config = useRuntimeConfig()
20
+ // VTable for a custom page's tables: its wrapper, not a bare <table>, carries Vuetify's table styles
21
+ const vuetify = { VAlert, VAutocomplete, VBtn, VCard, VCheckbox, VChip, VIcon, VProgressLinear, VRating, VSelect, VSwitch, VTable, VTextField, VTextarea }
22
+
23
+ const api = {
24
+ version: 1,
25
+ vue,
26
+ vuetify,
27
+ // what a rich-text extension is built from, so one needs no bundler of its own
28
+ tiptap: { Node: TiptapNode, Mark: TiptapMark, Extension: TiptapExtension, mergeAttributes },
29
+ defineCell: (name: string, component: vue.Component) => registries.cells.set(name, vue.markRaw(component)),
30
+ defineInput: (name: string, component: vue.Component) => registries.inputs.set(name, vue.markRaw(component)),
31
+ defineWidget: (type: string, component: vue.Component) => registries.widgets.set(type, vue.markRaw(component)),
32
+ /**
33
+ * A rich-text node or mark type (a Tiptap extension) a field's `features` may then name. The server
34
+ * accepts it only when the application registered the same name with Document::extend().
35
+ */
36
+ defineRichTextExtension: (name: string, extension: import('@tiptap/core').AnyExtension) => registries.richText.set(name, vue.markRaw(extension)),
37
+ /** Adds or overrides UI messages for a locale: defineMessages('de', { common: { save: 'Speichern' } }). */
38
+ defineMessages: (locale: string, messages: Record<string, unknown>) => i18n.global.mergeLocaleMessage(locale, messages),
39
+ /** A custom page at `/p/{name}`, listed in the menu: definePage('stock', { title: 'Stock', icon: 'mdi-warehouse', component }). */
40
+ definePage: (name: string, page: { title: string, icon?: string, group?: string, component: vue.Component }) => {
41
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new Error(`Vector page name "${name}" must be lowercase letters, digits and dashes.`)
42
+ registries.pages.set(name, { ...page, component: vue.markRaw(page.component) })
43
+ },
44
+ /** The admin's API client, with its credentials, for extensions calling the application's own endpoints. */
45
+ api: api$,
46
+ notify: {
47
+ success: (message: string) => notifications.success(message),
48
+ error: (message: string) => notifications.error(message),
49
+ },
50
+ navigate: (path: string) => navigateTo(`${appConfig.vector.basePath}${path}`),
51
+ /** The admin's translator, for an extension's own messages (defineMessages) or the admin's. */
52
+ t: (key: string, params: Record<string, unknown> = {}, plural?: number) => vt(key, params, plural),
53
+ }
54
+
55
+ window.Vector = api
56
+
57
+ const urls = [...(window.__VECTOR__?.extensions ?? []), ...((config.public.vector as { extensions?: string[] }).extensions ?? [])]
58
+
59
+ Promise.allSettled(urls.map(url => import(/* @vite-ignore */ url)
60
+ .then(module => (typeof module.default === 'function' ? module.default(api) : undefined))
61
+ .catch(error => console.error(`Vector extension ${url} failed to load`, error))))
62
+ .then(() => { registries.ready.value = true })
63
+ })
64
+
65
+ declare global {
66
+ interface Window {
67
+ Vector?: {
68
+ version: number
69
+ vue: typeof vue
70
+ vuetify: Record<string, vue.Component>
71
+ tiptap: { Node: typeof TiptapNode, Mark: typeof TiptapMark, Extension: typeof TiptapExtension, mergeAttributes: typeof mergeAttributes }
72
+ defineCell: (name: string, component: vue.Component) => void
73
+ defineInput: (name: string, component: vue.Component) => void
74
+ defineWidget: (type: string, component: vue.Component) => void
75
+ defineRichTextExtension: (name: string, extension: import('@tiptap/core').AnyExtension) => void
76
+ defineMessages: (locale: string, messages: Record<string, unknown>) => void
77
+ definePage: (name: string, page: { title: string, icon?: string, group?: string, component: vue.Component }) => void
78
+ api: import('nitropack').$Fetch
79
+ notify: { success: (message: string) => void, error: (message: string) => void }
80
+ navigate: (path: string) => unknown
81
+ t: (key: string, params?: Record<string, unknown>, plural?: number) => string
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,76 @@
1
+ import { createI18n } from 'vue-i18n'
2
+ import type { VectorI18n } from '#vector/app/utils/translate'
3
+ import { bundledMessages, isRtl, pluralRule, rtlLanguages, vuetifyLocale } from '#vector/app/i18n'
4
+
5
+ /**
6
+ * The admin's own strings, translated through vue-i18n, in the language
7
+ * the user chose. The descriptor's labels arrive translated by the server.
8
+ *
9
+ * The UI ships English and sixteen machine-translated locales. It starts in
10
+ * the hosted shell's language (the server's answer for this user), else
11
+ * the one this browser last used, else app config `vector.locale`, else
12
+ * English; the descriptor then confirms it, and the language menu in the
13
+ * app bar changes it (useVectorLocale). A key a locale lacks falls back to
14
+ * English. An application adds or overrides messages in app config
15
+ * `vector.messages` ({ de: { common: { save: 'Speichern' } } }), and a
16
+ * runtime extension through `window.Vector.defineMessages()`. Vuetify's
17
+ * own component strings and the page's direction follow the same locale.
18
+ */
19
+ export default defineNuxtPlugin({
20
+ name: 'vector-i18n',
21
+ enforce: 'pre',
22
+ setup(nuxtApp) {
23
+ const appConfig = useAppConfig()
24
+ const hosted = import.meta.client ? window.__VECTOR__?.locale : undefined
25
+ let remembered: string | null = null
26
+ try {
27
+ remembered = import.meta.client ? window.localStorage.getItem('vector.locale') : null
28
+ } catch {
29
+ // storage may be unavailable (a private window, blocked site data)
30
+ }
31
+ const locale = hosted || remembered || appConfig.vector.locale || 'en'
32
+
33
+ // each language's own plural forms (pluralCategories): one, few, many for Russian; one form for Japanese
34
+ const locales = [...new Set([...Object.keys(bundledMessages), ...Object.keys(appConfig.vector.messages ?? {}), locale])]
35
+
36
+ const i18n = createI18n({
37
+ legacy: false,
38
+ locale,
39
+ fallbackLocale: 'en',
40
+ messages: { ...bundledMessages },
41
+ pluralRules: Object.fromEntries(locales.map(code => [code, pluralRule(code)])),
42
+ missingWarn: false,
43
+ fallbackWarn: false,
44
+ })
45
+
46
+ for (const [code, messages] of Object.entries(appConfig.vector.messages ?? {})) {
47
+ i18n.global.mergeLocaleMessage(code, messages as Record<string, unknown>)
48
+ }
49
+
50
+ nuxtApp.vueApp.use(i18n)
51
+ const vectorI18n = i18n as VectorI18n
52
+ setVectorI18n(vectorI18n)
53
+ setFormatLocale(locale)
54
+
55
+ if (import.meta.client) {
56
+ document.documentElement.lang = locale
57
+ document.documentElement.dir = isRtl(locale) ? 'rtl' : 'ltr'
58
+ }
59
+
60
+ // Vuetify's component strings (pagination, "No data available", …) for every language it knows, switched at runtime.
61
+ nuxtApp.hook('vuetify:before-create', async ({ vuetifyOptions }) => {
62
+ const all = await import('vuetify/locale') as Record<string, unknown>
63
+ const messages = Object.fromEntries(Object.entries(all).filter(([, value]) => value && typeof value === 'object' && !Array.isArray(value)))
64
+ const current = messages[vuetifyLocale(locale)] ? vuetifyLocale(locale) : (messages[locale.split('-')[0]!] ? locale.split('-')[0]! : 'en')
65
+ vuetifyOptions.locale = {
66
+ ...(typeof vuetifyOptions.locale === 'object' ? vuetifyOptions.locale : {}),
67
+ locale: current,
68
+ fallback: 'en',
69
+ messages: messages as never,
70
+ rtl: Object.fromEntries(rtlLanguages.map(code => [code, true])),
71
+ }
72
+ })
73
+
74
+ return { provide: { vectorI18n } }
75
+ },
76
+ })