@ramathibodi/nuxt-commons 0.1.73 → 0.1.75

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 (111) hide show
  1. package/README.md +115 -96
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +1 -0
  4. package/dist/runtime/components/Alert.vue +58 -54
  5. package/dist/runtime/components/BarcodeReader.vue +130 -122
  6. package/dist/runtime/components/ExportCSV.vue +110 -102
  7. package/dist/runtime/components/FileBtn.vue +79 -67
  8. package/dist/runtime/components/ImportCSV.vue +151 -139
  9. package/dist/runtime/components/MrzReader.vue +168 -0
  10. package/dist/runtime/components/SplitterPanel.vue +67 -59
  11. package/dist/runtime/components/TabsGroup.vue +39 -31
  12. package/dist/runtime/components/TextBarcode.vue +66 -54
  13. package/dist/runtime/components/device/IdCardButton.vue +95 -83
  14. package/dist/runtime/components/device/IdCardWebSocket.vue +207 -195
  15. package/dist/runtime/components/device/Scanner.vue +350 -338
  16. package/dist/runtime/components/dialog/Confirm.vue +112 -100
  17. package/dist/runtime/components/dialog/Host.vue +88 -84
  18. package/dist/runtime/components/dialog/Index.vue +84 -72
  19. package/dist/runtime/components/dialog/Loading.vue +51 -39
  20. package/dist/runtime/components/dialog/default/Confirm.vue +112 -100
  21. package/dist/runtime/components/dialog/default/Loading.vue +60 -48
  22. package/dist/runtime/components/dialog/default/Notify.vue +82 -70
  23. package/dist/runtime/components/dialog/default/Printing.vue +46 -34
  24. package/dist/runtime/components/dialog/default/VerifyUser.vue +144 -132
  25. package/dist/runtime/components/document/Form.vue +50 -42
  26. package/dist/runtime/components/document/TemplateBuilder.vue +536 -524
  27. package/dist/runtime/components/form/ActionPad.vue +156 -144
  28. package/dist/runtime/components/form/Birthdate.vue +116 -104
  29. package/dist/runtime/components/form/CheckboxGroup.vue +99 -87
  30. package/dist/runtime/components/form/CodeEditor.vue +45 -37
  31. package/dist/runtime/components/form/Date.vue +270 -258
  32. package/dist/runtime/components/form/DateTime.vue +220 -208
  33. package/dist/runtime/components/form/Dialog.vue +178 -166
  34. package/dist/runtime/components/form/EditPad.vue +157 -145
  35. package/dist/runtime/components/form/File.vue +295 -283
  36. package/dist/runtime/components/form/Hidden.vue +44 -32
  37. package/dist/runtime/components/form/Iterator.vue +538 -526
  38. package/dist/runtime/components/form/Login.vue +143 -131
  39. package/dist/runtime/components/form/Pad.vue +399 -387
  40. package/dist/runtime/components/form/SignPad.vue +226 -218
  41. package/dist/runtime/components/form/System.vue +34 -26
  42. package/dist/runtime/components/form/Table.vue +391 -379
  43. package/dist/runtime/components/form/TableData.vue +236 -224
  44. package/dist/runtime/components/form/Time.vue +177 -165
  45. package/dist/runtime/components/form/images/Capture.vue +245 -237
  46. package/dist/runtime/components/form/images/Edit.vue +133 -121
  47. package/dist/runtime/components/form/images/Field.vue +331 -320
  48. package/dist/runtime/components/form/images/Pad.vue +54 -42
  49. package/dist/runtime/components/label/Date.vue +37 -29
  50. package/dist/runtime/components/label/DateAgo.vue +102 -94
  51. package/dist/runtime/components/label/DateCount.vue +152 -144
  52. package/dist/runtime/components/label/Field.vue +111 -103
  53. package/dist/runtime/components/label/FormatMoney.vue +37 -29
  54. package/dist/runtime/components/label/Mask.vue +46 -38
  55. package/dist/runtime/components/label/Object.vue +21 -13
  56. package/dist/runtime/components/master/Autocomplete.vue +89 -81
  57. package/dist/runtime/components/master/Combobox.vue +88 -80
  58. package/dist/runtime/components/master/RadioGroup.vue +90 -78
  59. package/dist/runtime/components/master/Select.vue +70 -62
  60. package/dist/runtime/components/master/label.vue +55 -47
  61. package/dist/runtime/components/model/Autocomplete.vue +91 -79
  62. package/dist/runtime/components/model/Combobox.vue +90 -78
  63. package/dist/runtime/components/model/Pad.vue +114 -102
  64. package/dist/runtime/components/model/Select.vue +78 -72
  65. package/dist/runtime/components/model/Table.vue +370 -358
  66. package/dist/runtime/components/model/iterator.vue +497 -489
  67. package/dist/runtime/components/model/label.vue +58 -50
  68. package/dist/runtime/components/pdf/Print.vue +75 -63
  69. package/dist/runtime/components/pdf/View.vue +146 -134
  70. package/dist/runtime/composables/alert.d.ts +4 -0
  71. package/dist/runtime/composables/api.d.ts +4 -0
  72. package/dist/runtime/composables/dialog.d.ts +1 -1
  73. package/dist/runtime/composables/document/templateFormHidden.d.ts +4 -0
  74. package/dist/runtime/composables/graphql.d.ts +1 -1
  75. package/dist/runtime/composables/graphqlModel.d.ts +9 -9
  76. package/dist/runtime/composables/graphqlModelItem.d.ts +7 -7
  77. package/dist/runtime/composables/graphqlModelOperation.d.ts +6 -6
  78. package/dist/runtime/composables/localStorageModel.d.ts +4 -0
  79. package/dist/runtime/composables/lookupList.d.ts +4 -0
  80. package/dist/runtime/composables/menu.d.ts +4 -0
  81. package/dist/runtime/composables/useMrzReader.d.ts +48 -0
  82. package/dist/runtime/composables/useMrzReader.js +423 -0
  83. package/dist/runtime/composables/useTesseract.d.ts +16 -0
  84. package/dist/runtime/composables/useTesseract.js +45 -0
  85. package/dist/runtime/composables/userPermission.d.ts +1 -1
  86. package/dist/runtime/labs/Calendar.vue +99 -99
  87. package/dist/runtime/labs/form/EditMobile.vue +152 -152
  88. package/dist/runtime/labs/form/TextFieldMask.vue +43 -43
  89. package/dist/runtime/plugins/clientConfig.d.ts +1 -1
  90. package/dist/runtime/plugins/default.d.ts +1 -1
  91. package/dist/runtime/plugins/dialogManager.d.ts +1 -1
  92. package/dist/runtime/plugins/permission.d.ts +1 -1
  93. package/dist/runtime/types/alert.d.ts +11 -11
  94. package/dist/runtime/types/clientConfig.d.ts +13 -13
  95. package/dist/runtime/types/dialogManager.d.ts +35 -35
  96. package/dist/runtime/types/formDialog.d.ts +5 -5
  97. package/dist/runtime/types/graphqlOperation.d.ts +23 -23
  98. package/dist/runtime/types/menu.d.ts +31 -31
  99. package/dist/runtime/types/modules.d.ts +7 -7
  100. package/dist/runtime/types/permission.d.ts +13 -13
  101. package/dist/runtime/utils/asset.d.ts +2 -0
  102. package/dist/runtime/utils/asset.js +49 -0
  103. package/package.json +131 -122
  104. package/scripts/enrich-vue-docs-from-ai.mjs +197 -0
  105. package/scripts/generate-ai-summary.mjs +321 -0
  106. package/scripts/generate-composables-md.mjs +129 -0
  107. package/scripts/postInstall.cjs +70 -70
  108. package/templates/.codegen/codegen.ts +32 -32
  109. package/templates/.codegen/plugin-schema-object.js +161 -161
  110. package/templates/public/tesseract/mrz.traineddata.gz +0 -0
  111. package/templates/public/tesseract/ocrb.traineddata.gz +0 -0
@@ -1,284 +1,296 @@
1
- <script lang="ts" setup>
2
- import { castArray } from 'lodash-es'
3
- import { ref, shallowRef, watch, computed } from 'vue'
4
- import { VTextField } from 'vuetify/components/VTextField'
5
- import { useAlert } from '../../composables/alert'
6
- import { type Base64File } from '../../composables/assetFile'
7
- import { useAssetFile } from '../../composables/assetFile'
8
-
9
- const alert = useAlert()
10
- const { hydrateAssetFile, base64ToFile, fileToBase64, downloadBase64File } = useAssetFile()
11
-
12
- const fileToBase64WithMaxSize = (file: File) => fileToBase64(file, props.maxSize)
13
-
14
- interface Props extends /* @vue-ignore */ InstanceType<typeof VTextField['$props']> {
15
- accept?: string
16
- multiple?: boolean
17
- maxSize?: number
18
- modelValue?: Base64File | Base64File[] | null
19
- downloadable?: boolean
20
- autoHydrate?: boolean
21
- }
22
-
23
- const props = withDefaults(defineProps<Props>(), {
24
- accept: '*',
25
- multiple: false,
26
- maxSize: 5,
27
- downloadable: false,
28
- autoHydrate: false,
29
- })
30
-
31
- const emit = defineEmits<{
32
- (e: 'update:modelValue', value: Base64File | Base64File[] | null): void
33
- }>()
34
-
35
- /** UI ref */
36
- const fileInput = ref<HTMLInputElement | null>(null)
37
-
38
- /** Internal sources (always arrays) */
39
- const assets = ref<Base64File[]>([]) // items with server id (or ones we keep as “assets”)
40
- const files = shallowRef<File[]>([]) // native File objects picked by the user
41
-
42
- /** Cache to avoid re-reading the same File repeatedly */
43
- const fileCache = new WeakMap<File, Promise<Base64File>>()
44
-
45
- /** Re-entrancy guard to break the emit -> props watcher -> syncFromModel loop */
46
- let internalSync = false
47
-
48
- /** Build a stable dedupe key */
49
- function base64FileKey(x: Base64File): string {
50
- if (x.id != null) return `id:${x.id}`
51
- const name = x.fileName ?? ''
52
- const len = x.base64String?.length ?? 0
53
- return `n:${name}|l:${len}`
54
- }
55
-
56
- /** Dedupe by key (preserves order) */
57
- function uniqByKey(arr: Base64File[]): Base64File[] {
58
- const seen = new Set<string>()
59
- const out: Base64File[] = []
60
- for (const it of arr) {
61
- const k = base64FileKey(it)
62
- if (!seen.has(k)) {
63
- seen.add(k)
64
- out.push(it)
65
- }
66
- }
67
- return out
68
- }
69
-
70
- /** Shallow equal by key sequence */
71
- function arraysEqualByKey(a: Base64File[], b: Base64File[]): boolean {
72
- if (a === b) return true
73
- if (a.length !== b.length) return false
74
- for (let i = 0; i < a.length; i++) {
75
- if (base64FileKey(a[i]) !== base64FileKey(b[i])) return false
76
- }
77
- return true
78
- }
79
-
80
- /** Normalize incoming modelValue into arrays for internal use */
81
- async function syncFromModel() {
82
- const mv = props.modelValue
83
- if (!mv) {
84
- assets.value = []
85
- files.value = []
86
- return
87
- }
88
-
89
- const asArray = castArray(mv) as Base64File[]
90
-
91
- // Split into “asset with id” vs “inline base64” (no id)
92
- const incomingAssets = asArray.filter(a => a.id != null)
93
- const inlineBase64 = asArray.filter(a => a.id == null && a.base64String)
94
-
95
- // Convert inline base64 to File for consistent UX
96
- const inlineFiles: File[] = []
97
- for (const item of inlineBase64) {
98
- const f = base64ToFile(item.base64String as string, item.fileName)
99
- if (f) inlineFiles.push(f)
100
- }
101
-
102
- assets.value = incomingAssets
103
- files.value = inlineFiles
104
-
105
- // Optionally hydrate assets that need it
106
- if (props.autoHydrate) {
107
- const needsHydration = assets.value.filter(a => a.id != null && !a.base64String)
108
- if (needsHydration.length) {
109
- await Promise.allSettled(needsHydration.map(hydrateAssetFile))
110
- }
111
- }
112
- }
113
-
114
- /** Open chooser */
115
- function openWindowUpload() {
116
- if (props.multiple || (assets.value.length === 0 && files.value.length === 0)) {
117
- fileInput.value?.click()
118
- }
119
- }
120
-
121
- /** Add files from input (single or multiple) */
122
- function addFiles(payload: File | File[]) {
123
- if (Array.isArray(payload)) files.value = [...files.value, ...payload]
124
- else files.value = [...files.value, payload]
125
- }
126
-
127
- /** Remove chips */
128
- function removeFileByIndex(i: number | string) {
129
- const idx = Number(i)
130
- if (idx >= 0 && idx < files.value.length) {
131
- const copy = files.value.slice()
132
- copy.splice(idx, 1)
133
- files.value = copy
134
- }
135
- }
136
- function removeAssetByIndex(i: number | string) {
137
- const idx = Number(i)
138
- if (idx >= 0 && idx < assets.value.length) {
139
- const copy = assets.value.slice()
140
- copy.splice(idx, 1)
141
- assets.value = copy
142
- }
143
- }
144
-
145
- /** Convert current files Base64File[] (cached) */
146
- async function filesToBase64Files(list: File[]): Promise<Base64File[]> {
147
- const tasks = list.map(f => {
148
- let p = fileCache.get(f)
149
- if (!p) {
150
- p = fileToBase64WithMaxSize(f)
151
- fileCache.set(f, p)
152
- }
153
- return p
154
- })
155
- return Promise.all(tasks)
156
- }
157
-
158
- /** Combined result (array form) */
159
- const combinedArray = ref<Base64File[]>([])
160
-
161
- /** Dirty flag for <v-text-field> */
162
- const isDirty = computed(() =>
163
- props.multiple ? combinedArray.value.length > 0 : combinedArray.value[0] != null
164
- )
165
-
166
- /** Sync from props.modelValue (guarded) */
167
- watch(
168
- () => props.modelValue,
169
- () => {
170
- if (internalSync) return
171
- void syncFromModel()
172
- },
173
- { deep: true, immediate: true }
174
- )
175
-
176
- /** Rebuild combined and emit (guarded, deduped, only-on-change) */
177
- watch([assets, files], async () => {
178
- try {
179
- const base64FromFiles = await filesToBase64Files(files.value)
180
-
181
- const mergedArray = props.multiple
182
- ? [...assets.value, ...base64FromFiles]
183
- : (assets.value[0] ?? base64FromFiles[0] ?? null)
184
- ? [assets.value[0] ?? base64FromFiles[0]]
185
- : []
186
-
187
- const nextCombined = props.multiple ? uniqByKey(mergedArray) : mergedArray
188
-
189
- if (!arraysEqualByKey(combinedArray.value, nextCombined)) {
190
- combinedArray.value = nextCombined
191
-
192
- internalSync = true
193
- try {
194
- if (props.multiple) {
195
- emit('update:modelValue', combinedArray.value)
196
- } else {
197
- emit('update:modelValue', combinedArray.value[0] ?? null)
198
- }
199
- } finally {
200
- // let Vue flush the emit before re-enabling the external sync
201
- queueMicrotask(() => { internalSync = false })
202
- }
203
- }
204
- } catch (error: any) {
205
- alert?.addAlert({ message: String(error), alertType: 'error' })
206
- files.value = []
207
- const fallback = props.multiple
208
- ? [...assets.value]
209
- : (assets.value[0] ? [assets.value[0]] : [])
210
-
211
- if (!arraysEqualByKey(combinedArray.value, fallback)) {
212
- combinedArray.value = fallback
213
- internalSync = true
214
- try {
215
- if (props.multiple) emit('update:modelValue', combinedArray.value)
216
- else emit('update:modelValue', combinedArray.value[0] ?? null)
217
- } finally {
218
- queueMicrotask(() => { internalSync = false })
219
- }
220
- }
221
- }
222
- }, { deep: true, immediate: true })
223
- </script>
224
-
225
- <template>
226
- <v-text-field
227
- v-bind="$attrs"
228
- label="Upload files"
229
- readonly
230
- :dirty="isDirty"
231
- v-on="isDirty && !props.multiple ? {} : { click: openWindowUpload }"
232
- >
233
- <template #default>
234
- <!-- Server/asset items -->
235
- <v-chip
236
- v-for="(asset, index) in assets"
237
- :key="`${asset.id ?? asset.fileName}-${index}`"
238
- color="green"
239
- variant="flat"
240
- closable
241
- @click:close="removeAssetByIndex(index)"
242
- >
243
- {{ asset.originalFileName || asset.fileName }}
244
- <template #append v-if="props.downloadable">
245
- <slot name="download" :item="asset">
246
- <v-icon
247
- v-if="asset.base64String"
248
- @click.stop="downloadBase64File(asset.base64String, asset.originalFileName || asset.fileName)"
249
- >
250
- mdi mdi-download
251
- </v-icon>
252
- </slot>
253
- </template>
254
- </v-chip>
255
-
256
- <!-- Local uploaded files -->
257
- <v-chip
258
- v-for="(file, index) in files"
259
- :key="`${file.name}-${file.size}-${index}`"
260
- color="primary"
261
- variant="flat"
262
- closable
263
- @click:close="removeFileByIndex(index)"
264
- >
265
- {{ file.name }}
266
- </v-chip>
267
- </template>
268
-
269
- <!-- Add more button for multi mode -->
270
- <template v-if="props.multiple && combinedArray.length > 0" #append-inner>
271
- <VBtn variant="text" :icon="true" @click="openWindowUpload">
272
- <v-icon>mdi mdi-plus</v-icon>
273
- </VBtn>
274
- </template>
275
- </v-text-field>
276
-
277
- <v-file-input
278
- ref="fileInput"
279
- :accept="props.accept"
280
- :multiple="props.multiple"
281
- style="display: none"
282
- @update:model-value="addFiles"
283
- />
1
+ <script lang="ts" setup>
2
+ /**
3
+ * FormFile is a schema-driven form field component that binds model data, renders field UI, and emits normalized updates.
4
+ * This doc block is consumed by vue-docgen for generated API documentation.
5
+ */
6
+ import { castArray } from 'lodash-es'
7
+ import { ref, shallowRef, watch, computed } from 'vue'
8
+ import { VTextField } from 'vuetify/components/VTextField'
9
+ import { useAlert } from '../../composables/alert'
10
+ import { type Base64File } from '../../composables/assetFile'
11
+ import { useAssetFile } from '../../composables/assetFile'
12
+
13
+ const alert = useAlert()
14
+ const { hydrateAssetFile, base64ToFile, fileToBase64, downloadBase64File } = useAssetFile()
15
+
16
+ const fileToBase64WithMaxSize = (file: File) => fileToBase64(file, props.maxSize)
17
+
18
+ interface Props extends /* @vue-ignore */ InstanceType<typeof VTextField['$props']> {
19
+ accept?: string // Accepted file MIME types or extensions for file selection.
20
+ multiple?: boolean // Allows selecting or uploading more than one file.
21
+ maxSize?: number // Maximum allowed output size (MB) before upload is blocked.
22
+ modelValue?: Base64File | Base64File[] | null // Bound value for v-model synchronization with the parent component.
23
+ downloadable?: boolean // Shows download actions for selected/loaded files.
24
+ autoHydrate?: boolean // Converts incoming serialized values into component runtime format on mount/watch.
25
+ }
26
+
27
+ /**
28
+ * Public props accepted by FormFile.
29
+ * Document each prop field with intent, defaults, and side effects for clear generated docs.
30
+ */
31
+ const props = withDefaults(defineProps<Props>(), {
32
+ accept: '*',
33
+ multiple: false,
34
+ maxSize: 5,
35
+ downloadable: false,
36
+ autoHydrate: false,
37
+ })
38
+
39
+ /**
40
+ * Custom events emitted by FormFile.
41
+ * Parents can listen to these events to react to user actions and internal state changes.
42
+ */
43
+ const emit = defineEmits<{
44
+ (e: 'update:modelValue', value: Base64File | Base64File[] | null): void
45
+ }>()
46
+
47
+ /** UI ref */
48
+ const fileInput = ref<HTMLInputElement | null>(null)
49
+
50
+ /** Internal sources (always arrays) */
51
+ const assets = ref<Base64File[]>([]) // items with server id (or ones we keep as “assets”)
52
+ const files = shallowRef<File[]>([]) // native File objects picked by the user
53
+
54
+ /** Cache to avoid re-reading the same File repeatedly */
55
+ const fileCache = new WeakMap<File, Promise<Base64File>>()
56
+
57
+ /** Re-entrancy guard to break the emit -> props watcher -> syncFromModel loop */
58
+ let internalSync = false
59
+
60
+ /** Build a stable dedupe key */
61
+ function base64FileKey(x: Base64File): string {
62
+ if (x.id != null) return `id:${x.id}`
63
+ const name = x.fileName ?? ''
64
+ const len = x.base64String?.length ?? 0
65
+ return `n:${name}|l:${len}`
66
+ }
67
+
68
+ /** Dedupe by key (preserves order) */
69
+ function uniqByKey(arr: Base64File[]): Base64File[] {
70
+ const seen = new Set<string>()
71
+ const out: Base64File[] = []
72
+ for (const it of arr) {
73
+ const k = base64FileKey(it)
74
+ if (!seen.has(k)) {
75
+ seen.add(k)
76
+ out.push(it)
77
+ }
78
+ }
79
+ return out
80
+ }
81
+
82
+ /** Shallow equal by key sequence */
83
+ function arraysEqualByKey(a: Base64File[], b: Base64File[]): boolean {
84
+ if (a === b) return true
85
+ if (a.length !== b.length) return false
86
+ for (let i = 0; i < a.length; i++) {
87
+ if (base64FileKey(a[i]) !== base64FileKey(b[i])) return false
88
+ }
89
+ return true
90
+ }
91
+
92
+ /** Normalize incoming modelValue into arrays for internal use */
93
+ async function syncFromModel() {
94
+ const mv = props.modelValue
95
+ if (!mv) {
96
+ assets.value = []
97
+ files.value = []
98
+ return
99
+ }
100
+
101
+ const asArray = castArray(mv) as Base64File[]
102
+
103
+ // Split into “asset with id” vs “inline base64” (no id)
104
+ const incomingAssets = asArray.filter(a => a.id != null)
105
+ const inlineBase64 = asArray.filter(a => a.id == null && a.base64String)
106
+
107
+ // Convert inline base64 to File for consistent UX
108
+ const inlineFiles: File[] = []
109
+ for (const item of inlineBase64) {
110
+ const f = base64ToFile(item.base64String as string, item.fileName)
111
+ if (f) inlineFiles.push(f)
112
+ }
113
+
114
+ assets.value = incomingAssets
115
+ files.value = inlineFiles
116
+
117
+ // Optionally hydrate assets that need it
118
+ if (props.autoHydrate) {
119
+ const needsHydration = assets.value.filter(a => a.id != null && !a.base64String)
120
+ if (needsHydration.length) {
121
+ await Promise.allSettled(needsHydration.map(hydrateAssetFile))
122
+ }
123
+ }
124
+ }
125
+
126
+ /** Open chooser */
127
+ function openWindowUpload() {
128
+ if (props.multiple || (assets.value.length === 0 && files.value.length === 0)) {
129
+ fileInput.value?.click()
130
+ }
131
+ }
132
+
133
+ /** Add files from input (single or multiple) */
134
+ function addFiles(payload: File | File[]) {
135
+ if (Array.isArray(payload)) files.value = [...files.value, ...payload]
136
+ else files.value = [...files.value, payload]
137
+ }
138
+
139
+ /** Remove chips */
140
+ function removeFileByIndex(i: number | string) {
141
+ const idx = Number(i)
142
+ if (idx >= 0 && idx < files.value.length) {
143
+ const copy = files.value.slice()
144
+ copy.splice(idx, 1)
145
+ files.value = copy
146
+ }
147
+ }
148
+ function removeAssetByIndex(i: number | string) {
149
+ const idx = Number(i)
150
+ if (idx >= 0 && idx < assets.value.length) {
151
+ const copy = assets.value.slice()
152
+ copy.splice(idx, 1)
153
+ assets.value = copy
154
+ }
155
+ }
156
+
157
+ /** Convert current files → Base64File[] (cached) */
158
+ async function filesToBase64Files(list: File[]): Promise<Base64File[]> {
159
+ const tasks = list.map(f => {
160
+ let p = fileCache.get(f)
161
+ if (!p) {
162
+ p = fileToBase64WithMaxSize(f)
163
+ fileCache.set(f, p)
164
+ }
165
+ return p
166
+ })
167
+ return Promise.all(tasks)
168
+ }
169
+
170
+ /** Combined result (array form) */
171
+ const combinedArray = ref<Base64File[]>([])
172
+
173
+ /** Dirty flag for <v-text-field> */
174
+ const isDirty = computed(() =>
175
+ props.multiple ? combinedArray.value.length > 0 : combinedArray.value[0] != null
176
+ )
177
+
178
+ /** Sync from props.modelValue (guarded) */
179
+ watch(
180
+ () => props.modelValue,
181
+ () => {
182
+ if (internalSync) return
183
+ void syncFromModel()
184
+ },
185
+ { deep: true, immediate: true }
186
+ )
187
+
188
+ /** Rebuild combined and emit (guarded, deduped, only-on-change) */
189
+ watch([assets, files], async () => {
190
+ try {
191
+ const base64FromFiles = await filesToBase64Files(files.value)
192
+
193
+ const mergedArray = props.multiple
194
+ ? [...assets.value, ...base64FromFiles]
195
+ : (assets.value[0] ?? base64FromFiles[0] ?? null)
196
+ ? [assets.value[0] ?? base64FromFiles[0]]
197
+ : []
198
+
199
+ const nextCombined = props.multiple ? uniqByKey(mergedArray) : mergedArray
200
+
201
+ if (!arraysEqualByKey(combinedArray.value, nextCombined)) {
202
+ combinedArray.value = nextCombined
203
+
204
+ internalSync = true
205
+ try {
206
+ if (props.multiple) {
207
+ emit('update:modelValue', combinedArray.value)
208
+ } else {
209
+ emit('update:modelValue', combinedArray.value[0] ?? null)
210
+ }
211
+ } finally {
212
+ // let Vue flush the emit before re-enabling the external sync
213
+ queueMicrotask(() => { internalSync = false })
214
+ }
215
+ }
216
+ } catch (error: any) {
217
+ alert?.addAlert({ message: String(error), alertType: 'error' })
218
+ files.value = []
219
+ const fallback = props.multiple
220
+ ? [...assets.value]
221
+ : (assets.value[0] ? [assets.value[0]] : [])
222
+
223
+ if (!arraysEqualByKey(combinedArray.value, fallback)) {
224
+ combinedArray.value = fallback
225
+ internalSync = true
226
+ try {
227
+ if (props.multiple) emit('update:modelValue', combinedArray.value)
228
+ else emit('update:modelValue', combinedArray.value[0] ?? null)
229
+ } finally {
230
+ queueMicrotask(() => { internalSync = false })
231
+ }
232
+ }
233
+ }
234
+ }, { deep: true, immediate: true })
235
+ </script>
236
+
237
+ <template>
238
+ <v-text-field
239
+ v-bind="$attrs"
240
+ label="Upload files"
241
+ readonly
242
+ :dirty="isDirty"
243
+ v-on="isDirty && !props.multiple ? {} : { click: openWindowUpload }"
244
+ >
245
+ <template #default>
246
+ <!-- Server/asset items -->
247
+ <v-chip
248
+ v-for="(asset, index) in assets"
249
+ :key="`${asset.id ?? asset.fileName}-${index}`"
250
+ color="green"
251
+ variant="flat"
252
+ closable
253
+ @click:close="removeAssetByIndex(index)"
254
+ >
255
+ {{ asset.originalFileName || asset.fileName }}
256
+ <template #append v-if="props.downloadable">
257
+ <slot name="download" :item="asset">
258
+ <v-icon
259
+ v-if="asset.base64String"
260
+ @click.stop="downloadBase64File(asset.base64String, asset.originalFileName || asset.fileName)"
261
+ >
262
+ mdi mdi-download
263
+ </v-icon>
264
+ </slot>
265
+ </template>
266
+ </v-chip>
267
+
268
+ <!-- Local uploaded files -->
269
+ <v-chip
270
+ v-for="(file, index) in files"
271
+ :key="`${file.name}-${file.size}-${index}`"
272
+ color="primary"
273
+ variant="flat"
274
+ closable
275
+ @click:close="removeFileByIndex(index)"
276
+ >
277
+ {{ file.name }}
278
+ </v-chip>
279
+ </template>
280
+
281
+ <!-- Add more button for multi mode -->
282
+ <template v-if="props.multiple && combinedArray.length > 0" #append-inner>
283
+ <VBtn variant="text" :icon="true" @click="openWindowUpload">
284
+ <v-icon>mdi mdi-plus</v-icon>
285
+ </VBtn>
286
+ </template>
287
+ </v-text-field>
288
+
289
+ <v-file-input
290
+ ref="fileInput"
291
+ :accept="props.accept"
292
+ :multiple="props.multiple"
293
+ style="display: none"
294
+ @update:model-value="addFiles"
295
+ />
284
296
  </template>