tantee-nuxt-commons 0.0.169 → 0.0.171

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.
@@ -1,263 +1,313 @@
1
1
  <script lang="ts" setup>
2
- import {type Ref, ref, watch, onMounted} from 'vue'
3
- import {useAlert} from '../../../composables/alert'
4
- import {isEqual} from 'lodash-es'
5
- import type {FormDialogCallback} from "../../../types/formDialog";
6
-
2
+ import { ref, watch } from 'vue'
3
+ import { isEqual } from 'lodash-es'
4
+ import { useAlert } from '../../../composables/alert'
5
+ import { useAssetFile, type Base64Image, type Base64Asset, type Base64File} from '../../../composables/assetFile'
6
+ import { useRuntimeConfig } from "#imports";
7
+ import { VInput } from 'vuetify/components/VInput'
7
8
 
8
9
  const emit = defineEmits<{
9
- (e: "update:modelValue", value: {}): void;
10
- }>();
10
+ (e: 'update:modelValue', value: Base64Image[]): void
11
+ }>()
11
12
 
12
13
  const alert = useAlert()
13
-
14
- interface Image {
15
- title: string;
16
- data: string;
17
- props: {};
18
- }
14
+ const { fileToBase64, hydrateAssetFile } = useAssetFile()
19
15
 
20
16
  interface Props {
21
- modelValue?: any[];
22
- readonly?: boolean;
23
- label?: string;
17
+ modelValue?: Base64Image[]
18
+ readonly?: boolean
19
+ label?: string
20
+ accept?: string
21
+ autoHydrate?: boolean
22
+ maxFileSize?: number
24
23
  }
25
24
  const props = withDefaults(defineProps<Props>(), {
26
- modelValue: () => [] as any[],
25
+ modelValue: () => [] as Base64Image[],
26
+ accept: '.jpg,.jpeg,.png,.webp,.gif,.bmp,.tiff,.tif',
27
+ autoHydrate: false,
28
+ maxFileSize: 10,
27
29
  })
28
30
 
29
- const images = ref<any[]>([]);
30
- // uploadImages = images that are imported by FileBtn
31
- const uploadImages: Ref<any[]> = ref([]);
32
- // dialog = dialog for capturing image
33
- const dialog: Ref<boolean> = ref(false);
34
- // dialogUpdate, dataUpdate = dialog for editing image
35
- const dialogUpdate: Ref<boolean> = ref(false);
36
- const dataUpdate: Ref<Image> = ref({
37
- title: "",
38
- data: "",
39
- props: {},
40
- });
41
-
42
-
43
- // remove selected image
44
- const remove = (index: number) => {
45
- images.value.splice(index, 1);
46
- };
31
+ /** Internal state (always Base64Image[]) */
32
+ const images = ref<Base64Image[]>([])
33
+ const uploadImages = ref<File[]>([])
47
34
 
48
- // open dialog for editing selected image
49
- const setDataUpdate = (data: Image) => {
50
- dataUpdate.value = data;
51
- dialogUpdate.value = true;
52
- };
35
+ /** Dialogs */
36
+ const dialog = ref(false) // capture dialog
37
+ const dialogUpdate = ref(false) // edit dialog
38
+ const dataUpdate = ref<Base64Image>({ imageData: {}, imageTitle: '', imageProps: {} })
53
39
 
40
+ /** Fullscreen preview */
41
+ const dialogImageFullScreen = ref(false)
42
+ const imageFullScreen = ref<{title: string,image: string|undefined}>({ title: '', image: '' })
54
43
 
44
+ /** ---------- Stable keys + guards ---------- */
45
+ let internalSync = false
46
+ let lastEmittedSig = '' // signature of last emitted state
55
47
 
56
- // check if File name is already exist
57
- const checkDuplicationName = async (currentImageName: string) => {
58
- for (const {title} of images.value) {
59
- if (isEqual(title, currentImageName)) return true
60
- }
48
+ function imageKey(im: Base64Image): string {
49
+ const id = im.imageData?.id
50
+ if (id != null) return `id:${id}`
51
+ const title = im.imageTitle ?? ''
52
+ const len = im.imageData?.base64String?.length ?? 0
53
+ return `t:${title}|l:${len}`
61
54
  }
62
- // check if the base64 is image or not
63
- const isImage = (fileBase64: any) => {
64
- const typeFile: string = fileBase64.mineType.substring(5, 10);
65
- return typeFile === "image";
66
- };
67
-
68
- // convert FIle that is uploaded by FileBtn to base64
69
- const convertFileToBase64 = async (file: any) => {
55
+
56
+ function signature(arr: Base64Image[]): string {
57
+ return arr.map(imageKey).join('|')
58
+ }
59
+
60
+ /** Emit helper (guarded + updates last signature) */
61
+ function emitNow(next: Base64Image[]) {
62
+ const sig = signature(next)
63
+ if (sig === lastEmittedSig) return
64
+ internalSync = true
70
65
  try {
71
- const readPromise: any = new Promise((resolve, reject) => {
72
- const reader = new FileReader();
73
- reader.onload = () => {
74
- let result: any = reader.result;
75
- const mineType = result.split(",")[0];
76
- const base64 = result.split(",")[1];
77
- resolve({
78
- base64string: base64,
79
- mineType: mineType,
80
- filename: file.name,
81
- });
82
- };
83
- reader.onerror = (error) => {
84
- reject(error);
85
- };
86
- reader.readAsDataURL(file);
87
- });
88
- return await readPromise;
89
- } catch (error: any) {
90
- alert?.addAlert({message: error, alertType: 'error'})
66
+ emit('update:modelValue', next)
67
+ lastEmittedSig = sig
68
+ } finally {
69
+ queueMicrotask(() => { internalSync = false })
91
70
  }
92
- };
71
+ }
93
72
 
94
- // push image into Field
95
- const addImage = (data: Image) => {
73
+ /** ---------- Helpers ---------- */
74
+
75
+ const addImage = (img: Base64Image) => {
96
76
  images.value.push({
97
- title: data.title,
98
- data: data.data,
99
- props: {},
100
- });
101
- dialog.value = false;
102
- };
103
-
104
- const update = async () => {
105
- const duplicatedFileName = ref("")
106
- for (const image of uploadImages.value) {
107
- if (await checkDuplicationName(image.name)) {
108
- duplicatedFileName.value += `${image.name},`
109
- } else {
110
- const fileBase64: any = await convertFileToBase64(image);
111
- if (isImage(fileBase64)) {
112
- addImage({
113
- title: fileBase64.filename,
114
- data: `${fileBase64.mineType},${fileBase64.base64string}`,
115
- props: {},
116
- })
117
- } else {
118
- alert?.addAlert({
119
- message: `ไฟล์ "${fileBase64.filename}" ไม่ใช่ไฟล์นามสกุล .jpg หรือ .jpeg`,
120
- alertType: 'error'
121
- })
122
- }
77
+ imageData: img.imageData ?? {},
78
+ imageTitle: img.imageTitle ?? '',
79
+ imageProps: img.imageProps ?? {},
80
+ })
81
+ dialog.value = false
82
+ }
83
+
84
+ const remove = (index: number) => {
85
+ images.value.splice(index, 1)
86
+ }
87
+
88
+ const setDataUpdate = (img: Base64Image) => {
89
+ dataUpdate.value = {
90
+ imageData: { ...(img.imageData ?? {}) },
91
+ imageTitle: img.imageTitle ?? '',
92
+ imageProps: { ...(img.imageProps ?? {}) },
93
+ }
94
+ dialogUpdate.value = true
95
+ }
96
+
97
+ const checkDuplicationName = (name: string) => images.value.some(({ imageTitle }) => isEqual(imageTitle, name))
98
+
99
+ const isImageDataUrl = (dataUrl: string) => /^data:image\//i.test(dataUrl)
100
+
101
+ const imageSrcFromImageData = (imageData: Base64Image) => {
102
+ let assetUrl = useRuntimeConfig().public.ASSET_URL as string
103
+ if (imageData?.imageData?.base64String) return useAssetFile().ensureDataUrl(imageData?.imageData?.base64String.trim(),(imageData?.imageData as Base64File).fileType || "image/png")
104
+ if (imageData?.imageData?.id) return (assetUrl || '/asset').replace(/\/+$/, '') +"/"+imageData?.imageData?.id
105
+ return undefined
106
+ }
107
+
108
+ /** File → Base64Image using composable */
109
+ const fileToBase64Image = async (file: File): Promise<Base64Image | null> => {
110
+ try {
111
+ const base64 = await fileToBase64(file,props.maxFileSize)
112
+ const dataUrl = base64.base64String || ''
113
+ if (!isImageDataUrl(dataUrl)) {
114
+ alert?.addAlert({ message: `File "${base64.fileName}" is not supported image type.`, alertType: 'error' })
115
+ return null
116
+ }
117
+ return {
118
+ imageData: { base64String: dataUrl } as Base64Asset,
119
+ imageTitle: base64.fileName,
120
+ imageProps: {},
123
121
  }
122
+ } catch (e: any) {
123
+ alert?.addAlert({ message: String(e), alertType: 'error' })
124
+ return null
125
+ }
126
+ }
127
+
128
+ /** Handle upload button update */
129
+ const uploadImageFile = async () => {
130
+ const duplicated: string[] = []
131
+ for (const file of uploadImages.value) {
132
+ if (checkDuplicationName(file.name)) {
133
+ duplicated.push(file.name)
134
+ continue
135
+ }
136
+ const base64Image = await fileToBase64Image(file)
137
+ if (base64Image) addImage(base64Image)
124
138
  }
125
139
  uploadImages.value = []
126
- if (duplicatedFileName.value !== "") {
127
- alert?.addAlert({message: `ไม่สามารถอัพโหลดไฟล์ ${duplicatedFileName.value}`, alertType: 'error'})
128
- duplicatedFileName.value = ""
140
+ if (duplicated.length) {
141
+ alert?.addAlert({ message: `File(s) are duplicated. ${duplicated.join(', ')}`, alertType: 'error' })
129
142
  }
130
- };
143
+ }
131
144
 
132
- // Capture Image
133
- const modelData = ref();
134
- const captureImage = (image: any, callback: FormDialogCallback) => {
145
+ /** Capture flow (FormDialog) */
146
+ type FormDialogCallback = { done: () => void }
147
+ const modelData = ref()
148
+
149
+ const captureImage = (payload: any, cb: FormDialogCallback) => {
150
+ const dataUrl: string = payload?.imageCapture ?? ''
151
+ if (!dataUrl || !isImageDataUrl(dataUrl)) {
152
+ alert?.addAlert({ message: 'Invalid image.', alertType: 'error' })
153
+ return
154
+ }
135
155
  addImage({
136
- title: Math.random().toString(36).slice(2, 11),
137
- data: image.imageCapture,
138
- props: {}
156
+ imageData: { base64String: dataUrl },
157
+ imageTitle: Math.random().toString(36).slice(2, 11),
158
+ imageProps: {},
139
159
  })
140
- callback.done()
160
+ cb?.done?.()
141
161
  }
142
162
 
143
- // open Fullscreen image
144
- const dialogImageFullScreen = ref(false)
145
- const imageFullScreen = ref({
146
- title: "",
147
- image: ""
148
- })
149
-
150
- const openImageFullScreen = (image: { [key: string]: string }) => {
163
+ /** Fullscreen preview */
164
+ const openImageFullScreen = (img: Base64Image) => {
151
165
  dialogImageFullScreen.value = true
152
- imageFullScreen.value.title = image.title
153
- imageFullScreen.value.image = image.data
166
+ imageFullScreen.value.title = img.imageTitle ?? ''
167
+ imageFullScreen.value.image = imageSrcFromImageData(img)
154
168
  }
155
169
 
170
+ /** ---------- Watchers (signature-based) ---------- */
156
171
 
157
- watch(images, () => {
158
- emit("update:modelValue", images.value);
159
- }, {deep: true});
172
+ /* Parent Internal */
173
+ watch(
174
+ () => props.modelValue,
175
+ async (val) => {
176
+ if (internalSync) return
160
177
 
178
+ const next = Array.isArray(val) ? [...val] : []
179
+ const nextSig = signature(next)
161
180
 
162
- // updating when the parent component adds image to v-model
163
- watch(props.modelValue, () => {
164
- images.value = props.modelValue
165
- }, {deep: true});
181
+ // Only reassign when truly different
182
+ if (nextSig !== signature(images.value)) {
183
+ images.value = next
166
184
 
185
+ // optional hydration
186
+ if (props.autoHydrate && images.value.length) {
187
+ const targets = images.value.filter(
188
+ (im) => im.imageData?.id != null && !im.imageData?.base64String
189
+ )
190
+ if (targets.length) {
191
+ await Promise.allSettled(targets.map((im) => hydrateAssetFile(im.imageData!)))
192
+ // After hydration, emit once (guarded) to update parent
193
+ emitNow(images.value)
194
+ }
195
+ }
167
196
 
168
- onMounted(() => {
169
- // import images by v-model
170
- for (const modelValue of props.modelValue) {
171
- addImage({
172
- title: modelValue.title ?? Math.random().toString(36).slice(2, 11),
173
- data: modelValue.data,
174
- props: {}
175
- })
176
- }
197
+ // sync lastEmittedSig to current internal state so next local change emits
198
+ lastEmittedSig = signature(images.value)
199
+ }
200
+ },
201
+ { deep: true, immediate: true }
202
+ )
203
+
204
+ /* Internal → Parent: watch the signature instead of deep structure */
205
+ watch(
206
+ () => signature(images.value),
207
+ () => {
208
+ if (internalSync) return
209
+ // Only emit when signature actually changes
210
+ emitNow(images.value)
211
+ },
212
+ { immediate: false }
213
+ )
214
+
215
+ // validation passthrough
216
+ const inputRef = ref<InstanceType<typeof VInput> | null>(null)
217
+
218
+ const isValid = computed(() => inputRef.value?.isValid)
219
+ const errorMessages = computed(() => inputRef.value?.errorMessages)
220
+
221
+ defineExpose({
222
+ errorMessages,
223
+ isValid,
224
+ reset: () => inputRef.value?.reset(),
225
+ resetValidation: () => inputRef.value?.resetValidation(),
226
+ validate: () => inputRef.value?.validate(),
177
227
  })
178
228
  </script>
179
229
 
180
230
  <template>
181
- <VCard>
182
- <VToolbar density="compact">
183
- <VToolbarTitle>{{ label }}</VToolbarTitle>
184
- <v-spacer></v-spacer>
185
- <VToolbarItems v-if="!readonly">
186
- <FileBtn
187
- ref="fileBtn"
188
- v-model="uploadImages"
189
- accept=".jpg, .jpeg"
190
- color="primary"
191
- icon="mdi:mdi-image-plus"
192
- iconOnly
193
- multiple
194
- variant="text"
195
- @update:model-value="update"
196
- />
197
- <v-btn color="primary" icon @click="dialog = true">
198
- <v-icon>mdi mdi-camera-plus</v-icon>
199
- </v-btn>
200
- </VToolbarItems>
201
- </VToolbar>
202
- <VCardText>
203
- <VRow dense justify="center">
204
- <VCol v-for="(image, index) in images" :key="index" cols="4">
205
- <VCard>
206
- <VToolbar density="compact">
207
- <VToolbarTitle>
208
- {{ image.title }}
209
- </VToolbarTitle>
210
- <VSpacer></VSpacer>
211
- <VToolbarItems v-if="!readonly">
212
- <v-btn icon @click="remove(index)">
213
- <v-icon>mdi mdi-delete-outline</v-icon>
214
- </v-btn>
215
- <v-btn
216
- color="primary"
217
- icon
218
- @click="setDataUpdate(image)"
219
- >
220
- <v-icon>mdi mdi-image-edit-outline</v-icon>
221
- </v-btn>
222
- </VToolbarItems>
223
- </VToolbar>
224
- <v-img
225
- :src="image.data"
226
- @click="() => { (props.readonly) ? openImageFullScreen(image) : setDataUpdate(image)}"
227
- height="250"
231
+ <v-input v-model="images" v-bind="$attrs" ref="inputRef">
232
+ <template #default="{ isReadonly, isDisabled }">
233
+ <VCard>
234
+ <VToolbar density="compact">
235
+ <VToolbarTitle>{{ label }}</VToolbarTitle>
236
+ <v-spacer />
237
+ <VToolbarItems v-if="!readonly">
238
+ <FileBtn
239
+ v-model="uploadImages"
240
+ :accept="accept"
241
+ color="primary"
242
+ icon="mdi:mdi-image-plus"
243
+ icon-only
244
+ multiple
245
+ variant="text"
246
+ @update:model-value="uploadImageFile"
247
+ :disabled="isDisabled?.value" :readonly="isReadonly?.value"
228
248
  />
229
- </VCard>
230
- </VCol>
231
- </VRow>
232
- </VCardText>
233
- </VCard>
234
-
235
-
236
- <VDialog
237
- v-model="dialogUpdate"
238
- fullscreen
239
- transition="dialog-bottom-transition"
240
- >
241
- <FormImagesPad
242
- v-model="dataUpdate.data"
243
- @closedDialog="dialogUpdate = false"
244
- ></FormImagesPad>
245
- </VDialog>
246
-
247
- <FormDialog v-model="dialog" :form-data="modelData" @create="captureImage">
248
- <template #default="{ data }">
249
- <FormImagesCapture v-model="data.imageCapture"/>
249
+ <v-btn color="primary" icon @click="dialog = true" :disabled="isDisabled?.value" :readonly="isReadonly?.value">
250
+ <v-icon>mdi mdi-camera-plus</v-icon>
251
+ </v-btn>
252
+ </VToolbarItems>
253
+ </VToolbar>
254
+
255
+ <VCardText>
256
+ <VRow dense justify="center">
257
+ <VCol v-for="(image, index) in images" :key="`${imageKey(image)}-${index}`" cols="4">
258
+ <VCard>
259
+ <VToolbar density="compact">
260
+ <VToolbarTitle>
261
+ {{ image.imageTitle }}
262
+ </VToolbarTitle>
263
+ <VSpacer />
264
+ <VToolbarItems v-if="!readonly">
265
+ <v-btn icon @click="remove(index)" :disabled="isDisabled?.value" :readonly="isReadonly?.value">
266
+ <v-icon>mdi mdi-delete-outline</v-icon>
267
+ </v-btn>
268
+ <v-btn color="primary" icon @click="setDataUpdate(image)" v-if="!image.imageData?.id" :disabled="isDisabled?.value" :readonly="isReadonly?.value">
269
+ <v-icon>mdi mdi-image-edit-outline</v-icon>
270
+ </v-btn>
271
+ </VToolbarItems>
272
+ </VToolbar>
273
+
274
+ <v-img
275
+ :src="imageSrcFromImageData(image)"
276
+ height="250"
277
+ @click="() => { (props.readonly || image.imageData?.id || isReadonly?.value) ? openImageFullScreen(image) : setDataUpdate(image) }"
278
+ :disabled="isDisabled?.value"
279
+ />
280
+ </VCard>
281
+ </VCol>
282
+ </VRow>
283
+ </VCardText>
284
+ </VCard>
285
+
286
+ <!-- Edit dialog -->
287
+ <VDialog v-model="dialogUpdate" fullscreen transition="dialog-bottom-transition">
288
+ <FormImagesPad
289
+ v-model="dataUpdate.imageData.base64String"
290
+ @closedDialog="dialogUpdate = false"
291
+ />
292
+ </VDialog>
293
+
294
+ <!-- Capture dialog -->
295
+ <FormDialog v-model="dialog" :form-data="modelData" @create="captureImage">
296
+ <template #default="{ data }">
297
+ <FormImagesCapture v-model="data.imageCapture" />
298
+ </template>
299
+ </FormDialog>
300
+
301
+ <!-- Fullscreen preview -->
302
+ <v-dialog v-model="dialogImageFullScreen">
303
+ <v-toolbar :title="imageFullScreen.title">
304
+ <v-spacer />
305
+ <v-btn icon="mdi mdi-close" @click="dialogImageFullScreen = false" />
306
+ </v-toolbar>
307
+ <v-card height="80vh">
308
+ <v-img :src="imageFullScreen.image" />
309
+ </v-card>
310
+ </v-dialog>
250
311
  </template>
251
- </FormDialog>
252
-
253
- <v-dialog v-model="dialogImageFullScreen">
254
- <v-toolbar :title="imageFullScreen.title">
255
- <v-spacer/>
256
- <v-btn icon="mdi mdi-close" @click="dialogImageFullScreen = false"/>
257
- </v-toolbar>
258
- <v-card height="80vh">
259
- <v-img :src="imageFullScreen.image"/>
260
- </v-card>
261
-
262
- </v-dialog>
312
+ </v-input>
263
313
  </template>