@ramathibodi/nuxt-commons 0.1.74 → 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 (96) hide show
  1. package/README.md +115 -115
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/components/Alert.vue +58 -58
  4. package/dist/runtime/components/BarcodeReader.vue +130 -130
  5. package/dist/runtime/components/ExportCSV.vue +110 -110
  6. package/dist/runtime/components/FileBtn.vue +79 -79
  7. package/dist/runtime/components/ImportCSV.vue +151 -151
  8. package/dist/runtime/components/MrzReader.vue +168 -168
  9. package/dist/runtime/components/SplitterPanel.vue +67 -67
  10. package/dist/runtime/components/TabsGroup.vue +39 -39
  11. package/dist/runtime/components/TextBarcode.vue +66 -66
  12. package/dist/runtime/components/device/IdCardButton.vue +95 -95
  13. package/dist/runtime/components/device/IdCardWebSocket.vue +207 -207
  14. package/dist/runtime/components/device/Scanner.vue +350 -350
  15. package/dist/runtime/components/dialog/Confirm.vue +112 -112
  16. package/dist/runtime/components/dialog/Host.vue +88 -88
  17. package/dist/runtime/components/dialog/Index.vue +84 -84
  18. package/dist/runtime/components/dialog/Loading.vue +51 -51
  19. package/dist/runtime/components/dialog/default/Confirm.vue +112 -112
  20. package/dist/runtime/components/dialog/default/Loading.vue +60 -60
  21. package/dist/runtime/components/dialog/default/Notify.vue +82 -82
  22. package/dist/runtime/components/dialog/default/Printing.vue +46 -46
  23. package/dist/runtime/components/dialog/default/VerifyUser.vue +144 -144
  24. package/dist/runtime/components/document/Form.vue +50 -50
  25. package/dist/runtime/components/document/TemplateBuilder.vue +536 -536
  26. package/dist/runtime/components/form/ActionPad.vue +156 -156
  27. package/dist/runtime/components/form/Birthdate.vue +116 -116
  28. package/dist/runtime/components/form/CheckboxGroup.vue +99 -99
  29. package/dist/runtime/components/form/CodeEditor.vue +45 -45
  30. package/dist/runtime/components/form/Date.vue +270 -270
  31. package/dist/runtime/components/form/DateTime.vue +220 -220
  32. package/dist/runtime/components/form/Dialog.vue +178 -178
  33. package/dist/runtime/components/form/EditPad.vue +157 -157
  34. package/dist/runtime/components/form/File.vue +295 -295
  35. package/dist/runtime/components/form/Hidden.vue +44 -44
  36. package/dist/runtime/components/form/Iterator.vue +538 -538
  37. package/dist/runtime/components/form/Login.vue +143 -143
  38. package/dist/runtime/components/form/Pad.vue +399 -399
  39. package/dist/runtime/components/form/SignPad.vue +226 -226
  40. package/dist/runtime/components/form/System.vue +34 -34
  41. package/dist/runtime/components/form/Table.vue +391 -391
  42. package/dist/runtime/components/form/TableData.vue +236 -236
  43. package/dist/runtime/components/form/Time.vue +177 -177
  44. package/dist/runtime/components/form/images/Capture.vue +245 -245
  45. package/dist/runtime/components/form/images/Edit.vue +133 -133
  46. package/dist/runtime/components/form/images/Field.vue +331 -331
  47. package/dist/runtime/components/form/images/Pad.vue +54 -54
  48. package/dist/runtime/components/label/Date.vue +37 -37
  49. package/dist/runtime/components/label/DateAgo.vue +102 -102
  50. package/dist/runtime/components/label/DateCount.vue +152 -152
  51. package/dist/runtime/components/label/Field.vue +111 -111
  52. package/dist/runtime/components/label/FormatMoney.vue +37 -37
  53. package/dist/runtime/components/label/Mask.vue +46 -46
  54. package/dist/runtime/components/label/Object.vue +21 -21
  55. package/dist/runtime/components/master/Autocomplete.vue +89 -89
  56. package/dist/runtime/components/master/Combobox.vue +88 -88
  57. package/dist/runtime/components/master/RadioGroup.vue +90 -90
  58. package/dist/runtime/components/master/Select.vue +70 -70
  59. package/dist/runtime/components/master/label.vue +55 -55
  60. package/dist/runtime/components/model/Autocomplete.vue +91 -91
  61. package/dist/runtime/components/model/Combobox.vue +90 -90
  62. package/dist/runtime/components/model/Pad.vue +114 -114
  63. package/dist/runtime/components/model/Select.vue +78 -84
  64. package/dist/runtime/components/model/Table.vue +370 -370
  65. package/dist/runtime/components/model/iterator.vue +497 -497
  66. package/dist/runtime/components/model/label.vue +58 -58
  67. package/dist/runtime/components/pdf/Print.vue +75 -75
  68. package/dist/runtime/components/pdf/View.vue +146 -146
  69. package/dist/runtime/composables/dialog.d.ts +1 -1
  70. package/dist/runtime/composables/graphql.d.ts +1 -1
  71. package/dist/runtime/composables/graphqlModel.d.ts +9 -9
  72. package/dist/runtime/composables/graphqlModelItem.d.ts +7 -7
  73. package/dist/runtime/composables/graphqlModelOperation.d.ts +6 -6
  74. package/dist/runtime/composables/userPermission.d.ts +1 -1
  75. package/dist/runtime/labs/Calendar.vue +99 -99
  76. package/dist/runtime/labs/form/EditMobile.vue +152 -152
  77. package/dist/runtime/labs/form/TextFieldMask.vue +43 -43
  78. package/dist/runtime/plugins/clientConfig.d.ts +1 -1
  79. package/dist/runtime/plugins/default.d.ts +1 -1
  80. package/dist/runtime/plugins/dialogManager.d.ts +1 -1
  81. package/dist/runtime/plugins/permission.d.ts +1 -1
  82. package/dist/runtime/types/alert.d.ts +11 -11
  83. package/dist/runtime/types/clientConfig.d.ts +13 -13
  84. package/dist/runtime/types/dialogManager.d.ts +35 -35
  85. package/dist/runtime/types/formDialog.d.ts +5 -5
  86. package/dist/runtime/types/graphqlOperation.d.ts +23 -23
  87. package/dist/runtime/types/menu.d.ts +31 -31
  88. package/dist/runtime/types/modules.d.ts +7 -7
  89. package/dist/runtime/types/permission.d.ts +13 -13
  90. package/package.json +131 -131
  91. package/scripts/enrich-vue-docs-from-ai.mjs +197 -197
  92. package/scripts/generate-ai-summary.mjs +321 -321
  93. package/scripts/generate-composables-md.mjs +129 -129
  94. package/scripts/postInstall.cjs +70 -70
  95. package/templates/.codegen/codegen.ts +32 -32
  96. package/templates/.codegen/plugin-schema-object.js +161 -161
@@ -1,331 +1,331 @@
1
- <script lang="ts" setup>
2
- /**
3
- * FormImagesField handles image capture, editing, preview, and value synchronization for schema-driven form image fields.
4
- * This doc block is consumed by vue-docgen for generated API documentation.
5
- */
6
- import { ref, watch,computed } from 'vue'
7
- import { isEqual } from 'lodash-es'
8
- import { VInput } from 'vuetify/components/VInput'
9
- import { assetUrl } from '#imports'
10
- import { useAlert } from '../../../composables/alert'
11
- import { useAssetFile, type Base64Image, type Base64Asset, type Base64File} from '../../../composables/assetFile'
12
-
13
- /**
14
- * Custom events emitted by FormImagesField.
15
- * Parents can listen to these events to react to user actions and internal state changes.
16
- */
17
- const emit = defineEmits<{
18
- (e: 'update:modelValue', value: Base64Image[]): void
19
- }>()
20
-
21
- const alert = useAlert()
22
- const { fileToBase64, hydrateAssetFile } = useAssetFile()
23
-
24
- interface Props {
25
- modelValue?: Base64Image[] // Bound value for v-model synchronization with the parent component.
26
- readonly?: boolean // renders as read-only while keeping value visible
27
- label?: string // UI label displayed to end users
28
- accept?: string // Accepted file MIME types or extensions for file selection.
29
- autoHydrate?: boolean // Converts incoming serialized values into component runtime format on mount/watch.
30
- maxFileSize?: number // Maximum allowed image file size in MB before conversion.
31
- }
32
- /**
33
- * Public props accepted by FormImagesField.
34
- * Document each prop field with intent, defaults, and side effects for clear generated docs.
35
- */
36
- const props = withDefaults(defineProps<Props>(), {
37
- modelValue: () => [] as Base64Image[],
38
- accept: '.jpg,.jpeg,.png,.webp,.gif,.bmp,.tiff,.tif',
39
- autoHydrate: false,
40
- maxFileSize: 10,
41
- })
42
-
43
- /** Internal state (always Base64Image[]) */
44
- const images = ref<Base64Image[]>([])
45
- const uploadImages = ref<File[]>([])
46
-
47
- /** Dialogs */
48
- const dialog = ref(false) // capture dialog
49
- const dialogUpdate = ref(false) // edit dialog
50
- const dataUpdate = ref<Base64Image>({ imageData: {}, imageTitle: '', imageProps: {} })
51
-
52
- /** Fullscreen preview */
53
- const dialogImageFullScreen = ref(false)
54
- const imageFullScreen = ref<{title: string,image: string|undefined}>({ title: '', image: '' })
55
-
56
- /** ---------- Stable keys + guards ---------- */
57
- let internalSync = false
58
- let lastEmittedSig = '' // signature of last emitted state
59
-
60
- function imageKey(im: Base64Image): string {
61
- const id = im.imageData?.id
62
- if (id != null) return `id:${id}`
63
- const title = im.imageTitle ?? ''
64
- const len = im.imageData?.base64String?.length ?? 0
65
- return `t:${title}|l:${len}`
66
- }
67
-
68
- function signature(arr: Base64Image[]): string {
69
- return arr.map(imageKey).join('|')
70
- }
71
-
72
- /** Emit helper (guarded + updates last signature) */
73
- function emitNow(next: Base64Image[]) {
74
- const sig = signature(next)
75
- if (sig === lastEmittedSig) return
76
- internalSync = true
77
- try {
78
- emit('update:modelValue', next)
79
- lastEmittedSig = sig
80
- } finally {
81
- queueMicrotask(() => { internalSync = false })
82
- }
83
- }
84
-
85
- /** ---------- Helpers ---------- */
86
-
87
- const addImage = (img: Base64Image) => {
88
- images.value.push({
89
- imageData: img.imageData ?? {},
90
- imageTitle: img.imageTitle ?? '',
91
- imageProps: img.imageProps ?? {},
92
- })
93
- dialog.value = false
94
- }
95
-
96
- const remove = (index: number) => {
97
- images.value.splice(index, 1)
98
- }
99
-
100
- const setDataUpdate = (img: Base64Image) => {
101
- dataUpdate.value = {
102
- imageData: { ...(img.imageData ?? {}) },
103
- imageTitle: img.imageTitle ?? '',
104
- imageProps: { ...(img.imageProps ?? {}) },
105
- }
106
- dialogUpdate.value = true
107
- }
108
-
109
- const checkDuplicationName = (name: string) => images.value.some(({ imageTitle }) => isEqual(imageTitle, name))
110
-
111
- const isImageDataUrl = (dataUrl: string) => /^data:image\//i.test(dataUrl)
112
-
113
- const imageSrcFromImageData = (imageData: Base64Image) => {
114
- if (imageData?.imageData?.base64String) return useAssetFile().ensureDataUrl(imageData?.imageData?.base64String.trim(),(imageData?.imageData as Base64File).fileType || "image/png")
115
- if (imageData?.imageData?.id != null) return assetUrl(imageData.imageData.id)
116
- return undefined
117
- }
118
-
119
- /** File → Base64Image using composable */
120
- const fileToBase64Image = async (file: File): Promise<Base64Image | null> => {
121
- try {
122
- const base64 = await fileToBase64(file,props.maxFileSize)
123
- const dataUrl = base64.base64String || ''
124
- if (!isImageDataUrl(dataUrl)) {
125
- alert?.addAlert({ message: `File "${base64.fileName}" is not supported image type.`, alertType: 'error' })
126
- return null
127
- }
128
- return {
129
- imageData: { base64String: dataUrl } as Base64Asset,
130
- imageTitle: base64.fileName,
131
- imageProps: {},
132
- }
133
- } catch (e: any) {
134
- alert?.addAlert({ message: String(e), alertType: 'error' })
135
- return null
136
- }
137
- }
138
-
139
- /** Handle upload button update */
140
- const uploadImageFile = async () => {
141
- const duplicated: string[] = []
142
- for (const file of uploadImages.value) {
143
- if (checkDuplicationName(file.name)) {
144
- duplicated.push(file.name)
145
- continue
146
- }
147
- const base64Image = await fileToBase64Image(file)
148
- if (base64Image) addImage(base64Image)
149
- }
150
- uploadImages.value = []
151
- if (duplicated.length) {
152
- alert?.addAlert({ message: `File(s) are duplicated. ${duplicated.join(', ')}`, alertType: 'error' })
153
- }
154
- }
155
-
156
- /** Capture flow (FormDialog) */
157
- type FormDialogCallback = { done: () => void }
158
- const modelData = ref()
159
-
160
- const captureImage = (payload: any, cb: FormDialogCallback) => {
161
- const dataUrl: string = payload?.imageCapture ?? ''
162
- if (!dataUrl || !isImageDataUrl(dataUrl)) {
163
- alert?.addAlert({ message: 'Invalid image.', alertType: 'error' })
164
- return
165
- }
166
- addImage({
167
- imageData: { base64String: dataUrl },
168
- imageTitle: Math.random().toString(36).slice(2, 11),
169
- imageProps: {},
170
- })
171
- cb?.done?.()
172
- }
173
-
174
- /** Fullscreen preview */
175
- const openImageFullScreen = (img: Base64Image) => {
176
- dialogImageFullScreen.value = true
177
- imageFullScreen.value.title = img.imageTitle ?? ''
178
- imageFullScreen.value.image = imageSrcFromImageData(img)
179
- }
180
-
181
- /** ---------- Watchers (signature-based) ---------- */
182
-
183
- /* Parent → Internal */
184
- watch(
185
- () => props.modelValue,
186
- async (val) => {
187
- if (internalSync) return
188
-
189
- const next = Array.isArray(val) ? [...val] : []
190
- const nextSig = signature(next)
191
-
192
- // Only reassign when truly different
193
- if (nextSig !== signature(images.value)) {
194
- images.value = next
195
-
196
- // optional hydration
197
- if (props.autoHydrate && images.value.length) {
198
- const targets = images.value.filter(
199
- (im) => im.imageData?.id != null && !im.imageData?.base64String
200
- )
201
- if (targets.length) {
202
- await Promise.allSettled(targets.map((im) => hydrateAssetFile(im.imageData!)))
203
- // After hydration, emit once (guarded) to update parent
204
- emitNow(images.value)
205
- }
206
- }
207
-
208
- // sync lastEmittedSig to current internal state so next local change emits
209
- lastEmittedSig = signature(images.value)
210
- }
211
- },
212
- { deep: true, immediate: true }
213
- )
214
-
215
- /* Internal → Parent: watch the signature instead of deep structure */
216
- watch(
217
- () => signature(images.value),
218
- () => {
219
- if (internalSync) return
220
- // Only emit when signature actually changes
221
- emitNow(images.value)
222
- },
223
- { immediate: false }
224
- )
225
-
226
- // validation passthrough
227
- const inputRef = ref<InstanceType<typeof VInput> | null>(null)
228
-
229
- const isValid = computed(() => inputRef.value?.isValid)
230
- const errorMessages = computed(() => inputRef.value?.errorMessages)
231
-
232
- defineExpose({
233
- errorMessages,
234
- isValid,
235
- reset: () => inputRef.value?.reset(),
236
- resetValidation: () => inputRef.value?.resetValidation(),
237
- validate: () => inputRef.value?.validate(),
238
- })
239
- </script>
240
-
241
- <template>
242
- <v-input v-model="images" v-bind="$attrs" ref="inputRef">
243
- <template #default="{ isReadonly, isDisabled }">
244
- <v-container fluid>
245
- <v-row>
246
- <v-col class="pa-0">
247
- <VCard>
248
- <VToolbar density="compact">
249
- <VToolbarTitle>{{ label }}</VToolbarTitle>
250
- <v-spacer />
251
- <VToolbarItems v-if="!readonly">
252
- <FileBtn
253
- v-model="uploadImages"
254
- :accept="accept"
255
- color="primary"
256
- icon="mdi:mdi-image-plus"
257
- icon-only
258
- multiple
259
- variant="text"
260
- @update:model-value="uploadImageFile"
261
- :disabled="isDisabled?.value" :readonly="isReadonly?.value"
262
- />
263
- <v-btn color="primary" icon @click="dialog = true" :disabled="isDisabled?.value" :readonly="isReadonly?.value">
264
- <v-icon>mdi mdi-camera-plus</v-icon>
265
- </v-btn>
266
- </VToolbarItems>
267
- </VToolbar>
268
-
269
- <VCardText>
270
- <VRow dense justify="start">
271
- <VCol v-for="(image, index) in images" :key="`${imageKey(image)}-${index}`" cols="4">
272
- <VCard>
273
- <VToolbar density="compact">
274
- <VToolbarTitle>
275
- {{ image.imageTitle }}
276
- </VToolbarTitle>
277
- <VSpacer />
278
- <VToolbarItems v-if="!readonly">
279
- <v-btn icon @click="remove(index)" :disabled="isDisabled?.value" :readonly="isReadonly?.value">
280
- <v-icon>mdi mdi-delete-outline</v-icon>
281
- </v-btn>
282
- <v-btn color="primary" icon @click="setDataUpdate(image)" v-if="!image.imageData?.id" :disabled="isDisabled?.value" :readonly="isReadonly?.value">
283
- <v-icon>mdi mdi-image-edit-outline</v-icon>
284
- </v-btn>
285
- </VToolbarItems>
286
- </VToolbar>
287
-
288
- <v-img
289
- :src="imageSrcFromImageData(image)"
290
- height="250"
291
- @click="() => { (props.readonly || image.imageData?.id || isReadonly?.value) ? openImageFullScreen(image) : setDataUpdate(image) }"
292
- :disabled="isDisabled?.value"
293
- />
294
- </VCard>
295
- </VCol>
296
- </VRow>
297
- </VCardText>
298
- </VCard>
299
- </v-col>
300
- </v-row>
301
- </v-container>
302
-
303
-
304
- <!-- Edit dialog -->
305
- <VDialog v-model="dialogUpdate" fullscreen transition="dialog-bottom-transition">
306
- <FormImagesPad
307
- v-model="dataUpdate.imageData.base64String"
308
- @closedDialog="dialogUpdate = false"
309
- />
310
- </VDialog>
311
-
312
- <!-- Capture dialog -->
313
- <FormDialog v-model="dialog" :form-data="modelData" @create="captureImage">
314
- <template #default="{ data }">
315
- <FormImagesCapture v-model="data.imageCapture" />
316
- </template>
317
- </FormDialog>
318
-
319
- <!-- Fullscreen preview -->
320
- <v-dialog v-model="dialogImageFullScreen">
321
- <v-toolbar :title="imageFullScreen.title">
322
- <v-spacer />
323
- <v-btn icon="mdi mdi-close" @click="dialogImageFullScreen = false" />
324
- </v-toolbar>
325
- <v-card height="80vh">
326
- <v-img :src="imageFullScreen.image" />
327
- </v-card>
328
- </v-dialog>
329
- </template>
330
- </v-input>
331
- </template>
1
+ <script lang="ts" setup>
2
+ /**
3
+ * FormImagesField handles image capture, editing, preview, and value synchronization for schema-driven form image fields.
4
+ * This doc block is consumed by vue-docgen for generated API documentation.
5
+ */
6
+ import { ref, watch,computed } from 'vue'
7
+ import { isEqual } from 'lodash-es'
8
+ import { VInput } from 'vuetify/components/VInput'
9
+ import { assetUrl } from '#imports'
10
+ import { useAlert } from '../../../composables/alert'
11
+ import { useAssetFile, type Base64Image, type Base64Asset, type Base64File} from '../../../composables/assetFile'
12
+
13
+ /**
14
+ * Custom events emitted by FormImagesField.
15
+ * Parents can listen to these events to react to user actions and internal state changes.
16
+ */
17
+ const emit = defineEmits<{
18
+ (e: 'update:modelValue', value: Base64Image[]): void
19
+ }>()
20
+
21
+ const alert = useAlert()
22
+ const { fileToBase64, hydrateAssetFile } = useAssetFile()
23
+
24
+ interface Props {
25
+ modelValue?: Base64Image[] // Bound value for v-model synchronization with the parent component.
26
+ readonly?: boolean // renders as read-only while keeping value visible
27
+ label?: string // UI label displayed to end users
28
+ accept?: string // Accepted file MIME types or extensions for file selection.
29
+ autoHydrate?: boolean // Converts incoming serialized values into component runtime format on mount/watch.
30
+ maxFileSize?: number // Maximum allowed image file size in MB before conversion.
31
+ }
32
+ /**
33
+ * Public props accepted by FormImagesField.
34
+ * Document each prop field with intent, defaults, and side effects for clear generated docs.
35
+ */
36
+ const props = withDefaults(defineProps<Props>(), {
37
+ modelValue: () => [] as Base64Image[],
38
+ accept: '.jpg,.jpeg,.png,.webp,.gif,.bmp,.tiff,.tif',
39
+ autoHydrate: false,
40
+ maxFileSize: 10,
41
+ })
42
+
43
+ /** Internal state (always Base64Image[]) */
44
+ const images = ref<Base64Image[]>([])
45
+ const uploadImages = ref<File[]>([])
46
+
47
+ /** Dialogs */
48
+ const dialog = ref(false) // capture dialog
49
+ const dialogUpdate = ref(false) // edit dialog
50
+ const dataUpdate = ref<Base64Image>({ imageData: {}, imageTitle: '', imageProps: {} })
51
+
52
+ /** Fullscreen preview */
53
+ const dialogImageFullScreen = ref(false)
54
+ const imageFullScreen = ref<{title: string,image: string|undefined}>({ title: '', image: '' })
55
+
56
+ /** ---------- Stable keys + guards ---------- */
57
+ let internalSync = false
58
+ let lastEmittedSig = '' // signature of last emitted state
59
+
60
+ function imageKey(im: Base64Image): string {
61
+ const id = im.imageData?.id
62
+ if (id != null) return `id:${id}`
63
+ const title = im.imageTitle ?? ''
64
+ const len = im.imageData?.base64String?.length ?? 0
65
+ return `t:${title}|l:${len}`
66
+ }
67
+
68
+ function signature(arr: Base64Image[]): string {
69
+ return arr.map(imageKey).join('|')
70
+ }
71
+
72
+ /** Emit helper (guarded + updates last signature) */
73
+ function emitNow(next: Base64Image[]) {
74
+ const sig = signature(next)
75
+ if (sig === lastEmittedSig) return
76
+ internalSync = true
77
+ try {
78
+ emit('update:modelValue', next)
79
+ lastEmittedSig = sig
80
+ } finally {
81
+ queueMicrotask(() => { internalSync = false })
82
+ }
83
+ }
84
+
85
+ /** ---------- Helpers ---------- */
86
+
87
+ const addImage = (img: Base64Image) => {
88
+ images.value.push({
89
+ imageData: img.imageData ?? {},
90
+ imageTitle: img.imageTitle ?? '',
91
+ imageProps: img.imageProps ?? {},
92
+ })
93
+ dialog.value = false
94
+ }
95
+
96
+ const remove = (index: number) => {
97
+ images.value.splice(index, 1)
98
+ }
99
+
100
+ const setDataUpdate = (img: Base64Image) => {
101
+ dataUpdate.value = {
102
+ imageData: { ...(img.imageData ?? {}) },
103
+ imageTitle: img.imageTitle ?? '',
104
+ imageProps: { ...(img.imageProps ?? {}) },
105
+ }
106
+ dialogUpdate.value = true
107
+ }
108
+
109
+ const checkDuplicationName = (name: string) => images.value.some(({ imageTitle }) => isEqual(imageTitle, name))
110
+
111
+ const isImageDataUrl = (dataUrl: string) => /^data:image\//i.test(dataUrl)
112
+
113
+ const imageSrcFromImageData = (imageData: Base64Image) => {
114
+ if (imageData?.imageData?.base64String) return useAssetFile().ensureDataUrl(imageData?.imageData?.base64String.trim(),(imageData?.imageData as Base64File).fileType || "image/png")
115
+ if (imageData?.imageData?.id != null) return assetUrl(imageData.imageData.id)
116
+ return undefined
117
+ }
118
+
119
+ /** File → Base64Image using composable */
120
+ const fileToBase64Image = async (file: File): Promise<Base64Image | null> => {
121
+ try {
122
+ const base64 = await fileToBase64(file,props.maxFileSize)
123
+ const dataUrl = base64.base64String || ''
124
+ if (!isImageDataUrl(dataUrl)) {
125
+ alert?.addAlert({ message: `File "${base64.fileName}" is not supported image type.`, alertType: 'error' })
126
+ return null
127
+ }
128
+ return {
129
+ imageData: { base64String: dataUrl } as Base64Asset,
130
+ imageTitle: base64.fileName,
131
+ imageProps: {},
132
+ }
133
+ } catch (e: any) {
134
+ alert?.addAlert({ message: String(e), alertType: 'error' })
135
+ return null
136
+ }
137
+ }
138
+
139
+ /** Handle upload button update */
140
+ const uploadImageFile = async () => {
141
+ const duplicated: string[] = []
142
+ for (const file of uploadImages.value) {
143
+ if (checkDuplicationName(file.name)) {
144
+ duplicated.push(file.name)
145
+ continue
146
+ }
147
+ const base64Image = await fileToBase64Image(file)
148
+ if (base64Image) addImage(base64Image)
149
+ }
150
+ uploadImages.value = []
151
+ if (duplicated.length) {
152
+ alert?.addAlert({ message: `File(s) are duplicated. ${duplicated.join(', ')}`, alertType: 'error' })
153
+ }
154
+ }
155
+
156
+ /** Capture flow (FormDialog) */
157
+ type FormDialogCallback = { done: () => void }
158
+ const modelData = ref()
159
+
160
+ const captureImage = (payload: any, cb: FormDialogCallback) => {
161
+ const dataUrl: string = payload?.imageCapture ?? ''
162
+ if (!dataUrl || !isImageDataUrl(dataUrl)) {
163
+ alert?.addAlert({ message: 'Invalid image.', alertType: 'error' })
164
+ return
165
+ }
166
+ addImage({
167
+ imageData: { base64String: dataUrl },
168
+ imageTitle: Math.random().toString(36).slice(2, 11),
169
+ imageProps: {},
170
+ })
171
+ cb?.done?.()
172
+ }
173
+
174
+ /** Fullscreen preview */
175
+ const openImageFullScreen = (img: Base64Image) => {
176
+ dialogImageFullScreen.value = true
177
+ imageFullScreen.value.title = img.imageTitle ?? ''
178
+ imageFullScreen.value.image = imageSrcFromImageData(img)
179
+ }
180
+
181
+ /** ---------- Watchers (signature-based) ---------- */
182
+
183
+ /* Parent → Internal */
184
+ watch(
185
+ () => props.modelValue,
186
+ async (val) => {
187
+ if (internalSync) return
188
+
189
+ const next = Array.isArray(val) ? [...val] : []
190
+ const nextSig = signature(next)
191
+
192
+ // Only reassign when truly different
193
+ if (nextSig !== signature(images.value)) {
194
+ images.value = next
195
+
196
+ // optional hydration
197
+ if (props.autoHydrate && images.value.length) {
198
+ const targets = images.value.filter(
199
+ (im) => im.imageData?.id != null && !im.imageData?.base64String
200
+ )
201
+ if (targets.length) {
202
+ await Promise.allSettled(targets.map((im) => hydrateAssetFile(im.imageData!)))
203
+ // After hydration, emit once (guarded) to update parent
204
+ emitNow(images.value)
205
+ }
206
+ }
207
+
208
+ // sync lastEmittedSig to current internal state so next local change emits
209
+ lastEmittedSig = signature(images.value)
210
+ }
211
+ },
212
+ { deep: true, immediate: true }
213
+ )
214
+
215
+ /* Internal → Parent: watch the signature instead of deep structure */
216
+ watch(
217
+ () => signature(images.value),
218
+ () => {
219
+ if (internalSync) return
220
+ // Only emit when signature actually changes
221
+ emitNow(images.value)
222
+ },
223
+ { immediate: false }
224
+ )
225
+
226
+ // validation passthrough
227
+ const inputRef = ref<InstanceType<typeof VInput> | null>(null)
228
+
229
+ const isValid = computed(() => inputRef.value?.isValid)
230
+ const errorMessages = computed(() => inputRef.value?.errorMessages)
231
+
232
+ defineExpose({
233
+ errorMessages,
234
+ isValid,
235
+ reset: () => inputRef.value?.reset(),
236
+ resetValidation: () => inputRef.value?.resetValidation(),
237
+ validate: () => inputRef.value?.validate(),
238
+ })
239
+ </script>
240
+
241
+ <template>
242
+ <v-input v-model="images" v-bind="$attrs" ref="inputRef">
243
+ <template #default="{ isReadonly, isDisabled }">
244
+ <v-container fluid>
245
+ <v-row>
246
+ <v-col class="pa-0">
247
+ <VCard>
248
+ <VToolbar density="compact">
249
+ <VToolbarTitle>{{ label }}</VToolbarTitle>
250
+ <v-spacer />
251
+ <VToolbarItems v-if="!readonly">
252
+ <FileBtn
253
+ v-model="uploadImages"
254
+ :accept="accept"
255
+ color="primary"
256
+ icon="mdi:mdi-image-plus"
257
+ icon-only
258
+ multiple
259
+ variant="text"
260
+ @update:model-value="uploadImageFile"
261
+ :disabled="isDisabled?.value" :readonly="isReadonly?.value"
262
+ />
263
+ <v-btn color="primary" icon @click="dialog = true" :disabled="isDisabled?.value" :readonly="isReadonly?.value">
264
+ <v-icon>mdi mdi-camera-plus</v-icon>
265
+ </v-btn>
266
+ </VToolbarItems>
267
+ </VToolbar>
268
+
269
+ <VCardText>
270
+ <VRow dense justify="start">
271
+ <VCol v-for="(image, index) in images" :key="`${imageKey(image)}-${index}`" cols="4">
272
+ <VCard>
273
+ <VToolbar density="compact">
274
+ <VToolbarTitle>
275
+ {{ image.imageTitle }}
276
+ </VToolbarTitle>
277
+ <VSpacer />
278
+ <VToolbarItems v-if="!readonly">
279
+ <v-btn icon @click="remove(index)" :disabled="isDisabled?.value" :readonly="isReadonly?.value">
280
+ <v-icon>mdi mdi-delete-outline</v-icon>
281
+ </v-btn>
282
+ <v-btn color="primary" icon @click="setDataUpdate(image)" v-if="!image.imageData?.id" :disabled="isDisabled?.value" :readonly="isReadonly?.value">
283
+ <v-icon>mdi mdi-image-edit-outline</v-icon>
284
+ </v-btn>
285
+ </VToolbarItems>
286
+ </VToolbar>
287
+
288
+ <v-img
289
+ :src="imageSrcFromImageData(image)"
290
+ height="250"
291
+ @click="() => { (props.readonly || image.imageData?.id || isReadonly?.value) ? openImageFullScreen(image) : setDataUpdate(image) }"
292
+ :disabled="isDisabled?.value"
293
+ />
294
+ </VCard>
295
+ </VCol>
296
+ </VRow>
297
+ </VCardText>
298
+ </VCard>
299
+ </v-col>
300
+ </v-row>
301
+ </v-container>
302
+
303
+
304
+ <!-- Edit dialog -->
305
+ <VDialog v-model="dialogUpdate" fullscreen transition="dialog-bottom-transition">
306
+ <FormImagesPad
307
+ v-model="dataUpdate.imageData.base64String"
308
+ @closedDialog="dialogUpdate = false"
309
+ />
310
+ </VDialog>
311
+
312
+ <!-- Capture dialog -->
313
+ <FormDialog v-model="dialog" :form-data="modelData" @create="captureImage">
314
+ <template #default="{ data }">
315
+ <FormImagesCapture v-model="data.imageCapture" />
316
+ </template>
317
+ </FormDialog>
318
+
319
+ <!-- Fullscreen preview -->
320
+ <v-dialog v-model="dialogImageFullScreen">
321
+ <v-toolbar :title="imageFullScreen.title">
322
+ <v-spacer />
323
+ <v-btn icon="mdi mdi-close" @click="dialogImageFullScreen = false" />
324
+ </v-toolbar>
325
+ <v-card height="80vh">
326
+ <v-img :src="imageFullScreen.image" />
327
+ </v-card>
328
+ </v-dialog>
329
+ </template>
330
+ </v-input>
331
+ </template>