@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,305 @@
1
+ import type { AnyExtension, Extensions, JSONContent } from '@tiptap/core'
2
+ import { generateHTML, generateJSON, generateText } from '@tiptap/core'
3
+ import StarterKit from '@tiptap/starter-kit'
4
+ import { TableKit } from '@tiptap/extension-table'
5
+ import Image from '@tiptap/extension-image'
6
+ import Placeholder from '@tiptap/extension-placeholder'
7
+ import Mention, { type MentionOptions } from '@tiptap/extension-mention'
8
+ import DOMPurify from 'dompurify'
9
+ import MarkdownIt from 'markdown-it'
10
+
11
+ /**
12
+ * The rich-text schema on the client: the Tiptap extensions a field's
13
+ * `features` allow — the same node and mark types, with the same
14
+ * attributes, the server's Document validates — and the sanitizer every
15
+ * generated HTML string passes before the one `v-html` that shows it.
16
+ */
17
+
18
+ /** Every feature a field may allow, as the server names them. */
19
+ export const RICH_TEXT_FEATURES = ['bold', 'italic', 'underline', 'strike', 'code', 'heading', 'list', 'blockquote', 'codeBlock', 'horizontalRule', 'link', 'table', 'image', 'mention'] as const
20
+
21
+ /** Links go to http(s), mailto or a relative place — the server's rule. */
22
+ export function allowedHref(href: string): boolean {
23
+ // Control characters and spaces are what hide a scheme, so they are stripped on purpose.
24
+ // eslint-disable-next-line no-control-regex
25
+ const compact = href.replace(/[\u0000- \u007F]+/g, '')
26
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(compact)
27
+ return scheme === null || ['http', 'https', 'mailto'].includes(scheme[1]!.toLowerCase())
28
+ }
29
+
30
+ export interface RichTextOptions {
31
+ features: readonly string[]
32
+ placeholder?: string
33
+ /** Resolves an image's storage key to a URL it can show (a blob URL); editing only. */
34
+ imageSource?: (key: string) => Promise<string | null>
35
+ /** The `@` menu: records of the mentioned relation matching a query. */
36
+ mentionItems?: (query: string) => Promise<{ id: string | number, label: string }[]>
37
+ /** Node and mark types runtime extensions added, by name. */
38
+ custom?: ReadonlyMap<string, AnyExtension>
39
+ }
40
+
41
+ /** The Tiptap extensions for a field: exactly its features, nothing else. */
42
+ export function richTextExtensions(options: RichTextOptions): Extensions {
43
+ const on = (feature: string) => options.features.includes(feature)
44
+ const extensions: Extensions = [
45
+ StarterKit.configure({
46
+ bold: on('bold') ? {} : false,
47
+ italic: on('italic') ? {} : false,
48
+ underline: on('underline') ? {} : false,
49
+ strike: on('strike') ? {} : false,
50
+ code: on('code') ? {} : false,
51
+ heading: on('heading') ? { levels: [1, 2, 3, 4] } : false,
52
+ bulletList: on('list') ? {} : false,
53
+ orderedList: on('list') ? {} : false,
54
+ listItem: on('list') ? {} : false,
55
+ listKeymap: on('list') ? {} : false,
56
+ blockquote: on('blockquote') ? {} : false,
57
+ codeBlock: on('codeBlock') ? {} : false,
58
+ horizontalRule: on('horizontalRule') ? {} : false,
59
+ link: on('link')
60
+ ? {
61
+ openOnClick: false,
62
+ autolink: true,
63
+ protocols: [],
64
+ HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer nofollow' },
65
+ isAllowedUri: (url: string) => allowedHref(url),
66
+ }
67
+ : false,
68
+ }),
69
+ ]
70
+
71
+ if (on('table')) {
72
+ // not resizable: resizing writes style attributes a strict CSP refuses
73
+ extensions.push(TableKit.configure({ table: { resizable: false } }))
74
+ }
75
+
76
+ if (on('image')) {
77
+ extensions.push(vectorImage(options.imageSource))
78
+ }
79
+
80
+ if (on('mention')) {
81
+ extensions.push(Mention.configure({
82
+ HTMLAttributes: { class: 'vx-mention' },
83
+ suggestion: options.mentionItems ? mentionSuggestion(options.mentionItems) : undefined,
84
+ }))
85
+ }
86
+
87
+ if (options.placeholder) {
88
+ extensions.push(Placeholder.configure({ placeholder: options.placeholder }))
89
+ }
90
+
91
+ for (const [name, extension] of options.custom ?? []) {
92
+ if (on(name)) extensions.push(extension)
93
+ }
94
+
95
+ return extensions
96
+ }
97
+
98
+ /**
99
+ * An image stores a storage key in `src`, never a URL. Rendered, it
100
+ * carries the key as `data-vector-key` and no `src`: whoever shows it
101
+ * fetches the file with the API's credentials. Editing, a node view does
102
+ * that through `imageSource`.
103
+ */
104
+ function vectorImage(imageSource?: (key: string) => Promise<string | null>): AnyExtension {
105
+ return Image.extend({
106
+ renderHTML({ HTMLAttributes }) {
107
+ const { src, ...rest } = HTMLAttributes as Record<string, unknown>
108
+ return ['img', { ...rest, 'data-vector-key': src }]
109
+ },
110
+ parseHTML() {
111
+ return [{ tag: 'img[src]' }, { tag: 'img[data-vector-key]', getAttrs: element => ({ src: (element as HTMLElement).getAttribute('data-vector-key') }) }]
112
+ },
113
+ addNodeView() {
114
+ return ({ node }) => {
115
+ const img = document.createElement('img')
116
+ img.alt = String(node.attrs.alt ?? '')
117
+ img.className = 'vx-richtext-image'
118
+ const key = String(node.attrs.src ?? '')
119
+ img.setAttribute('data-vector-key', key)
120
+ imageSource?.(key).then((url) => {
121
+ if (url) img.src = url
122
+ }).catch(() => undefined)
123
+ return { dom: img }
124
+ }
125
+ },
126
+ }).configure({ allowBase64: false })
127
+ }
128
+
129
+ /** A minimal `@` menu: a list under the caret, arrow keys and Enter to pick. */
130
+ function mentionSuggestion(items: (query: string) => Promise<{ id: string | number, label: string }[]>): NonNullable<MentionOptions['suggestion']> {
131
+ return {
132
+ items: ({ query }) => items(query),
133
+ render: () => {
134
+ let menu: HTMLDivElement | null = null
135
+ let choices: { id: string | number, label: string }[] = []
136
+ let active = 0
137
+ let pick: ((item: { id: string | number, label: string }) => void) | null = null
138
+
139
+ const draw = () => {
140
+ if (!menu) return
141
+ menu.replaceChildren(...choices.map((choice, index) => {
142
+ const button = document.createElement('button')
143
+ button.type = 'button'
144
+ button.className = `vx-mention-choice${index === active ? ' vx-mention-choice--active' : ''}`
145
+ button.textContent = choice.label
146
+ button.addEventListener('mousedown', (event) => {
147
+ event.preventDefault()
148
+ pick?.(choice)
149
+ })
150
+ return button
151
+ }))
152
+ }
153
+ const place = (rect: DOMRect | null) => {
154
+ if (!menu || !rect) return
155
+ menu.style.left = `${rect.left + window.scrollX}px`
156
+ menu.style.top = `${rect.bottom + window.scrollY + 4}px`
157
+ }
158
+
159
+ return {
160
+ onStart: (props) => {
161
+ menu = document.createElement('div')
162
+ menu.className = 'vx-mention-menu'
163
+ menu.setAttribute('role', 'listbox')
164
+ document.body.appendChild(menu)
165
+ choices = props.items as typeof choices
166
+ pick = item => props.command({ id: String(item.id), label: item.label })
167
+ active = 0
168
+ draw()
169
+ place(props.clientRect?.() ?? null)
170
+ },
171
+ onUpdate: (props) => {
172
+ choices = props.items as typeof choices
173
+ pick = item => props.command({ id: String(item.id), label: item.label })
174
+ active = Math.min(active, Math.max(0, choices.length - 1))
175
+ draw()
176
+ place(props.clientRect?.() ?? null)
177
+ },
178
+ onKeyDown: ({ event }) => {
179
+ if (!choices.length) return false
180
+ if (event.key === 'ArrowDown') active = (active + 1) % choices.length
181
+ else if (event.key === 'ArrowUp') active = (active - 1 + choices.length) % choices.length
182
+ else if (event.key === 'Enter') {
183
+ pick?.(choices[active]!)
184
+ return true
185
+ } else return false
186
+ draw()
187
+ return true
188
+ },
189
+ onExit: () => {
190
+ menu?.remove()
191
+ menu = null
192
+ },
193
+ }
194
+ },
195
+ }
196
+ }
197
+
198
+ /** A stored value as a document: JSON, or HTML (a field that stores HTML) read through the schema. */
199
+ export function toDocument(value: string, extensions: Extensions): JSONContent | null {
200
+ if (value.trim() === '') return null
201
+ try {
202
+ const parsed = JSON.parse(value) as unknown
203
+ if (parsed && typeof parsed === 'object' && (parsed as JSONContent).type === 'doc') return parsed as JSONContent
204
+ } catch {
205
+ // not JSON: HTML
206
+ }
207
+ return generateJSON(value, extensions) as JSONContent
208
+ }
209
+
210
+ /** Whether a document holds no text, image, table or other content. */
211
+ export function isEmptyDocument(doc: JSONContent | null): boolean {
212
+ const empty = (node: JSONContent): boolean => {
213
+ if (node.type === 'text') return (node.text ?? '').trim() === ''
214
+ if (!['doc', 'paragraph', 'hardBreak'].includes(node.type ?? '')) return false
215
+ return (node.content ?? []).every(empty)
216
+ }
217
+ return doc === null || empty(doc)
218
+ }
219
+
220
+ let purifier: ReturnType<typeof DOMPurify> | null = null
221
+ let purifierWorks: boolean | null = null
222
+
223
+ const SANITIZE_CONFIG = {
224
+ ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'hr', 'a', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'colgroup', 'col', 'img', 'span'],
225
+ ALLOWED_ATTR: ['href', 'target', 'rel', 'colspan', 'rowspan', 'alt', 'title', 'width', 'height', 'start', 'data-vector-key', 'data-type', 'data-id', 'data-label'],
226
+ ALLOW_DATA_ATTR: false,
227
+ ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
228
+ }
229
+
230
+ /** Text shown as text: what a sanitizer that cannot be trusted leaves. */
231
+ function escapeHtml(html: string): string {
232
+ return html.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' })[c]!)
233
+ }
234
+
235
+ /**
236
+ * The allow-list the generated HTML passes before `v-html`: the schema's
237
+ * elements and attributes only. No `style`, no event handler, no
238
+ * `class`, no `src` (images carry their key), links only to http(s),
239
+ * mailto or a relative place, and always opening apart.
240
+ */
241
+ export function sanitizeHtml(html: string): string {
242
+ if (!purifier) {
243
+ purifier = DOMPurify(window)
244
+ purifier.addHook('afterSanitizeAttributes', (node) => {
245
+ if (node.tagName === 'A') {
246
+ if (!allowedHref(node.getAttribute('href') ?? '')) node.removeAttribute('href')
247
+ node.setAttribute('target', '_blank')
248
+ node.setAttribute('rel', 'noopener noreferrer nofollow')
249
+ }
250
+ })
251
+ }
252
+
253
+ // Fail closed: a sanitizer that does not clean a known-bad canary exactly
254
+ // (a DOM that iterates wrongly, a patched library) is not trusted, and
255
+ // everything is shown as text instead.
256
+ purifierWorks ??= purifier.sanitize('<p onclick="x">a<script>b</script><img src="x" onerror="y"><a href="javascript:z">c</a></p>', SANITIZE_CONFIG)
257
+ === '<p>a<img><a target="_blank" rel="noopener noreferrer nofollow">c</a></p>'
258
+
259
+ return purifierWorks ? purifier.sanitize(html, SANITIZE_CONFIG) : escapeHtml(html)
260
+ }
261
+
262
+ /** Whether this DOM's sanitizer passed its canary: false means every rich value is shown as text. */
263
+ export function sanitizerWorks(): boolean {
264
+ sanitizeHtml('')
265
+ return purifierWorks === true
266
+ }
267
+
268
+ /** A stored value as sanitized HTML, generated through the schema. */
269
+ export function richTextHtml(value: string, extensions: Extensions): string {
270
+ try {
271
+ const doc = toDocument(value, extensions)
272
+ return doc ? sanitizeHtml(generateHTML(doc, extensions)) : ''
273
+ } catch {
274
+ return '' // a node the schema does not know: the server refuses such a document, so it is not shown either
275
+ }
276
+ }
277
+
278
+ /** A stored value's plain text, for a list cell. */
279
+ export function richTextPlain(value: string, extensions: Extensions): string {
280
+ try {
281
+ const doc = toDocument(value, extensions)
282
+ return doc ? generateText(doc, extensions, { blockSeparator: ' ' }).trim() : ''
283
+ } catch {
284
+ return ''
285
+ }
286
+ }
287
+
288
+ let markdown: ReturnType<typeof MarkdownIt> | null = null
289
+
290
+ /**
291
+ * Markdown source as sanitized HTML: markdown-it with HTML in the source
292
+ * disabled (it shows as text) and bare URLs linked, then the same
293
+ * allow-list as rich text.
294
+ */
295
+ export function markdownHtml(source: string): string {
296
+ markdown ??= new MarkdownIt({ html: false, linkify: true, breaks: false })
297
+ return sanitizeHtml(markdown.render(source))
298
+ }
299
+
300
+ /** Markdown source as its plain text, for a list cell. */
301
+ export function markdownPlain(source: string): string {
302
+ const holder = document.createElement('div')
303
+ holder.innerHTML = markdownHtml(source)
304
+ return (holder.textContent ?? '').replace(/\s+/g, ' ').trim()
305
+ }
@@ -0,0 +1,113 @@
1
+ import type { VectorCondition, VectorField, VectorFieldState, VectorRule } from '#vector/app/types/vector'
2
+
3
+ type Rule = (value: unknown) => true | string
4
+
5
+ const empty = (value: unknown) => value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0)
6
+
7
+ /**
8
+ * The descriptor's rules, compiled to Vuetify rule functions. Each
9
+ * mirrors the framework constraint the server runs, by its class's
10
+ * short name; a constraint with no client equivalent is left to the
11
+ * server, whose 422 lands on the field as `error-messages`.
12
+ */
13
+ export function rulesFor(field: VectorField, operation: 'create' | 'update', state?: VectorFieldState): Rule[] {
14
+ const rules: Rule[] = []
15
+ const requiredHere = (operation === 'create' && field.nullable === false && !['manyToMany', 'hasMany', 'hasOne', 'boolean'].includes(field.type)) || state?.required === true
16
+
17
+ if (requiredHere) {
18
+ rules.push(value => !empty(value) || vt('common.required'))
19
+ }
20
+
21
+ for (const rule of field.rules) {
22
+ const compiled = compile(rule)
23
+
24
+ if (compiled) {
25
+ rules.push(compiled)
26
+ }
27
+ }
28
+
29
+ return rules
30
+ }
31
+
32
+ function compile(rule: VectorRule): Rule | null {
33
+ const name = rule.constraint.split('\\').pop()
34
+ const [a, b] = rule.args as [unknown, unknown]
35
+
36
+ switch (name) {
37
+ case 'NotBlank':
38
+ return value => (typeof value === 'string' ? value.trim() !== '' : !empty(value)) || vt('common.required')
39
+ case 'MaxLength':
40
+ return value => empty(value) || typeof value !== 'string' || [...value].length <= Number(a) || vt('fields.rules.maxLength', { max: String(a) })
41
+ case 'MinLength':
42
+ return value => empty(value) || typeof value !== 'string' || [...value].length >= Number(a) || vt('fields.rules.minLength', { min: String(a) })
43
+ case 'Email':
44
+ return value => empty(value) || (typeof value === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) || vt('fields.rules.email')
45
+ case 'Url':
46
+ case 'WebUrl':
47
+ return value => empty(value) || (typeof value === 'string' && /^https?:\/\/\S+$/i.test(value)) || vt('fields.rules.url')
48
+ case 'GreaterThan':
49
+ return value => empty(value) || Number(value) > Number(a) || vt('fields.rules.greaterThan', { limit: String(a) })
50
+ case 'GreaterThanOrEqual':
51
+ return value => empty(value) || Number(value) >= Number(a) || vt('fields.rules.greaterThanOrEqual', { limit: String(a) })
52
+ case 'LessThan':
53
+ return value => empty(value) || Number(value) < Number(a) || vt('fields.rules.lessThan', { limit: String(a) })
54
+ case 'LessThanOrEqual':
55
+ return value => empty(value) || Number(value) <= Number(a) || vt('fields.rules.lessThanOrEqual', { limit: String(a) })
56
+ case 'Regex':
57
+ return value => empty(value) || typeof value !== 'string' || phpRegex(String(a)).test(value) || (typeof b === 'string' ? b : vt('fields.rules.invalidFormat'))
58
+ case 'In':
59
+ return value => empty(value) || (Array.isArray(a) && a.includes(value)) || vt('fields.rules.notAllowed')
60
+ default:
61
+ return null
62
+ }
63
+ }
64
+
65
+ /** A PCRE pattern with its delimiters and flags, as the framework's Regex constraint takes it, as a JavaScript RegExp. */
66
+ function phpRegex(pattern: string): RegExp {
67
+ const match = pattern.match(/^(.)([\s\S]*)\1([a-zA-Z]*)$/)
68
+
69
+ if (!match) return new RegExp(pattern)
70
+
71
+ const flags = [...match[3]!].filter(f => 'gimsuy'.includes(f)).join('')
72
+
73
+ return new RegExp(match[2]!, flags)
74
+ }
75
+
76
+ /**
77
+ * What a field's conditions decide under the form's current values: the
78
+ * server evaluates the same conditions the same way, comparing a
79
+ * relation by its id, a boolean as true/false, anything else as text.
80
+ */
81
+ export function fieldState(field: VectorField, values: Record<string, unknown>): VectorFieldState {
82
+ const c = field.conditions
83
+ if (!c) return { visible: true, required: false, disabled: false }
84
+ return {
85
+ visible: holds(c.visible, values),
86
+ required: c.required.length > 0 && holds(c.required, values),
87
+ disabled: c.disabled.length > 0 && holds(c.disabled, values),
88
+ }
89
+ }
90
+
91
+ function holds(conditions: VectorCondition[], values: Record<string, unknown>): boolean {
92
+ return conditions.every((condition) => {
93
+ const actual = normalize(values[condition.field])
94
+ const list = Array.isArray(condition.value) ? condition.value.map(normalize) : [normalize(condition.value)]
95
+ switch (condition.op) {
96
+ case 'eq': return actual === normalize(condition.value)
97
+ case 'ne': return actual !== normalize(condition.value)
98
+ case 'in': return list.includes(actual)
99
+ case 'nin': return !list.includes(actual)
100
+ case 'null': return actual === null
101
+ case 'nnull': return actual !== null
102
+ default: return false
103
+ }
104
+ })
105
+ }
106
+
107
+ function normalize(value: unknown): string | null {
108
+ if (value && typeof value === 'object' && 'id' in value) value = (value as { id: unknown }).id
109
+ if (value === null || value === undefined || value === '') return null
110
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
111
+ if (typeof value === 'number' || typeof value === 'string') return String(value)
112
+ return null
113
+ }
@@ -0,0 +1,45 @@
1
+ import type { I18n } from 'vue-i18n'
2
+
3
+ // The admin's translator, set once by the vector-i18n plugin. The UI runs in
4
+ // the browser only (ssr: false), one app per page, so a module-level instance
5
+ // is safe and lets utilities, stores and async handlers translate outside a
6
+ // component's setup.
7
+ let instance: VectorI18n | null = null
8
+
9
+ /** The admin's vue-i18n instance, in composition mode: what the plugin provides as `$vectorI18n`. */
10
+ export type VectorI18n = I18n<Record<string, unknown>, Record<string, unknown>, Record<string, unknown>, string, false>
11
+
12
+ export function setVectorI18n(i18n: VectorI18n): void {
13
+ instance = i18n
14
+ }
15
+
16
+ /**
17
+ * Translates one of the admin's own strings: `vt('list.selected', { count }, count)`.
18
+ * `{name}` interpolates; `a | b | c` pluralizes on the count passed last.
19
+ * Before the plugin runs (never in practice) the key itself comes back.
20
+ */
21
+ export function vt(key: string, params: Record<string, unknown> = {}, plural?: number): string {
22
+ if (!instance) return key
23
+ return plural === undefined ? instance.global.t(key, params) : instance.global.t(key, params, plural)
24
+ }
25
+
26
+ /**
27
+ * A resource label as it reads inside a sentence (`Create product`,
28
+ * `Delete 3 products?`): lower-cased in the current language, except
29
+ * where nouns keep their capital — German writes every noun with one.
30
+ */
31
+ export function vnoun(label: string): string {
32
+ const locale = instance ? String(instance.global.locale.value) : 'en'
33
+ return locale === 'de' || locale.startsWith('de-') ? label : label.toLocaleLowerCase(locale)
34
+ }
35
+
36
+ // `$vectorI18n` is declared here rather than left to Nuxt's generated plugin
37
+ // types: NuxtApp inherits those from the type of every plugin module, a layer's
38
+ // own plugins among them, and resolving that is circular — it leaves
39
+ // `useNuxtApp().$vectorI18n` unknown. Kept out of the plugin module, which is one
40
+ // of those bases itself.
41
+ declare module '#app' {
42
+ interface NuxtApp {
43
+ $vectorI18n: VectorI18n
44
+ }
45
+ }
@@ -0,0 +1,11 @@
1
+ /** Formats a widget value by its declared format: number, money:CUR, percent, duration. */
2
+ export function formatWidgetValue(value: number | null | undefined, format: string | undefined): string {
3
+ if (value === null || value === undefined) return vt('common.none')
4
+ if (format?.startsWith('money:')) return formatMoney(Math.round(value), format.slice(6))
5
+ if (format === 'percent') return vt('dashboard.percent', { value: formatNumber(value, 1) })
6
+ if (format === 'duration') {
7
+ const minutes = Math.round(value / 60)
8
+ return minutes < 60 ? vt('dashboard.duration.minutes', { value: minutes }) : vt('dashboard.duration.hours', { value: formatNumber(minutes / 60, 1) })
9
+ }
10
+ return formatNumber(value, Number.isInteger(value) ? undefined : 2)
11
+ }
package/nuxt.config.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { fileURLToPath } from 'node:url'
2
+
3
+ // Paths inside a layer's config resolve against the consuming app, so
4
+ // anything that names a file in this package goes through this alias.
5
+ const layerDir = fileURLToPath(new URL('./', import.meta.url))
6
+
7
+ export default defineNuxtConfig({
8
+ $meta: {
9
+ name: 'vector',
10
+ },
11
+
12
+ modules: ['vuetify-nuxt-module', '@pinia/nuxt', '@vueuse/nuxt'],
13
+
14
+ // A back-office application: rendered in the browser only, hostable
15
+ // as static files (standalone) or from the PHP application (hosted).
16
+ ssr: false,
17
+
18
+ runtimeConfig: {
19
+ public: {
20
+ vector: {
21
+ // Overridden by NUXT_PUBLIC_VECTOR_API_BASE.
22
+ apiBase: 'http://localhost:8080',
23
+ // Runtime UI extensions (ES-module URLs) in standalone mode; hosted mode reads VECTOR_UI_EXTENSIONS.
24
+ extensions: [] as string[],
25
+ },
26
+ },
27
+ },
28
+
29
+ vuetify: {
30
+ moduleOptions: {
31
+ prefixComposables: true,
32
+ },
33
+ vuetifyOptions: {
34
+ theme: {
35
+ defaultTheme: 'system',
36
+ themes: {
37
+ // medium-emphasis text at 0.74 (Vuetify's 0.6 default) keeps subtitles and hints above WCAG AA contrast
38
+ light: {
39
+ dark: false,
40
+ colors: {
41
+ primary: '#1F5F8B',
42
+ secondary: '#546E7A',
43
+ // Status colours dark enough for white text at WCAG AA (4.5:1): Vuetify's
44
+ // defaults (#4CAF50, #FB8C00, #2196F3) fall below it on toasts and chips.
45
+ success: '#2E7D32',
46
+ warning: '#B45309',
47
+ info: '#1565C0',
48
+ error: '#C62828',
49
+ },
50
+ variables: { 'medium-emphasis-opacity': 0.74 },
51
+ },
52
+ dark: {
53
+ dark: true,
54
+ colors: {
55
+ primary: '#5AA9E6',
56
+ secondary: '#90A4AE',
57
+ },
58
+ variables: { 'medium-emphasis-opacity': 0.8 },
59
+ },
60
+ },
61
+ },
62
+ // The house style every generated view shares; a consuming app
63
+ // overrides any entry in its own nuxt.config.
64
+ defaults: {
65
+ VTextField: { variant: 'outlined', density: 'comfortable' },
66
+ VTextarea: { variant: 'outlined', density: 'comfortable' },
67
+ VSelect: { variant: 'outlined', density: 'comfortable' },
68
+ VAutocomplete: { variant: 'outlined', density: 'comfortable' },
69
+ VNumberInput: { variant: 'outlined', density: 'comfortable' },
70
+ VBtn: { variant: 'flat' },
71
+ VCard: { variant: 'flat', border: true },
72
+ },
73
+ },
74
+ },
75
+
76
+ typescript: {
77
+ strict: true,
78
+ },
79
+
80
+ alias: {
81
+ '#vector': layerDir,
82
+ },
83
+ })
package/package.json CHANGED
@@ -1,6 +1,84 @@
1
1
  {
2
- "name": "@kinetis/vector-ui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@kinetis/vector-ui",
3
+ "version": "0.9.1",
4
+ "description": "The Kinetis Vector admin UI, as a Nuxt layer on Vuetify.",
5
+ "keywords": [
6
+ "kinetis",
7
+ "vector",
8
+ "admin",
9
+ "nuxt",
10
+ "nuxt-layer",
11
+ "vuetify"
12
+ ],
13
+ "homepage": "https://kinetis.dev/vector/docs/",
14
+ "bugs": {
15
+ "url": "https://github.com/kinetis-dev/vector-dev/issues"
16
+ },
17
+ "license": "MIT",
18
+ "author": "Alon Noy",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/kinetis-dev/vector-dev.git",
22
+ "directory": "packages/vector-ui"
23
+ },
24
+ "type": "module",
25
+ "main": "./nuxt.config.ts",
26
+ "files": [
27
+ "nuxt.config.ts",
28
+ "app",
29
+ "README.md",
30
+ "LICENSE",
31
+ "CHANGELOG.md"
32
+ ],
33
+ "publishConfig": {
34
+ "access": "public",
35
+ "provenance": true
36
+ },
37
+ "scripts": {
38
+ "prepare": "nuxt prepare",
39
+ "typecheck": "nuxt prepare && vue-tsc --noEmit",
40
+ "lint": "eslint .",
41
+ "test": "vitest run --passWithNoTests"
42
+ },
43
+ "dependencies": {
44
+ "@mdi/font": "^7.4.47",
45
+ "@pinia/nuxt": "^1.0.2",
46
+ "@tiptap/core": "^3.32.0",
47
+ "@tiptap/extension-image": "^3.32.0",
48
+ "@tiptap/extension-link": "^3.32.0",
49
+ "@tiptap/extension-mention": "^3.32.0",
50
+ "@tiptap/extension-placeholder": "^3.32.0",
51
+ "@tiptap/extension-table": "^3.32.0",
52
+ "@tiptap/markdown": "^3.32.0",
53
+ "@tiptap/pm": "^3.32.0",
54
+ "@tiptap/starter-kit": "^3.32.0",
55
+ "@tiptap/suggestion": "^3.32.0",
56
+ "@tiptap/vue-3": "^3.32.0",
57
+ "@vueuse/core": "^15.0.0",
58
+ "@vueuse/nuxt": "^15.0.0",
59
+ "dompurify": "^3.4.16",
60
+ "markdown-it": "^15.0.2",
61
+ "pinia": "^4.0.3",
62
+ "vue-i18n": "^11.4.12"
63
+ },
64
+ "peerDependencies": {
65
+ "nuxt": "^4.5.0",
66
+ "vue": "^3.5.0",
67
+ "vuetify": "^4.2.0",
68
+ "vuetify-nuxt-module": "^1.0.0-rc.6"
69
+ },
70
+ "devDependencies": {
71
+ "@nuxt/test-utils": "^4.3.0",
72
+ "@types/node": "^22.0.0",
73
+ "@vue/test-utils": "^2.5.1",
74
+ "happy-dom": "^20.14.5",
75
+ "nuxt": "^4.5.2",
76
+ "sass-embedded": "^1.90.0",
77
+ "typescript": "~6.0.3",
78
+ "vitest": "^4.0.2",
79
+ "vue": "^3.5.0",
80
+ "vue-tsc": "^3.0.0",
81
+ "vuetify": "^4.2.2",
82
+ "vuetify-nuxt-module": "^1.0.0-rc.6"
83
+ }
84
+ }