@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,238 @@
1
+ <script setup lang="ts">
2
+ import { useTheme } from 'vuetify'
3
+ import { languageName } from '#vector/app/i18n'
4
+
5
+ const appConfig = useAppConfig()
6
+ const auth = useVectorAuthStore()
7
+ const theme = useTheme()
8
+ const drawer = ref<boolean | null>(null)
9
+ const { schema, menu, load, clear } = useVectorSchema()
10
+ const { branding, apply } = useVectorBranding()
11
+ const palette = ref<{ open: boolean } | null>(null)
12
+ const { t, noun } = useVectorT()
13
+
14
+ apply() // hosted: branding is known before the descriptor loads
15
+ await load()
16
+ apply()
17
+ watch(branding, apply)
18
+
19
+ // the user's language: the descriptor names it and the ones on offer; the app bar switches it.
20
+ // Followed on every reload of the descriptor: saving the Languages settings changes what is on offer,
21
+ // and switching off the language in use moves the user to another.
22
+ const language = useVectorLocale()
23
+ watch(() => schema.value?.i18n, i18n => language.adopt(i18n), { immediate: true })
24
+ const currentLanguage = computed(() => language.locales.value.find(l => l.code === language.current.value) ?? null)
25
+ async function chooseLanguage(code: string) {
26
+ try {
27
+ await language.choose(code)
28
+ } catch (caught) {
29
+ useVectorNotifications().error(t('shell.languageFailed', { error: vectorErrorMessage(caught) }))
30
+ // refused — say, switched off since this menu was loaded: the descriptor says what is on offer now
31
+ await load(true).catch(() => undefined)
32
+ }
33
+ }
34
+
35
+ useHead({ titleTemplate: title => title ? t('shell.titleTemplate', { title, brand: branding.value.title }) : branding.value.title })
36
+
37
+ // Menu badges: counted after the shell renders, again every minute while
38
+ // the tab is visible, and after each navigation (an edit may change them).
39
+ const api = useVectorApi()
40
+ const route = useRoute()
41
+ const badges = ref<Record<string, { count: number, color: string, label: string | null }>>({})
42
+ async function loadBadges() {
43
+ if (!import.meta.client || document.hidden) return
44
+ try {
45
+ badges.value = (await api<{ data: typeof badges.value }>('/vector/badges')).data
46
+ } catch {
47
+ // a badge is a hint; the menu works without one
48
+ }
49
+ }
50
+ if (import.meta.client) {
51
+ onMounted(loadBadges)
52
+ useIntervalFn(loadBadges, 60_000)
53
+ watch(() => route.path, loadBadges)
54
+ }
55
+ const badgeOf = (slug: string) => (badges.value[slug]?.count ?? 0) > 0 ? badges.value[slug] : undefined
56
+
57
+ function toggleTheme() {
58
+ theme.toggle()
59
+ }
60
+
61
+ const runtime = useVectorRuntime()
62
+ auth.restore()
63
+
64
+ const impersonation = useVectorImpersonation()
65
+ // the sentence around the impersonated user's name, split so the name can stay bold
66
+ onMounted(() => impersonation.refresh().catch(() => undefined))
67
+
68
+ async function stopImpersonating() {
69
+ try {
70
+ await impersonation.stop()
71
+ impersonation.impersonator.value = null
72
+ await navigateTo(appConfig.vector.basePath || '/')
73
+ } catch (caught) {
74
+ useVectorNotifications().error(vectorErrorMessage(caught))
75
+ }
76
+ }
77
+
78
+ // nobody at the keyboard for vector.idleMinutes: the session ends, and the login page says why
79
+ useVectorIdle(() => signOut('idle'))
80
+
81
+ function logout() {
82
+ return signOut()
83
+ }
84
+
85
+ /** Ends the session: the application's, by its own logout form, or the bearer one, then the login page. */
86
+ async function signOut(reason?: 'idle') {
87
+ if (runtime.hosted && runtime.logoutUrl) {
88
+ // the application ends its own session: a form post carrying its CSRF token
89
+ const form = document.createElement('form')
90
+ form.method = 'post'
91
+ form.action = runtime.logoutUrl
92
+ const token = document.createElement('input')
93
+ token.type = 'hidden'
94
+ token.name = '_token'
95
+ token.value = runtime.csrfToken ?? ''
96
+ form.appendChild(token)
97
+ document.body.appendChild(form)
98
+ form.submit()
99
+
100
+ return
101
+ }
102
+
103
+ await auth.logout() // revokes the refresh token where the application issued one
104
+ clear()
105
+ useVectorPreferences().clear()
106
+ await navigateTo({
107
+ path: `${appConfig.vector.basePath || ''}/login`,
108
+ // back to this page after signing in again, and the reason the session ended
109
+ query: reason === 'idle' ? { reason, redirect: useRoute().fullPath } : {},
110
+ })
111
+ }
112
+
113
+ // Custom pages, grouped by their menu section ("Pages" when they name none).
114
+ const pages = useVectorPages()
115
+ const pageGroups = computed(() => {
116
+ const groups = new Map<string, { name: string, title: string, icon?: string }[]>()
117
+ for (const [name, page] of Object.entries(pages.value)) {
118
+ const label = page.group ?? t('shell.nav.pages')
119
+ groups.set(label, [...(groups.get(label) ?? []), { name, title: page.title, icon: page.icon }])
120
+ }
121
+ return [...groups.entries()].map(([label, items]) => ({ label, items }))
122
+ })
123
+ </script>
124
+
125
+ <template>
126
+ <v-app>
127
+ <v-navigation-drawer v-model="drawer">
128
+ <v-list-item :title="branding.title" :subtitle="t('shell.brandSubtitle')" class="py-3">
129
+ <template v-if="branding.logoUrl" #prepend>
130
+ <v-avatar rounded="0" size="36"><v-img :src="branding.logoUrl" :alt="branding.title" /></v-avatar>
131
+ </template>
132
+ </v-list-item>
133
+ <v-divider />
134
+ <v-list nav density="compact" role="navigation" :aria-label="t('shell.nav.main')">
135
+ <v-list-item :to="appConfig.vector.basePath || '/'" prepend-icon="mdi-view-dashboard-outline" :title="t('shell.nav.dashboard')" exact />
136
+ <v-list-item v-for="dashboard in (schema?.dashboards ?? []).filter(d => d.name !== 'main')" :key="dashboard.name" :to="`${appConfig.vector.basePath}/d/${dashboard.name}`" :prepend-icon="dashboard.icon ?? 'mdi-view-dashboard-outline'" :title="dashboard.label" />
137
+ <template v-for="group in menu" :key="group.label">
138
+ <v-list-subheader>{{ group.label }}</v-list-subheader>
139
+ <v-list-item
140
+ v-for="resource in group.items"
141
+ :key="resource.slug"
142
+ :to="`${appConfig.vector.basePath}/r/${resource.slug}`"
143
+ :prepend-icon="resource.icon ?? 'mdi-table'"
144
+ :title="resource.label"
145
+ >
146
+ <template v-if="badgeOf(resource.slug)" #append>
147
+ <v-chip
148
+ size="x-small"
149
+ variant="flat"
150
+ label
151
+ :color="badgeOf(resource.slug)!.color"
152
+ :aria-label="t('shell.nav.badge', { count: badgeOf(resource.slug)!.count, label: badgeOf(resource.slug)!.label ?? noun(resource.label) })"
153
+ >{{ formatNumber(badgeOf(resource.slug)!.count) }}</v-chip>
154
+ </template>
155
+ </v-list-item>
156
+ </template>
157
+ <template v-for="group in pageGroups" :key="`pages-${group.label}`">
158
+ <v-list-subheader>{{ group.label }}</v-list-subheader>
159
+ <v-list-item
160
+ v-for="page in group.items"
161
+ :key="page.name"
162
+ :to="`${appConfig.vector.basePath}/p/${page.name}`"
163
+ :prepend-icon="page.icon ?? 'mdi-file-document-outline'"
164
+ :title="page.title"
165
+ />
166
+ </template>
167
+ <v-divider class="my-2" />
168
+ <v-list-item v-if="schema?.capabilities.queue" :to="`${appConfig.vector.basePath}/jobs`" prepend-icon="mdi-progress-clock" :title="t('shell.nav.jobs')" />
169
+ <v-list-item v-if="schema?.settings.length" :to="`${appConfig.vector.basePath}/settings`" prepend-icon="mdi-cog-outline" :title="t('shell.nav.settings')" />
170
+ </v-list>
171
+ </v-navigation-drawer>
172
+
173
+ <v-app-bar flat border>
174
+ <v-app-bar-nav-icon :aria-label="t('shell.toggleNavigation')" @click="drawer = !drawer" />
175
+ <v-app-bar-title>{{ branding.title }}</v-app-bar-title>
176
+ <v-spacer />
177
+ <v-btn v-if="appConfig.vector.commandPalette" variant="text" prepend-icon="mdi-magnify" class="d-none d-sm-flex" @click="palette && (palette.open = true)">
178
+ {{ t('common.search') }} <kbd class="ms-2 text-caption text-medium-emphasis">⌘K</kbd>
179
+ </v-btn>
180
+ <VxInbox />
181
+ <v-menu v-if="language.locales.value.length > 1">
182
+ <template #activator="{ props }">
183
+ <v-btn v-bind="props" icon="mdi-translate" variant="text" :aria-label="t('shell.language', { name: currentLanguage?.name ?? language.current.value })" />
184
+ </template>
185
+ <v-list density="compact" :aria-label="t('shell.language', { name: currentLanguage?.name ?? language.current.value })">
186
+ <!-- each in its own name (and script direction), with its name in the current language beneath -->
187
+ <v-list-item
188
+ v-for="locale in language.locales.value"
189
+ :key="locale.code"
190
+ :subtitle="languageName(locale.code, language.current.value, locale.name) ?? undefined"
191
+ :active="locale.code === language.current.value"
192
+ @click="chooseLanguage(locale.code)"
193
+ >
194
+ <template #title><span :lang="locale.code" :dir="locale.dir">{{ locale.name }}</span></template>
195
+ </v-list-item>
196
+ </v-list>
197
+ </v-menu>
198
+ <v-btn icon="mdi-theme-light-dark" variant="text" :aria-label="t('shell.toggleTheme')" @click="toggleTheme" />
199
+ <v-menu>
200
+ <template #activator="{ props }">
201
+ <v-btn v-bind="props" variant="text" prepend-icon="mdi-account-circle-outline">
202
+ {{ auth.user?.name ?? t('shell.account') }}
203
+ </v-btn>
204
+ </template>
205
+ <v-list density="compact">
206
+ <v-list-item :title="t('shell.signOut')" prepend-icon="mdi-logout" @click="logout" />
207
+ </v-list>
208
+ </v-menu>
209
+ </v-app-bar>
210
+
211
+ <v-main>
212
+ <v-alert v-if="impersonation.impersonator.value" type="warning" variant="tonal" density="compact" rounded="0" icon="mdi-account-switch-outline" class="px-6">
213
+ <i18n-t keypath="shell.impersonation.actingAs" tag="span" scope="global">
214
+ <template #name><strong>{{ auth.user?.name ?? t('shell.impersonation.anotherUser') }}</strong></template>
215
+ </i18n-t>
216
+ <template #append>
217
+ <v-btn size="small" variant="flat" color="warning" @click="stopImpersonating">{{ t('shell.impersonation.stop') }}</v-btn>
218
+ </template>
219
+ </v-alert>
220
+ <v-container fluid class="pa-6">
221
+ <slot />
222
+ </v-container>
223
+ </v-main>
224
+
225
+ <v-footer v-if="branding.footerText" app border class="text-caption text-medium-emphasis justify-center">{{ branding.footerText }}</v-footer>
226
+
227
+ <VxNotifications />
228
+ <VxConfirm />
229
+ <VxCommandPalette v-if="appConfig.vector.commandPalette" ref="palette" />
230
+ </v-app>
231
+ </template>
232
+
233
+ <style>
234
+ /* The current breadcrumb is not a link; Vuetify dims it as disabled, which fails AA contrast. */
235
+ .v-breadcrumbs-item--disabled {
236
+ opacity: var(--v-medium-emphasis-opacity);
237
+ }
238
+ </style>
@@ -0,0 +1,16 @@
1
+ export default defineNuxtRouteMiddleware((to) => {
2
+ const runtime = useVectorRuntime()
3
+
4
+ if (runtime.hosted) {
5
+ return // the application gated the shell page before it was served
6
+ }
7
+
8
+ const auth = useVectorAuthStore()
9
+ const appConfig = useAppConfig()
10
+
11
+ auth.restore()
12
+
13
+ if (!auth.isAuthenticated) {
14
+ return navigateTo({ path: `${appConfig.vector.basePath || ''}/login`, query: { redirect: to.fullPath } })
15
+ }
16
+ })
@@ -0,0 +1,12 @@
1
+ <script setup lang="ts">
2
+ definePageMeta({ layout: 'vector', middleware: 'vector-auth', key: route => route.fullPath })
3
+
4
+ const route = useRoute()
5
+ const { load } = useVectorSchema()
6
+ await load()
7
+ const name = computed(() => String(route.params.name))
8
+ </script>
9
+
10
+ <template>
11
+ <VxDashboard :name="name" />
12
+ </template>
@@ -0,0 +1,22 @@
1
+ <script setup lang="ts">
2
+ definePageMeta({ layout: 'vector', middleware: 'vector-auth' })
3
+
4
+ // The home page: the `main` dashboard when the application declares one.
5
+ // Every resource is in the navigation drawer, so the page repeats none.
6
+ const { t } = useVectorT()
7
+ const { load, schema } = useVectorSchema()
8
+ await load()
9
+
10
+ const main = computed(() => schema.value?.dashboards.find(d => d.name === 'main') ?? schema.value?.dashboards[0] ?? null)
11
+
12
+ useHead({ title: () => t('shell.home.title') })
13
+ </script>
14
+
15
+ <template>
16
+ <div>
17
+ <VxDashboard v-if="main" :name="main.name" />
18
+ <template v-else>
19
+ <h1 class="text-h4 mb-4">{{ t('shell.home.title') }}</h1>
20
+ </template>
21
+ </div>
22
+ </template>
@@ -0,0 +1,52 @@
1
+ <script setup lang="ts">
2
+ import type { VectorJob } from '#vector/app/types/vector'
3
+
4
+ definePageMeta({ layout: 'vector', middleware: 'vector-auth', key: route => route.fullPath })
5
+
6
+ const route = useRoute()
7
+ const appConfig = useAppConfig()
8
+ const jobs = useVectorJobs()
9
+ const { t } = useVectorT()
10
+ const id = Number(route.params.id)
11
+ const job = ref<VectorJob | null>(null)
12
+ const error = ref<string | null>(null)
13
+
14
+ const polling = new AbortController()
15
+ onBeforeUnmount(() => polling.abort())
16
+
17
+ onMounted(async () => {
18
+ try {
19
+ await jobs.watch(id, j => (job.value = j), polling.signal)
20
+ } catch (caught) {
21
+ if (!polling.signal.aborted) error.value = vectorErrorMessage(caught)
22
+ }
23
+ })
24
+
25
+ useHead({ title: () => (job.value ? t('jobs.detail.pageTitle', { id: job.value.id }) : t('jobs.detail.fallbackTitle')) })
26
+ const percent = computed(() => (job.value && job.value.total ? Math.min(100, (job.value.processed / job.value.total) * 100) : null))
27
+ </script>
28
+
29
+ <template>
30
+ <div>
31
+ <v-breadcrumbs class="pa-0 mb-2" :items="[{ title: t('jobs.title'), to: `${appConfig.vector.basePath}/jobs` }, { title: `#${id}` }]" />
32
+ <v-alert v-if="error" type="error" variant="tonal" :text="error" />
33
+ <v-card v-else-if="job">
34
+ <v-card-item>
35
+ <v-card-title class="text-capitalize">{{ t(`jobs.kind.${job.kind}`) }} · {{ job.name }} <v-chip size="small" class="ms-2" :color="{ queued: 'grey', running: 'info', done: 'success', failed: 'error', stalled: 'warning' }[job.status]" variant="tonal">{{ t(`jobs.status.${job.status}`) }}</v-chip></v-card-title>
36
+ <v-card-subtitle>{{ job.resource }} · {{ t('jobs.detail.started', { date: formatDateTime(job.createdAt) }) }}<template v-if="job.finishedAt"> · {{ t('jobs.detail.finished', { date: formatDateTime(job.finishedAt) }) }}</template></v-card-subtitle>
37
+ </v-card-item>
38
+ <v-card-text>
39
+ <v-progress-linear :model-value="percent ?? 0" :indeterminate="percent === null && job.status !== 'done' && job.status !== 'failed'" color="primary" height="10" rounded class="mb-2" :aria-label="t('jobs.detail.progress')" />
40
+ <div class="text-body-2 mb-4">{{ job.total !== null ? t('jobs.detail.processedOfTotal', { processed: job.processed, total: job.total }) : t('jobs.detail.processed', { processed: job.processed }) }}</div>
41
+ <v-alert v-if="job.failure" type="error" variant="tonal" :text="job.failure" class="mb-4" />
42
+ <template v-if="job.result">
43
+ <div v-for="(value, key) in job.result" :key="key" class="text-body-2"><span class="text-medium-emphasis">{{ key }}:</span> {{ value }}</div>
44
+ </template>
45
+ </v-card-text>
46
+ <v-card-actions v-if="job.hasFile && job.status === 'done'">
47
+ <v-btn color="primary" prepend-icon="mdi-download-outline" @click="jobs.download(job)">{{ t('jobs.detail.download', { file: job.fileName ?? t('jobs.detail.file') }) }}</v-btn>
48
+ </v-card-actions>
49
+ </v-card>
50
+ <v-skeleton-loader v-else type="article" />
51
+ </div>
52
+ </template>
@@ -0,0 +1,42 @@
1
+ <script setup lang="ts">
2
+ import type { VectorJob } from '#vector/app/types/vector'
3
+
4
+ definePageMeta({ layout: 'vector', middleware: 'vector-auth' })
5
+
6
+ const appConfig = useAppConfig()
7
+ const jobs = useVectorJobs()
8
+ const { t } = useVectorT()
9
+ const { data, refresh } = await useAsyncData('vector.jobs', () => jobs.list())
10
+ const running = computed(() => (data.value ?? []).some(j => j.status === 'queued' || j.status === 'running'))
11
+ const { pause, resume } = useIntervalFn(() => refresh(), 3000, { immediate: false })
12
+ watch(running, r => (r ? resume() : pause()), { immediate: true })
13
+ onUnmounted(pause)
14
+
15
+ useHead({ title: () => t('jobs.title') })
16
+
17
+ const color: Record<VectorJob['status'], string> = { queued: 'grey', running: 'info', done: 'success', failed: 'error', stalled: 'warning' }
18
+ </script>
19
+
20
+ <template>
21
+ <div>
22
+ <h1 class="text-h4 mb-4">{{ t('jobs.heading') }}</h1>
23
+ <v-card>
24
+ <v-table hover>
25
+ <thead>
26
+ <tr><th>{{ t('jobs.columns.job') }}</th><th>{{ t('jobs.columns.resource') }}</th><th>{{ t('jobs.columns.status') }}</th><th class="text-end">{{ t('jobs.columns.progress') }}</th><th>{{ t('jobs.columns.started') }}</th><th /></tr>
27
+ </thead>
28
+ <tbody>
29
+ <tr v-for="job in data ?? []" :key="job.id" style="cursor: pointer" @click="navigateTo(`${appConfig.vector.basePath}/jobs/${job.id}`)">
30
+ <td><span class="text-capitalize">{{ t(`jobs.kind.${job.kind}`) }}</span> <span class="text-medium-emphasis">{{ job.name }}</span></td>
31
+ <td>{{ job.resource }}</td>
32
+ <td><v-chip size="small" :color="color[job.status]" variant="tonal">{{ t(`jobs.status.${job.status}`) }}</v-chip></td>
33
+ <td class="text-end">{{ job.processed }}<template v-if="job.total !== null"> / {{ job.total }}</template></td>
34
+ <td>{{ formatDateTime(job.createdAt) }}</td>
35
+ <td class="text-end"><v-btn v-if="job.hasFile && job.status === 'done'" size="small" variant="text" icon="mdi-download-outline" :aria-label="t('common.download')" @click.stop="jobs.download(job)" /></td>
36
+ </tr>
37
+ <tr v-if="(data ?? []).length === 0"><td colspan="6" class="text-medium-emphasis">{{ t('jobs.empty') }}</td></tr>
38
+ </tbody>
39
+ </v-table>
40
+ </v-card>
41
+ </div>
42
+ </template>
@@ -0,0 +1,109 @@
1
+ <script setup lang="ts">
2
+ import type { SubmitEventPromise } from 'vuetify'
3
+
4
+ definePageMeta({ layout: 'default' })
5
+
6
+ const appConfig = useAppConfig()
7
+ const auth = useVectorAuthStore()
8
+ const route = useRoute()
9
+ const notifications = useVectorNotifications()
10
+ const { t } = useVectorT()
11
+
12
+ const email = ref('')
13
+ const password = ref('')
14
+ const submitting = ref(false)
15
+ const error = ref<string | null>(null)
16
+ // why the last session ended, when the admin ended it itself
17
+ const notice = computed(() => route.query.reason === 'idle' ? t('shell.login.signedOutIdle', { minutes: appConfig.vector.idleMinutes }) : null)
18
+
19
+ const rules = {
20
+ required: (value: string) => value.trim() !== '' || t('common.required'),
21
+ email: (value: string) => /.+@.+\..+/.test(value) || t('shell.login.invalidEmail'),
22
+ }
23
+
24
+ const runtime = useVectorRuntime()
25
+ useHead({ title: () => t('shell.login.pageTitle', { brand: appConfig.vector.brand.title }) })
26
+
27
+ if (runtime.hosted && runtime.loginUrl && import.meta.client) {
28
+ window.location.assign(runtime.loginUrl) // the application owns sign-in in hosted mode
29
+ }
30
+
31
+ auth.restore()
32
+
33
+ if (auth.isAuthenticated) {
34
+ await navigateTo(appConfig.vector.basePath || '/', { replace: true })
35
+ }
36
+
37
+ async function submit(event: SubmitEventPromise) {
38
+ const { valid } = await event
39
+
40
+ if (!valid) {
41
+ return
42
+ }
43
+
44
+ submitting.value = true
45
+ error.value = null
46
+
47
+ try {
48
+ await auth.login(email.value, password.value)
49
+
50
+ // The credentials can be right for an account the admin refuses (no
51
+ // access, 403): the descriptor is the first thing the admin needs, so
52
+ // it is loaded here, for this identity, and a refusal ends the sign-in
53
+ // with the server's reason instead of a welcome.
54
+ try {
55
+ await useVectorSchema().load(true)
56
+ } catch (refused) {
57
+ await auth.logout()
58
+ throw refused
59
+ }
60
+
61
+ notifications.success(t('shell.login.welcome', { name: auth.user?.name ?? email.value }))
62
+
63
+ // only a path of this app: `//host`, `/\host` and anything with a scheme are not places to go back to
64
+ const wanted = typeof route.query.redirect === 'string' ? route.query.redirect : ''
65
+ const redirect = /^\/(?![/\\])/.test(wanted) ? wanted : (appConfig.vector.basePath || '/')
66
+ await navigateTo(redirect, { replace: true })
67
+ } catch (caught) {
68
+ error.value = vectorErrorMessage(caught)
69
+ } finally {
70
+ submitting.value = false
71
+ }
72
+ }
73
+ </script>
74
+
75
+ <template>
76
+ <v-container class="fill-height" fluid>
77
+ <v-row justify="center" align="center">
78
+ <v-col cols="12" sm="8" md="4">
79
+ <v-card class="pa-4">
80
+ <v-card-title class="text-h5">{{ appConfig.vector.brand.title }}</v-card-title>
81
+ <v-card-subtitle>{{ t('shell.login.subtitle') }}</v-card-subtitle>
82
+ <v-card-text>
83
+ <v-form validate-on="blur lazy" @submit.prevent="submit">
84
+ <v-text-field
85
+ v-model="email"
86
+ :label="t('shell.login.email')"
87
+ type="email"
88
+ autocomplete="username"
89
+ :rules="[rules.required, rules.email]"
90
+ prepend-inner-icon="mdi-email-outline"
91
+ />
92
+ <v-text-field
93
+ v-model="password"
94
+ :label="t('shell.login.password')"
95
+ type="password"
96
+ autocomplete="current-password"
97
+ :rules="[rules.required]"
98
+ prepend-inner-icon="mdi-lock-outline"
99
+ />
100
+ <v-alert v-if="notice && !error" type="info" variant="tonal" class="mb-4" :text="notice" />
101
+ <v-alert v-if="error" type="error" variant="tonal" class="mb-4" :text="error" />
102
+ <v-btn type="submit" color="primary" block :loading="submitting">{{ t('shell.login.submit') }}</v-btn>
103
+ </v-form>
104
+ </v-card-text>
105
+ </v-card>
106
+ </v-col>
107
+ </v-row>
108
+ </v-container>
109
+ </template>
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ definePageMeta({ layout: 'vector', middleware: 'vector-auth', key: route => route.fullPath })
3
+
4
+ // A custom page: the component a runtime extension or the application
5
+ // registered under this name, handed the admin's API client so it can
6
+ // call the application's own endpoints with the admin's credentials.
7
+ const route = useRoute()
8
+ const { t } = useVectorT()
9
+ const pages = useVectorPages()
10
+ const { ready } = useVectorExtensions()
11
+ const appConfig = useAppConfig()
12
+ const api = useVectorApi()
13
+ const notifications = useVectorNotifications()
14
+
15
+ const name = computed(() => String(route.params.name))
16
+ const page = computed(() => pages.value[name.value] ?? null)
17
+ const component = computed(() => (typeof page.value?.component === 'string' ? resolveComponent(page.value.component) : page.value?.component))
18
+ const context = {
19
+ api,
20
+ notify: { success: (m: string) => notifications.success(m), error: (m: string) => notifications.error(m) },
21
+ navigate: (path: string) => navigateTo(`${appConfig.vector.basePath}${path}`),
22
+ }
23
+
24
+ useHead({ title: () => page.value?.title ?? t('shell.page.unknown') })
25
+ </script>
26
+
27
+ <template>
28
+ <div v-if="page">
29
+ <h1 class="text-h4 mb-4 d-flex align-center ga-2">
30
+ <v-icon v-if="page.icon" :icon="page.icon" />
31
+ {{ page.title }}
32
+ </h1>
33
+ <component :is="component" :vector="context" />
34
+ </div>
35
+ <v-progress-linear v-else-if="!ready" indeterminate :aria-label="t('shell.page.loading')" />
36
+ <v-empty-state v-else icon="mdi-help-circle-outline" :title="t('shell.page.unknown')" />
37
+ </template>
@@ -0,0 +1,54 @@
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 } = 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 id = computed(() => String(route.params.id))
15
+ const resource = computed(() => find(slug.value))
16
+ const client = useVectorResource(slug)
17
+ const base = computed(() => `${appConfig.vector.basePath}/r/${slug.value}`)
18
+
19
+ const { data: response, status, error, refresh } = await useAsyncData(() => `vector.${slug.value}.${id.value}.edit`, () => client.show(id.value))
20
+
21
+ const title = computed(() => {
22
+ const value = response.value?.data?.[resource.value?.titleField ?? 'id']
23
+ return value === undefined || value === null ? `#${id.value}` : String(value)
24
+ })
25
+
26
+ useHead({ title: () => resource.value ? `${t('record.editTitle', { title: title.value })} · ${resource.value.label}` : '' })
27
+
28
+ const form = ref<{ dirty: boolean, reset: () => void } | null>(null)
29
+ useLeaveGuard(() => form.value?.dirty === true)
30
+ // a new form after "save and continue": the record, and its version, as saved
31
+ const generation = ref(0)
32
+
33
+ async function saved(record: VectorRecord, variant: SaveVariant) {
34
+ if (variant === 'continue') {
35
+ await refresh()
36
+ generation.value++
37
+ return
38
+ }
39
+
40
+ navigateTo(`${base.value}/${encodeURIComponent(String(record.id))}`)
41
+ }
42
+ </script>
43
+
44
+ <template>
45
+ <div v-if="resource">
46
+ <v-breadcrumbs class="pa-0 mb-2" :items="[{ title: resource.label, to: base }, { title, to: `${base}/${id}` }, { title: t('common.edit') }]" />
47
+ <h1 class="text-h4 mb-4">{{ t('record.editTitle', { title }) }}</h1>
48
+ <v-skeleton-loader v-if="status === 'pending'" type="article" />
49
+ <v-alert v-else-if="error" type="error" variant="tonal" :text="vectorErrorMessage(error)" />
50
+ <v-alert v-else-if="response && !response.permissions.update" type="warning" variant="tonal" :text="t('record.editNotAllowed')" />
51
+ <VxResourceForm v-else-if="response" :key="generation" ref="form" :resource="resource" operation="update" :record="response.data" variants @saved="saved" @cancel="navigateTo(`${base}/${id}`)" />
52
+ </div>
53
+ <v-empty-state v-else icon="mdi-help-circle-outline" :title="t('record.unknownResource')" />
54
+ </template>