@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,120 @@
1
+ <script setup lang="ts">
2
+ import { EditorContent, useEditor } from '@tiptap/vue-3'
3
+ import type { Editor } from '@tiptap/core'
4
+ import StarterKit from '@tiptap/starter-kit'
5
+ import { Markdown } from '@tiptap/markdown'
6
+ import type { VectorField } from '#vector/app/types/vector'
7
+
8
+ // A Markdown field, for technical content: edited WYSIWYG, stored as the
9
+ // Markdown source (the editor reads and writes it through @tiptap/markdown).
10
+ // "Source" shows the text itself, to edit what the toolbar does not offer.
11
+ const props = defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record?: Record<string, unknown>, resource?: string }>()
12
+ const model = defineModel<string | null>()
13
+
14
+ const { t } = useVectorT()
15
+ const toolbar = computed(() => props.field.markdown?.toolbar ?? ['bold', 'italic', 'strike', 'code', 'heading', 'list', 'blockquote', 'codeBlock', 'horizontalRule', 'link'])
16
+ const on = (button: string) => toolbar.value.includes(button)
17
+ const source = ref(false)
18
+ let emitting = false
19
+
20
+ const editor = useEditor({
21
+ extensions: [
22
+ StarterKit.configure({
23
+ link: { openOnClick: false, autolink: true, protocols: [], isAllowedUri: (url: string) => allowedHref(url) },
24
+ heading: { levels: [1, 2, 3, 4] },
25
+ }),
26
+ Markdown,
27
+ ],
28
+ injectNonce: import.meta.client ? (window.__VECTOR__?.cspNonce ?? undefined) : undefined,
29
+ editorProps: { attributes: { 'class': 'vx-richtext-content', 'aria-label': props.field.label, 'role': 'textbox', 'aria-multiline': 'true' } },
30
+ onCreate: ({ editor: current }) => {
31
+ current.commands.setContent(model.value ?? '', { contentType: 'markdown', emitUpdate: false })
32
+ },
33
+ onUpdate: ({ editor: current }) => {
34
+ const text = current.getMarkdown().trim()
35
+ emitting = true
36
+ model.value = text === '' ? null : text
37
+ },
38
+ })
39
+
40
+ watch(model, (value) => {
41
+ if (emitting) {
42
+ emitting = false
43
+ return
44
+ }
45
+ if (!source.value) editor.value?.commands.setContent(value ?? '', { contentType: 'markdown', emitUpdate: false })
46
+ })
47
+
48
+ // leaving the source view reads the text back into the editor
49
+ watch(source, (showing) => {
50
+ if (!showing) editor.value?.commands.setContent(model.value ?? '', { contentType: 'markdown', emitUpdate: false })
51
+ })
52
+
53
+ onBeforeUnmount(() => editor.value?.destroy())
54
+
55
+ const active = (name: string, attrs?: Record<string, unknown>) => editor.value?.isActive(name, attrs) ?? false
56
+ const run = (command: (e: Editor) => void) => {
57
+ if (editor.value) command(editor.value)
58
+ }
59
+
60
+ const linkMenu = ref(false)
61
+ const linkHref = ref('')
62
+ const linkError = computed(() => linkHref.value.trim() !== '' && !allowedHref(linkHref.value.trim()) ? t('fields.richtext.linkRefused') : null)
63
+ function openLink() {
64
+ linkHref.value = String(editor.value?.getAttributes('link').href ?? '')
65
+ linkMenu.value = true
66
+ }
67
+ function applyLink() {
68
+ const href = linkHref.value.trim()
69
+ if (linkError.value) return
70
+ run(e => href === '' ? e.chain().focus().unsetLink().run() : e.chain().focus().extendMarkRange('link').setLink({ href }).run())
71
+ linkMenu.value = false
72
+ }
73
+ </script>
74
+
75
+ <template>
76
+ <v-input :model-value="model" :rules="rules" :error-messages="errorMessages" :hint="field.help ?? undefined" :persistent-hint="!!field.help" class="vx-richtext">
77
+ <div class="vx-richtext-frame" :class="{ 'vx-richtext-frame--error': errorMessages.length > 0 }">
78
+ <div class="text-caption text-medium-emphasis px-3 pt-2">{{ field.label }}</div>
79
+ <div class="vx-richtext-toolbar d-flex flex-wrap align-center ga-1 px-2 py-1" role="toolbar" :aria-label="t('fields.richtext.toolbar', { label: field.label })">
80
+ <template v-if="!source">
81
+ <v-btn v-if="on('bold')" icon="mdi-format-bold" size="small" variant="text" :active="active('bold')" :aria-label="t('fields.richtext.bold')" @click="run(e => e.chain().focus().toggleBold().run())" />
82
+ <v-btn v-if="on('italic')" icon="mdi-format-italic" size="small" variant="text" :active="active('italic')" :aria-label="t('fields.richtext.italic')" @click="run(e => e.chain().focus().toggleItalic().run())" />
83
+ <v-btn v-if="on('strike')" icon="mdi-format-strikethrough" size="small" variant="text" :active="active('strike')" :aria-label="t('fields.richtext.strike')" @click="run(e => e.chain().focus().toggleStrike().run())" />
84
+ <v-btn v-if="on('code')" icon="mdi-code-tags" size="small" variant="text" :active="active('code')" :aria-label="t('fields.richtext.code')" @click="run(e => e.chain().focus().toggleCode().run())" />
85
+ <template v-if="on('heading')">
86
+ <v-divider vertical class="mx-1" />
87
+ <v-btn v-for="level in ([1, 2, 3] as const)" :key="level" :icon="`mdi-format-header-${level}`" size="small" variant="text" :active="active('heading', { level })" :aria-label="t('fields.richtext.heading', { level })" @click="run(e => e.chain().focus().toggleHeading({ level }).run())" />
88
+ </template>
89
+ <template v-if="on('list')">
90
+ <v-divider vertical class="mx-1" />
91
+ <v-btn icon="mdi-format-list-bulleted" size="small" variant="text" :active="active('bulletList')" :aria-label="t('fields.richtext.bulletList')" @click="run(e => e.chain().focus().toggleBulletList().run())" />
92
+ <v-btn icon="mdi-format-list-numbered" size="small" variant="text" :active="active('orderedList')" :aria-label="t('fields.richtext.orderedList')" @click="run(e => e.chain().focus().toggleOrderedList().run())" />
93
+ </template>
94
+ <v-btn v-if="on('blockquote')" icon="mdi-format-quote-close" size="small" variant="text" :active="active('blockquote')" :aria-label="t('fields.richtext.blockquote')" @click="run(e => e.chain().focus().toggleBlockquote().run())" />
95
+ <v-btn v-if="on('codeBlock')" icon="mdi-code-braces" size="small" variant="text" :active="active('codeBlock')" :aria-label="t('fields.richtext.codeBlock')" @click="run(e => e.chain().focus().toggleCodeBlock().run())" />
96
+ <v-btn v-if="on('horizontalRule')" icon="mdi-minus" size="small" variant="text" :aria-label="t('fields.richtext.horizontalRule')" @click="run(e => e.chain().focus().setHorizontalRule().run())" />
97
+ <v-menu v-if="on('link')" v-model="linkMenu" :close-on-content-click="false" location="bottom">
98
+ <template #activator="{ props: activator }">
99
+ <v-btn v-bind="activator" icon="mdi-link-variant" size="small" variant="text" :active="active('link')" :aria-label="t('fields.richtext.link')" @click="openLink" />
100
+ </template>
101
+ <v-card min-width="320" class="pa-3">
102
+ <v-text-field v-model="linkHref" :label="t('fields.richtext.linkUrl')" :error-messages="linkError ? [linkError] : []" autofocus @keydown.enter.prevent="applyLink" />
103
+ <div class="d-flex justify-end ga-2">
104
+ <v-btn variant="text" @click="linkHref = ''; applyLink()">{{ t('common.remove') }}</v-btn>
105
+ <v-btn color="primary" :disabled="!!linkError" @click="applyLink">{{ t('common.apply') }}</v-btn>
106
+ </div>
107
+ </v-card>
108
+ </v-menu>
109
+ </template>
110
+ <v-spacer />
111
+ <v-btn size="small" variant="text" :prepend-icon="source ? 'mdi-eye-outline' : 'mdi-language-markdown-outline'" :aria-pressed="source" @click="source = !source">
112
+ {{ source ? t('fields.richtext.visual') : t('fields.richtext.source') }}
113
+ </v-btn>
114
+ </div>
115
+ <v-divider />
116
+ <v-textarea v-if="source" v-model="model" :aria-label="t('fields.richtext.sourceOf', { label: field.label })" variant="plain" auto-grow rows="6" hide-details class="px-3 font-monospace" />
117
+ <EditorContent v-show="!source" :editor="editor" class="vx-richtext-editor" />
118
+ </div>
119
+ </v-input>
120
+ </template>
@@ -0,0 +1,47 @@
1
+ <script setup lang="ts">
2
+ import type { VectorField } from '#vector/app/types/vector'
3
+
4
+ // Edited as a decimal amount, held as minor units — the wire value, in as
5
+ // many decimals as the currency has (none for yen, three for dinars). The
6
+ // text is what was typed until the field is left; only then, when the
7
+ // value changes from outside, or when the currency changes, is it written
8
+ // out in full.
9
+ const props = defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record: Record<string, unknown> }>()
10
+ const model = defineModel<number | null>()
11
+
12
+ const currency = computed(() => currencyOf(props.field, props.record))
13
+
14
+ const digits = computed(() => minorDigits(currency.value))
15
+ const format = (value: number | null | undefined) => (typeof value === 'number' ? (value / 10 ** digits.value).toFixed(digits.value) : '')
16
+ const text = ref(format(model.value))
17
+ let typed: number | null | undefined = model.value
18
+
19
+ watch(model, (value) => {
20
+ if (value !== typed) text.value = format(value)
21
+ typed = value
22
+ })
23
+ // the same minor units read in another currency's decimals
24
+ watch(digits, () => { text.value = format(model.value) })
25
+
26
+ function input(value: string) {
27
+ text.value = value
28
+ const parsed = Number.parseFloat(String(value ?? '').replace(',', '.'))
29
+ typed = Number.isFinite(parsed) ? Math.round(parsed * 10 ** digits.value) : null
30
+ model.value = typed
31
+ }
32
+ </script>
33
+
34
+ <template>
35
+ <v-text-field
36
+ :model-value="text"
37
+ :label="field.label"
38
+ :rules="rules"
39
+ :error-messages="errorMessages"
40
+ :suffix="currency ?? undefined"
41
+ :inputmode="digits > 0 ? 'decimal' : 'numeric'"
42
+ :hint="field.help ?? undefined"
43
+ :persistent-hint="!!field.help"
44
+ @update:model-value="input"
45
+ @blur="text = format(model)"
46
+ />
47
+ </template>
@@ -0,0 +1,24 @@
1
+ <script setup lang="ts">
2
+ import { fieldProps, type VectorField } from '#vector/app/types/vector'
3
+
4
+ const props = defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record?: Record<string, unknown>, resource?: string }>()
5
+ const model = defineModel<number | null>()
6
+ const decimals = computed(() => {
7
+ const d = fieldProps(props.field).decimals
8
+ return typeof d === 'number' ? d : (props.field.valueType === 'float' ? 2 : 0)
9
+ })
10
+ </script>
11
+
12
+ <template>
13
+ <v-number-input
14
+ v-model="model"
15
+ :label="field.label"
16
+ :rules="rules"
17
+ :error-messages="errorMessages"
18
+ :precision="decimals"
19
+ :step="decimals > 0 ? 1 / 10 ** decimals : 1"
20
+ :hint="field.help ?? undefined"
21
+ :persistent-hint="!!field.help"
22
+ control-variant="stacked"
23
+ />
24
+ </template>
@@ -0,0 +1,127 @@
1
+ <script setup lang="ts">
2
+ import type { VectorField, VectorRelationValue } from '#vector/app/types/vector'
3
+
4
+ // belongsTo edits one identifier, manyToMany a list; both search the
5
+ // target's title on the server, debounced, dropping stale answers. A
6
+ // dependent choice list searches with the form's value of the field it
7
+ // depends on, waits for one, and drops its choice when that value changes.
8
+ // An option shows the target's subtitle and thumbnail when it has them;
9
+ // thumbnails come through the API client as data URLs, like image cells.
10
+ const props = defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], resource: string, record?: Record<string, unknown>, disabled?: boolean }>()
11
+ const model = defineModel<number | string | (number | string)[] | null>()
12
+ const multiple = computed(() => props.field.type === 'manyToMany')
13
+ const client = useVectorResource(() => props.resource)
14
+ const { t, noun } = useVectorT()
15
+
16
+ const items = ref<VectorRelationValue[]>([])
17
+ const target = useVectorResource(() => props.field.relation?.resource ?? props.resource)
18
+ const thumbs = reactive<Record<string, string>>({})
19
+ const requested = new Set<string>()
20
+
21
+ function loadThumbs(options: VectorRelationValue[]) {
22
+ for (const option of options) {
23
+ const key = String(option.id)
24
+ if (!option.image || requested.has(key)) continue
25
+ requested.add(key)
26
+ target.fileBlob(option.id, option.image).then(dataUrl).then((url) => { thumbs[key] = url }).catch(() => {})
27
+ }
28
+ }
29
+
30
+ const search = ref('')
31
+ const loading = ref(false)
32
+ let requestId = 0
33
+
34
+ const dependsOn = computed(() => props.field.relation?.dependsOn ?? null)
35
+ const dependency = computed<string | number | null>(() => {
36
+ if (!dependsOn.value) return null
37
+ const value = props.record?.[dependsOn.value.field]
38
+ const key = value && typeof value === 'object' && 'id' in value ? (value as { id: unknown }).id : value
39
+ return typeof key === 'number' || (typeof key === 'string' && key !== '') ? key : typeof key === 'boolean' ? Number(key) : null
40
+ })
41
+ const waiting = computed(() => dependsOn.value !== null && dependency.value === null)
42
+
43
+ async function fetchOptions(query: string, ids: (number | string)[] = []) {
44
+ if (waiting.value && !ids.length) {
45
+ items.value = items.value.filter(i => isSelected(i.id))
46
+ return
47
+ }
48
+ const id = ++requestId
49
+ loading.value = true
50
+ try {
51
+ const depends = dependsOn.value && dependency.value !== null ? { [dependsOn.value.field]: dependency.value } : {}
52
+ const result = await client.options(props.field.name, query, ids, depends)
53
+ if (id === requestId) {
54
+ // keep the selected entries visible while the list changes
55
+ const selected = items.value.filter(i => isSelected(i.id))
56
+ const merged = [...selected, ...result.data.filter(r => !selected.some(s => s.id === r.id))]
57
+ items.value = merged
58
+ loadThumbs(result.data)
59
+ }
60
+ } finally {
61
+ if (id === requestId) loading.value = false
62
+ }
63
+ }
64
+
65
+ function isSelected(id: number | string) {
66
+ return multiple.value ? Array.isArray(model.value) && model.value.includes(id) : model.value === id
67
+ }
68
+
69
+ const debounced = useDebounceFn((q: string) => fetchOptions(q), 250)
70
+ watch(search, (q) => { if (typeof q === 'string') debounced(q) })
71
+
72
+ // Another team, other agents: a choice made for the old value no longer holds.
73
+ watch(dependency, (now, before) => {
74
+ if (now === before) return
75
+ if (!multiple.value && model.value !== null && model.value !== undefined) model.value = null
76
+ items.value = []
77
+ fetchOptions('')
78
+ })
79
+
80
+ // Resolve the labels of an initial value once.
81
+ onMounted(async () => {
82
+ const initial: (number | string)[] = multiple.value
83
+ ? (Array.isArray(model.value) ? model.value : [])
84
+ : (model.value === null || model.value === undefined || Array.isArray(model.value) ? [] : [model.value])
85
+ await fetchOptions('', initial.length ? initial : [])
86
+ if (initial.length) await fetchOptions('')
87
+ })
88
+ </script>
89
+
90
+ <template>
91
+ <v-autocomplete
92
+ v-model="model"
93
+ v-model:search="search"
94
+ :label="field.label"
95
+ :items="items"
96
+ item-title="label"
97
+ item-value="id"
98
+ :multiple="multiple"
99
+ :chips="multiple"
100
+ :closable-chips="multiple"
101
+ :loading="loading"
102
+ :rules="rules"
103
+ :error-messages="errorMessages"
104
+ :clearable="field.nullable === true || multiple"
105
+ :disabled="disabled || waiting"
106
+ no-filter
107
+ :hint="waiting ? t('fields.relation.dependsFirst', { label: noun(dependsOn!.label) }) : (field.help ?? undefined)"
108
+ :persistent-hint="waiting || !!field.help"
109
+ :placeholder="t('fields.relation.placeholder')"
110
+ >
111
+ <template #item="{ props: itemProps, item }">
112
+ <v-list-item v-bind="itemProps" :subtitle="(item as VectorRelationValue).subtitle ?? undefined">
113
+ <template v-if="thumbs[String((item as VectorRelationValue).id)]" #prepend>
114
+ <v-avatar rounded size="32"><v-img :src="thumbs[String((item as VectorRelationValue).id)]" alt="" cover /></v-avatar>
115
+ </template>
116
+ </v-list-item>
117
+ </template>
118
+ <template v-if="multiple" #chip="{ props: chipProps, item }">
119
+ <v-chip v-bind="chipProps" :prepend-avatar="thumbs[String((item as VectorRelationValue).id)]" :title="(item as VectorRelationValue).subtitle ?? undefined" />
120
+ </template>
121
+ <template v-else #selection="{ item }">
122
+ <v-avatar v-if="thumbs[String((item as VectorRelationValue).id)]" rounded size="24" class="me-2"><v-img :src="thumbs[String((item as VectorRelationValue).id)]" alt="" cover /></v-avatar>
123
+ <span>{{ (item as VectorRelationValue).label }}</span>
124
+ <span v-if="(item as VectorRelationValue).subtitle" class="text-medium-emphasis ms-2 vx-relation-subtitle">{{ (item as VectorRelationValue).subtitle }}</span>
125
+ </template>
126
+ </v-autocomplete>
127
+ </template>
@@ -0,0 +1,134 @@
1
+ <script setup lang="ts">
2
+ import type { VectorField } from '#vector/app/types/vector'
3
+
4
+ // An owned relation's rows edited inside the parent's form: one line of
5
+ // the child's inputs per row, added and removed within the repeater's
6
+ // bounds. The form sends the whole list; a row keeps its `id`, so the
7
+ // server updates that child, and a row without one is created.
8
+ const props = defineProps<{
9
+ field: VectorField
10
+ /** the form's rules for the field itself; a row's inputs carry their own */
11
+ rules?: unknown[]
12
+ errorMessages: string[]
13
+ resource: string
14
+ record?: Record<string, unknown>
15
+ /** the form's server errors, keyed `field.row.column` for a row's own */
16
+ serverErrors?: Record<string, string[]>
17
+ disabled?: boolean
18
+ }>()
19
+ const model = defineModel<Record<string, unknown>[] | null>()
20
+
21
+ const { t, noun } = useVectorT()
22
+ const { resource: resourceOf } = useVectorSchema()
23
+ const { inputFor } = useVectorInputs()
24
+
25
+ const child = computed(() => props.field.relation ? resourceOf(props.field.relation.resource) : undefined)
26
+ const columns = computed<VectorField[]>(() => (props.field.repeater?.fields ?? [])
27
+ .map(name => child.value?.fields.find(f => f.name === name))
28
+ .filter((f): f is VectorField => f !== undefined))
29
+ const min = computed(() => props.field.repeater?.min ?? 0)
30
+ const max = computed(() => props.field.repeater?.max ?? 100)
31
+ const rows = computed(() => (Array.isArray(model.value) ? model.value : []))
32
+ const width = computed(() => Math.max(3, Math.floor(12 / Math.max(1, Math.min(columns.value.length, 4)))))
33
+
34
+ // Stable keys, so removing a row keeps every other row's inputs (and a
35
+ // relation input's loaded label) with the row it belongs to.
36
+ let next = 0
37
+ const keys = ref<number[]>([])
38
+ watch(() => rows.value.length, (length) => {
39
+ if (keys.value.length !== length) keys.value = Array.from({ length }, () => next++)
40
+ }, { immediate: true })
41
+
42
+ function blank(): Record<string, unknown> {
43
+ return Object.fromEntries(columns.value.map(f => [f.name, f.hasDefault ? f.default : f.type === 'boolean' ? false : null]))
44
+ }
45
+
46
+ function add() {
47
+ keys.value = [...keys.value, next++]
48
+ model.value = [...rows.value, blank()]
49
+ }
50
+
51
+ function remove(index: number) {
52
+ keys.value = keys.value.filter((_, i) => i !== index)
53
+ model.value = rows.value.filter((_, i) => i !== index)
54
+ clearRowErrors()
55
+ }
56
+
57
+ function set(index: number, name: string, value: unknown) {
58
+ model.value = rows.value.map((row, i) => (i === index ? { ...row, [name]: value } : row))
59
+ // The form owns the server errors and lets each input clear its own.
60
+ // eslint-disable-next-line vue/no-mutating-props
61
+ if (props.serverErrors) delete props.serverErrors[`${props.field.name}.${index}.${name}`]
62
+ }
63
+
64
+ /** Row errors are keyed by position, which a removal shifts: they go with it. */
65
+ function clearRowErrors() {
66
+ for (const key of Object.keys(props.serverErrors ?? {})) {
67
+ if (key.startsWith(`${props.field.name}.`)) delete props.serverErrors![key]
68
+ }
69
+ }
70
+
71
+ // A row's inputs read the record they belong to through the relation back
72
+ // to it: a line priced in `order.currency` sees the order being edited.
73
+ const inverse = computed(() => props.field.relation?.inverse)
74
+ const rowRecord = (row: Record<string, unknown>) => (inverse.value ? { ...row, [inverse.value]: props.record ?? {} } : row)
75
+
76
+ const rowErrors = (index: number, name: string) => props.serverErrors?.[`${props.field.name}.${index}.${name}`] ?? []
77
+
78
+ // A new record starts with the rows it must hold.
79
+ onMounted(() => {
80
+ if (child.value && rows.value.length < min.value && !props.record?.id) {
81
+ model.value = [...rows.value, ...Array.from({ length: min.value - rows.value.length }, blank)]
82
+ }
83
+ })
84
+ </script>
85
+
86
+ <template>
87
+ <v-card variant="outlined" class="pa-3" role="group" :aria-label="field.label">
88
+ <div class="d-flex align-center mb-2">
89
+ <span class="text-subtitle-2">{{ field.label }}</span>
90
+ <v-spacer />
91
+ <span class="text-caption text-medium-emphasis">{{ t('fields.repeater.count', { count: rows.length, max }) }}</span>
92
+ </div>
93
+ <v-alert v-for="message in errorMessages" :key="message" type="error" variant="tonal" density="compact" class="mb-2" :text="message" />
94
+ <p v-if="!child" class="text-medium-emphasis text-body-2">{{ t('fields.repeater.unavailable') }}</p>
95
+ <template v-else>
96
+ <div
97
+ v-for="(row, index) in rows"
98
+ :key="keys[index]"
99
+ class="d-flex ga-2 align-start mb-1"
100
+ role="group"
101
+ :aria-label="t('fields.repeater.row', { label: child.labelSingular, n: index + 1 })"
102
+ >
103
+ <v-row density="compact" class="flex-grow-1">
104
+ <v-col v-for="column in columns" :key="column.name" cols="12" :md="width">
105
+ <component
106
+ :is="inputFor(column)"
107
+ :model-value="row[column.name]"
108
+ :field="column"
109
+ :rules="rulesFor(column, row.id ? 'update' : 'create')"
110
+ :error-messages="rowErrors(index, column.name)"
111
+ :record="rowRecord(row)"
112
+ :resource="child.slug"
113
+ :disabled="disabled"
114
+ density="compact"
115
+ @update:model-value="(value: unknown) => set(index, column.name, value)"
116
+ />
117
+ </v-col>
118
+ </v-row>
119
+ <v-btn
120
+ icon="mdi-close"
121
+ variant="text"
122
+ size="small"
123
+ class="mt-2"
124
+ :disabled="disabled || rows.length <= min"
125
+ :aria-label="t('fields.repeater.remove', { label: noun(child.labelSingular), n: index + 1 })"
126
+ @click="remove(index)"
127
+ />
128
+ </div>
129
+ <v-btn variant="tonal" size="small" prepend-icon="mdi-plus" :disabled="disabled || rows.length >= max" @click="add">
130
+ {{ t('fields.repeater.add', { label: noun(child.labelSingular) }) }}
131
+ </v-btn>
132
+ </template>
133
+ </v-card>
134
+ </template>
@@ -0,0 +1,182 @@
1
+ <script setup lang="ts">
2
+ import { EditorContent, useEditor } from '@tiptap/vue-3'
3
+ import type { Editor } from '@tiptap/core'
4
+ import type { VectorField } from '#vector/app/types/vector'
5
+
6
+ // A rich-text field: a Tiptap editor over exactly the field's schema. Its
7
+ // value is the document as JSON text — null when empty — which the server
8
+ // validates against the same schema. Pasted content outside the schema is
9
+ // dropped by construction: ProseMirror keeps only what the schema holds.
10
+ // An image is uploaded through the field's image field and stored by its
11
+ // key; a mention picks a record of the field's `mentions` relation.
12
+ const props = defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record?: Record<string, unknown>, resource?: string }>()
13
+ const model = defineModel<string | null>()
14
+
15
+ const { t } = useVectorT()
16
+ const client = useVectorResource(() => props.resource ?? '')
17
+ const { richText: custom } = useVectorExtensions()
18
+ const rich = computed(() => props.field.richtext ?? { features: [], imageField: null, mentions: null, storesHtml: false, maxBytes: 262_144 })
19
+ const on = (feature: string) => rich.value.features.includes(feature)
20
+
21
+ // images uploaded in this form, shown from the picked file until the record is saved
22
+ const uploaded = new Map<string, string>()
23
+
24
+ async function imageSource(key: string): Promise<string | null> {
25
+ if (uploaded.has(key)) return uploaded.get(key)!
26
+ const id = props.record?.id
27
+ if (id === undefined || id === null || !rich.value.imageField) return null
28
+ return await dataUrl(await client.fileBlob(id as string | number, rich.value.imageField, key))
29
+ }
30
+
31
+ async function mentionItems(query: string) {
32
+ if (!rich.value.mentions) return []
33
+ return (await client.options(rich.value.mentions, query)).data.slice(0, 8).map(o => ({ id: o.id, label: o.label }))
34
+ }
35
+
36
+ const extensions = richTextExtensions({ features: rich.value.features, placeholder: props.field.help ?? undefined, imageSource, mentionItems, custom })
37
+ let emitting = false
38
+
39
+ const editor = useEditor({
40
+ content: model.value ? toDocument(model.value, extensions) : null,
41
+ extensions,
42
+ // Tiptap's own style element carries the hosted shell's nonce, so a strict style-src admits it
43
+ injectNonce: import.meta.client ? (window.__VECTOR__?.cspNonce ?? undefined) : undefined,
44
+ editorProps: { attributes: { 'class': 'vx-richtext-content', 'aria-label': props.field.label, 'role': 'textbox', 'aria-multiline': 'true' } },
45
+ onUpdate: ({ editor: current }) => {
46
+ const doc = current.getJSON()
47
+ emitting = true
48
+ model.value = isEmptyDocument(doc) ? null : JSON.stringify(doc)
49
+ },
50
+ })
51
+
52
+ // a value set from outside (a reset, a repeater row) replaces the content
53
+ watch(model, (value) => {
54
+ if (emitting) {
55
+ emitting = false
56
+ return
57
+ }
58
+ editor.value?.commands.setContent(value ? toDocument(value, extensions) : null, { emitUpdate: false })
59
+ })
60
+
61
+ onBeforeUnmount(() => editor.value?.destroy())
62
+
63
+ const active = (name: string, attrs?: Record<string, unknown>) => editor.value?.isActive(name, attrs) ?? false
64
+ const run = (command: (e: Editor) => void) => {
65
+ if (editor.value) command(editor.value)
66
+ }
67
+
68
+ // links: http(s), mailto or relative only, as the server requires
69
+ const linkMenu = ref(false)
70
+ const linkHref = ref('')
71
+ const linkError = computed(() => linkHref.value.trim() !== '' && !allowedHref(linkHref.value.trim()) ? t('fields.richtext.linkRefused') : null)
72
+ function openLink() {
73
+ linkHref.value = String(editor.value?.getAttributes('link').href ?? '')
74
+ linkMenu.value = true
75
+ }
76
+ function applyLink() {
77
+ const href = linkHref.value.trim()
78
+ if (linkError.value) return
79
+ run(e => href === '' ? e.chain().focus().unsetLink().run() : e.chain().focus().extendMarkRange('link').setLink({ href }).run())
80
+ linkMenu.value = false
81
+ }
82
+
83
+ // images: uploaded at once through the image field, stored by key
84
+ const picker = ref<HTMLInputElement | null>(null)
85
+ const uploading = ref(false)
86
+ const uploadError = ref<string | null>(null)
87
+ async function insertImage(event: Event) {
88
+ const file = (event.target as HTMLInputElement).files?.[0]
89
+ ;(event.target as HTMLInputElement).value = ''
90
+ if (!file || !rich.value.imageField) return
91
+ uploading.value = true
92
+ uploadError.value = null
93
+ try {
94
+ const { key } = await client.upload(rich.value.imageField, file)
95
+ uploaded.set(key, await dataUrl(file))
96
+ run(e => e.chain().focus().setImage({ src: key, alt: file.name }).run())
97
+ } catch (error) {
98
+ uploadError.value = vectorErrorMessage(error)
99
+ } finally {
100
+ uploading.value = false
101
+ }
102
+ }
103
+
104
+ const inTable = computed(() => active('table'))
105
+ </script>
106
+
107
+ <template>
108
+ <v-input :model-value="model" :rules="rules" :error-messages="uploadError ? [uploadError, ...errorMessages] : errorMessages" :hint="field.help ?? undefined" :persistent-hint="!!field.help" class="vx-richtext">
109
+ <div class="vx-richtext-frame" :class="{ 'vx-richtext-frame--error': errorMessages.length > 0 }">
110
+ <div class="text-caption text-medium-emphasis px-3 pt-2">{{ field.label }}</div>
111
+ <div class="vx-richtext-toolbar d-flex flex-wrap align-center ga-1 px-2 py-1" role="toolbar" :aria-label="t('fields.richtext.toolbar', { label: field.label })">
112
+ <v-btn v-if="on('bold')" icon="mdi-format-bold" size="small" variant="text" :active="active('bold')" :aria-label="t('fields.richtext.bold')" @click="run(e => e.chain().focus().toggleBold().run())" />
113
+ <v-btn v-if="on('italic')" icon="mdi-format-italic" size="small" variant="text" :active="active('italic')" :aria-label="t('fields.richtext.italic')" @click="run(e => e.chain().focus().toggleItalic().run())" />
114
+ <v-btn v-if="on('underline')" icon="mdi-format-underline" size="small" variant="text" :active="active('underline')" :aria-label="t('fields.richtext.underline')" @click="run(e => e.chain().focus().toggleUnderline().run())" />
115
+ <v-btn v-if="on('strike')" icon="mdi-format-strikethrough" size="small" variant="text" :active="active('strike')" :aria-label="t('fields.richtext.strike')" @click="run(e => e.chain().focus().toggleStrike().run())" />
116
+ <v-btn v-if="on('code')" icon="mdi-code-tags" size="small" variant="text" :active="active('code')" :aria-label="t('fields.richtext.code')" @click="run(e => e.chain().focus().toggleCode().run())" />
117
+ <template v-if="on('heading')">
118
+ <v-divider vertical class="mx-1" />
119
+ <v-btn v-for="level in ([1, 2, 3] as const)" :key="level" :icon="`mdi-format-header-${level}`" size="small" variant="text" :active="active('heading', { level })" :aria-label="t('fields.richtext.heading', { level })" @click="run(e => e.chain().focus().toggleHeading({ level }).run())" />
120
+ </template>
121
+ <template v-if="on('list')">
122
+ <v-divider vertical class="mx-1" />
123
+ <v-btn icon="mdi-format-list-bulleted" size="small" variant="text" :active="active('bulletList')" :aria-label="t('fields.richtext.bulletList')" @click="run(e => e.chain().focus().toggleBulletList().run())" />
124
+ <v-btn icon="mdi-format-list-numbered" size="small" variant="text" :active="active('orderedList')" :aria-label="t('fields.richtext.orderedList')" @click="run(e => e.chain().focus().toggleOrderedList().run())" />
125
+ </template>
126
+ <v-btn v-if="on('blockquote')" icon="mdi-format-quote-close" size="small" variant="text" :active="active('blockquote')" :aria-label="t('fields.richtext.blockquote')" @click="run(e => e.chain().focus().toggleBlockquote().run())" />
127
+ <v-btn v-if="on('codeBlock')" icon="mdi-code-braces" size="small" variant="text" :active="active('codeBlock')" :aria-label="t('fields.richtext.codeBlock')" @click="run(e => e.chain().focus().toggleCodeBlock().run())" />
128
+ <v-btn v-if="on('horizontalRule')" icon="mdi-minus" size="small" variant="text" :aria-label="t('fields.richtext.horizontalRule')" @click="run(e => e.chain().focus().setHorizontalRule().run())" />
129
+ <v-menu v-if="on('link')" v-model="linkMenu" :close-on-content-click="false" location="bottom">
130
+ <template #activator="{ props: activator }">
131
+ <v-btn v-bind="activator" icon="mdi-link-variant" size="small" variant="text" :active="active('link')" :aria-label="t('fields.richtext.link')" @click="openLink" />
132
+ </template>
133
+ <v-card min-width="320" class="pa-3">
134
+ <v-text-field v-model="linkHref" :label="t('fields.richtext.linkUrl')" :error-messages="linkError ? [linkError] : []" autofocus @keydown.enter.prevent="applyLink" />
135
+ <div class="d-flex justify-end ga-2">
136
+ <v-btn variant="text" @click="linkHref = ''; applyLink()">{{ t('common.remove') }}</v-btn>
137
+ <v-btn color="primary" :disabled="!!linkError" @click="applyLink">{{ t('common.apply') }}</v-btn>
138
+ </div>
139
+ </v-card>
140
+ </v-menu>
141
+ <template v-if="on('table')">
142
+ <v-divider vertical class="mx-1" />
143
+ <v-btn icon="mdi-table-plus" size="small" variant="text" :aria-label="t('fields.richtext.insertTable')" @click="run(e => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run())" />
144
+ <template v-if="inTable">
145
+ <v-btn icon="mdi-table-row-plus-after" size="small" variant="text" :aria-label="t('fields.richtext.addRow')" @click="run(e => e.chain().focus().addRowAfter().run())" />
146
+ <v-btn icon="mdi-table-column-plus-after" size="small" variant="text" :aria-label="t('fields.richtext.addColumn')" @click="run(e => e.chain().focus().addColumnAfter().run())" />
147
+ <v-btn icon="mdi-table-row-remove" size="small" variant="text" :aria-label="t('fields.richtext.deleteRow')" @click="run(e => e.chain().focus().deleteRow().run())" />
148
+ <v-btn icon="mdi-table-column-remove" size="small" variant="text" :aria-label="t('fields.richtext.deleteColumn')" @click="run(e => e.chain().focus().deleteColumn().run())" />
149
+ <v-btn icon="mdi-table-remove" size="small" variant="text" :aria-label="t('fields.richtext.deleteTable')" @click="run(e => e.chain().focus().deleteTable().run())" />
150
+ </template>
151
+ </template>
152
+ <template v-if="on('image') && rich.imageField">
153
+ <v-btn icon="mdi-image-plus" size="small" variant="text" :loading="uploading" :aria-label="t('fields.richtext.image')" @click="picker?.click()" />
154
+ <input ref="picker" type="file" accept="image/png,image/jpeg,image/webp,image/gif" class="d-none" @change="insertImage">
155
+ </template>
156
+ <v-spacer />
157
+ <v-btn icon="mdi-undo" size="small" variant="text" :aria-label="t('fields.richtext.undo')" @click="run(e => e.chain().focus().undo().run())" />
158
+ <v-btn icon="mdi-redo" size="small" variant="text" :aria-label="t('fields.richtext.redo')" @click="run(e => e.chain().focus().redo().run())" />
159
+ </div>
160
+ <v-divider />
161
+ <EditorContent :editor="editor" class="vx-richtext-editor" />
162
+ </div>
163
+ </v-input>
164
+ </template>
165
+
166
+ <style>
167
+ .vx-richtext .v-input__control { display: block; }
168
+ .vx-richtext-frame { border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)); border-radius: 4px; width: 100%; }
169
+ .vx-richtext-frame--error { border-color: rgb(var(--v-theme-error)); }
170
+ .vx-richtext-editor .vx-richtext-content { min-height: 8rem; padding: 0.75rem 1rem; outline: none; }
171
+ .vx-richtext-content p.is-editor-empty:first-child::before { content: attr(data-placeholder); float: inline-start; height: 0; pointer-events: none; opacity: 0.5; }
172
+ .vx-richtext-content table, .vx-richtext-view table { border-collapse: collapse; margin: 0.5rem 0; }
173
+ .vx-richtext-content td, .vx-richtext-content th, .vx-richtext-view td, .vx-richtext-view th { border: 1px solid rgba(var(--v-border-color), 0.3); padding: 0.25rem 0.5rem; min-width: 4rem; vertical-align: top; }
174
+ .vx-richtext-content img, .vx-richtext-view img { max-width: 100%; height: auto; }
175
+ .vx-richtext-content blockquote, .vx-richtext-view blockquote { border-inline-start: 3px solid rgba(var(--v-border-color), 0.4); padding-inline-start: 0.75rem; margin: 0.5rem 0; }
176
+ .vx-richtext-content pre, .vx-richtext-view pre { background: rgba(var(--v-theme-on-surface), 0.06); padding: 0.5rem 0.75rem; border-radius: 4px; overflow-x: auto; }
177
+ .vx-richtext-content ul, .vx-richtext-content ol, .vx-richtext-view ul, .vx-richtext-view ol { padding-inline-start: 1.5rem; }
178
+ .vx-mention { color: rgb(var(--v-theme-primary)); font-weight: 500; }
179
+ .vx-mention-menu { position: absolute; z-index: 3000; background: rgb(var(--v-theme-surface)); border: 1px solid rgba(var(--v-border-color), 0.3); border-radius: 4px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); display: flex; flex-direction: column; min-width: 12rem; }
180
+ .vx-mention-choice { text-align: start; padding: 0.35rem 0.75rem; background: none; border: 0; cursor: pointer; }
181
+ .vx-mention-choice--active, .vx-mention-choice:hover { background: rgba(var(--v-theme-primary), 0.12); }
182
+ </style>