tantee-nuxt-commons 0.0.167 → 0.0.169

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,220 +1,195 @@
1
1
  <script lang="ts" setup>
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 } from '../../../composables/assetFile'
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
+
6
7
 
7
8
  const emit = defineEmits<{
8
- (e: 'update:modelValue', value: Base64Image[]): void
9
- }>()
9
+ (e: "update:modelValue", value: {}): void;
10
+ }>();
10
11
 
11
12
  const alert = useAlert()
12
- const { fileToBase64, hydrateAssetFile } = useAssetFile()
13
+
14
+ interface Image {
15
+ title: string;
16
+ data: string;
17
+ props: {};
18
+ }
13
19
 
14
20
  interface Props {
15
- modelValue?: Base64Image[]
16
- readonly?: boolean
17
- label?: string
18
- accept?: string
19
- autoHydrate?: boolean
21
+ modelValue?: any[];
22
+ readonly?: boolean;
23
+ label?: string;
20
24
  }
21
25
  const props = withDefaults(defineProps<Props>(), {
22
- modelValue: () => [] as Base64Image[],
23
- accept: '.jpg,.jpeg,.png,.webp,.gif,.bmp,.tiff,.tif',
24
- autoHydrate: false,
26
+ modelValue: () => [] as any[],
25
27
  })
26
28
 
27
- /** Internal state (always Base64Image[]) */
28
- const images = ref<Base64Image[]>([])
29
- const uploadImages = ref<File[]>([])
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
+ };
30
47
 
31
- /** Dialogs */
32
- const dialog = ref(false) // capture dialog
33
- const dialogUpdate = ref(false) // edit dialog
34
- const dataUpdate = ref<Base64Image>({ imageData: {}, imageTitle: '', imageProps: {} })
48
+ // open dialog for editing selected image
49
+ const setDataUpdate = (data: Image) => {
50
+ dataUpdate.value = data;
51
+ dialogUpdate.value = true;
52
+ };
35
53
 
36
- /** Fullscreen preview */
37
- const dialogImageFullScreen = ref(false)
38
- const imageFullScreen = ref({ title: '', image: '' })
39
54
 
40
- /** ---------- Stable keys + guards ---------- */
41
- let internalSync = false
42
- let lastEmittedSig = '' // signature of last emitted state
43
55
 
44
- function imageKey(im: Base64Image): string {
45
- const id = im.imageData?.id
46
- if (id != null) return `id:${id}`
47
- const title = im.imageTitle ?? ''
48
- const len = im.imageData?.base64String?.length ?? 0
49
- return `t:${title}|l:${len}`
50
- }
51
-
52
- function signature(arr: Base64Image[]): string {
53
- return arr.map(imageKey).join('|')
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
+ }
54
61
  }
55
-
56
- /** Emit helper (guarded + updates last signature) */
57
- function emitNow(next: Base64Image[]) {
58
- const sig = signature(next)
59
- if (sig === lastEmittedSig) return
60
- internalSync = true
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) => {
61
70
  try {
62
- emit('update:modelValue', next)
63
- lastEmittedSig = sig
64
- } finally {
65
- queueMicrotask(() => { internalSync = false })
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
91
  }
67
- }
68
-
69
- /** ---------- Helpers ---------- */
92
+ };
70
93
 
71
- const addImage = (img: Base64Image) => {
94
+ // push image into Field
95
+ const addImage = (data: Image) => {
72
96
  images.value.push({
73
- imageData: img.imageData ?? {},
74
- imageTitle: img.imageTitle ?? '',
75
- imageProps: img.imageProps ?? {},
76
- })
77
- dialog.value = false
78
- }
79
-
80
- const remove = (index: number) => {
81
- images.value.splice(index, 1)
82
- }
83
-
84
- const setDataUpdate = (img: Base64Image) => {
85
- dataUpdate.value = {
86
- imageData: { ...(img.imageData ?? {}) },
87
- imageTitle: img.imageTitle ?? '',
88
- imageProps: { ...(img.imageProps ?? {}) },
89
- }
90
- dialogUpdate.value = true
91
- }
92
-
93
- const checkDuplicationName = (name: string) =>
94
- images.value.some(({ imageTitle }) => isEqual(imageTitle, name))
95
-
96
- const isImageDataUrl = (dataUrl: string) => /^data:image\//i.test(dataUrl)
97
+ title: data.title,
98
+ data: data.data,
99
+ props: {},
100
+ });
101
+ dialog.value = false;
102
+ };
97
103
 
98
- /** File → Base64Image using composable */
99
- const fileToBase64Image = async (file: File): Promise<Base64Image | null> => {
100
- try {
101
- const b64 = await fileToBase64(file)
102
- const dataUrl = b64.base64String || ''
103
- if (!isImageDataUrl(dataUrl)) {
104
- alert?.addAlert({ message: `ไฟล์ "${b64.fileName}" ไม่ใช่ไฟล์รูปภาพที่รองรับ`, alertType: 'error' })
105
- return null
106
- }
107
- return {
108
- imageData: { base64String: dataUrl } as Base64Asset,
109
- imageTitle: b64.fileName,
110
- imageProps: {},
111
- }
112
- } catch (e: any) {
113
- alert?.addAlert({ message: String(e), alertType: 'error' })
114
- return null
115
- }
116
- }
117
-
118
- /** Handle upload button update */
119
104
  const update = async () => {
120
- const duplicated: string[] = []
121
- for (const file of uploadImages.value) {
122
- if (checkDuplicationName(file.name)) {
123
- duplicated.push(file.name)
124
- continue
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
+ }
125
123
  }
126
- const base64Image = await fileToBase64Image(file)
127
- if (base64Image) addImage(base64Image)
128
124
  }
129
125
  uploadImages.value = []
130
- if (duplicated.length) {
131
- alert?.addAlert({ message: `ไม่สามารถอัพโหลดไฟล์ ${duplicated.join(', ')}`, alertType: 'error' })
126
+ if (duplicatedFileName.value !== "") {
127
+ alert?.addAlert({message: `ไม่สามารถอัพโหลดไฟล์ ${duplicatedFileName.value}`, alertType: 'error'})
128
+ duplicatedFileName.value = ""
132
129
  }
133
- }
134
-
135
- /** Capture flow (FormDialog) */
136
- type FormDialogCallback = { done: () => void }
137
- const modelData = ref()
130
+ };
138
131
 
139
- const captureImage = (payload: any, cb: FormDialogCallback) => {
140
- const dataUrl: string = payload?.imageCapture ?? ''
141
- if (!dataUrl || !isImageDataUrl(dataUrl)) {
142
- alert?.addAlert({ message: 'รูปภาพไม่ถูกต้อง', alertType: 'error' })
143
- return
144
- }
132
+ // Capture Image
133
+ const modelData = ref();
134
+ const captureImage = (image: any, callback: FormDialogCallback) => {
145
135
  addImage({
146
- imageData: { base64String: dataUrl },
147
- imageTitle: Math.random().toString(36).slice(2, 11),
148
- imageProps: {},
136
+ title: Math.random().toString(36).slice(2, 11),
137
+ data: image.imageCapture,
138
+ props: {}
149
139
  })
150
- cb?.done?.()
140
+ callback.done()
151
141
  }
152
142
 
153
- /** Fullscreen preview */
154
- const openImageFullScreen = (img: Base64Image) => {
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 }) => {
155
151
  dialogImageFullScreen.value = true
156
- imageFullScreen.value.title = img.imageTitle ?? ''
157
- imageFullScreen.value.image = img.imageData?.base64String ?? ''
152
+ imageFullScreen.value.title = image.title
153
+ imageFullScreen.value.image = image.data
158
154
  }
159
155
 
160
- /** ---------- Watchers (signature-based) ---------- */
161
156
 
162
- /* Parent Internal */
163
- watch(
164
- () => props.modelValue,
165
- async (val) => {
166
- if (internalSync) return
157
+ watch(images, () => {
158
+ emit("update:modelValue", images.value);
159
+ }, {deep: true});
167
160
 
168
- const next = Array.isArray(val) ? [...val] : []
169
- const nextSig = signature(next)
170
161
 
171
- // Only reassign when truly different
172
- if (nextSig !== signature(images.value)) {
173
- images.value = next
162
+ // updating when the parent component adds image to v-model
163
+ watch(props.modelValue, () => {
164
+ images.value = props.modelValue
165
+ }, {deep: true});
174
166
 
175
- // optional hydration
176
- if (props.autoHydrate && images.value.length) {
177
- const targets = images.value.filter(
178
- (im) => im.imageData?.id != null && !im.imageData?.base64String
179
- )
180
- if (targets.length) {
181
- await Promise.allSettled(targets.map((im) => hydrateAssetFile(im.imageData!)))
182
- // After hydration, emit once (guarded) to update parent
183
- emitNow(images.value)
184
- }
185
- }
186
167
 
187
- // sync lastEmittedSig to current internal state so next local change emits
188
- lastEmittedSig = signature(images.value)
189
- }
190
- },
191
- { deep: true, immediate: true }
192
- )
193
-
194
- /* Internal → Parent: watch the signature instead of deep structure */
195
- watch(
196
- () => signature(images.value),
197
- () => {
198
- if (internalSync) return
199
- // Only emit when signature actually changes
200
- emitNow(images.value)
201
- },
202
- { immediate: false }
203
- )
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
+ }
177
+ })
204
178
  </script>
205
179
 
206
180
  <template>
207
181
  <VCard>
208
182
  <VToolbar density="compact">
209
183
  <VToolbarTitle>{{ label }}</VToolbarTitle>
210
- <v-spacer />
184
+ <v-spacer></v-spacer>
211
185
  <VToolbarItems v-if="!readonly">
212
186
  <FileBtn
187
+ ref="fileBtn"
213
188
  v-model="uploadImages"
214
- :accept="accept"
189
+ accept=".jpg, .jpeg"
215
190
  color="primary"
216
191
  icon="mdi:mdi-image-plus"
217
- icon-only
192
+ iconOnly
218
193
  multiple
219
194
  variant="text"
220
195
  @update:model-value="update"
@@ -224,30 +199,32 @@ watch(
224
199
  </v-btn>
225
200
  </VToolbarItems>
226
201
  </VToolbar>
227
-
228
202
  <VCardText>
229
203
  <VRow dense justify="center">
230
- <VCol v-for="(image, index) in images" :key="`${imageKey(image)}-${index}`" cols="4">
204
+ <VCol v-for="(image, index) in images" :key="index" cols="4">
231
205
  <VCard>
232
206
  <VToolbar density="compact">
233
207
  <VToolbarTitle>
234
- {{ image.imageTitle }}
208
+ {{ image.title }}
235
209
  </VToolbarTitle>
236
- <VSpacer />
210
+ <VSpacer></VSpacer>
237
211
  <VToolbarItems v-if="!readonly">
238
212
  <v-btn icon @click="remove(index)">
239
213
  <v-icon>mdi mdi-delete-outline</v-icon>
240
214
  </v-btn>
241
- <v-btn color="primary" icon @click="setDataUpdate(image)">
215
+ <v-btn
216
+ color="primary"
217
+ icon
218
+ @click="setDataUpdate(image)"
219
+ >
242
220
  <v-icon>mdi mdi-image-edit-outline</v-icon>
243
221
  </v-btn>
244
222
  </VToolbarItems>
245
223
  </VToolbar>
246
-
247
224
  <v-img
248
- :src="image.imageData?.base64String"
225
+ :src="image.data"
226
+ @click="() => { (props.readonly) ? openImageFullScreen(image) : setDataUpdate(image)}"
249
227
  height="250"
250
- @click="() => { props.readonly ? openImageFullScreen(image) : setDataUpdate(image) }"
251
228
  />
252
229
  </VCard>
253
230
  </VCol>
@@ -255,29 +232,32 @@ watch(
255
232
  </VCardText>
256
233
  </VCard>
257
234
 
258
- <!-- Edit dialog -->
259
- <VDialog v-model="dialogUpdate" fullscreen transition="dialog-bottom-transition">
235
+
236
+ <VDialog
237
+ v-model="dialogUpdate"
238
+ fullscreen
239
+ transition="dialog-bottom-transition"
240
+ >
260
241
  <FormImagesPad
261
- v-model="dataUpdate.imageData.base64String"
242
+ v-model="dataUpdate.data"
262
243
  @closedDialog="dialogUpdate = false"
263
- />
244
+ ></FormImagesPad>
264
245
  </VDialog>
265
246
 
266
- <!-- Capture dialog -->
267
247
  <FormDialog v-model="dialog" :form-data="modelData" @create="captureImage">
268
248
  <template #default="{ data }">
269
- <FormImagesCapture v-model="data.imageCapture" />
249
+ <FormImagesCapture v-model="data.imageCapture"/>
270
250
  </template>
271
251
  </FormDialog>
272
252
 
273
- <!-- Fullscreen preview -->
274
253
  <v-dialog v-model="dialogImageFullScreen">
275
254
  <v-toolbar :title="imageFullScreen.title">
276
- <v-spacer />
277
- <v-btn icon="mdi mdi-close" @click="dialogImageFullScreen = false" />
255
+ <v-spacer/>
256
+ <v-btn icon="mdi mdi-close" @click="dialogImageFullScreen = false"/>
278
257
  </v-toolbar>
279
258
  <v-card height="80vh">
280
- <v-img :src="imageFullScreen.image" />
259
+ <v-img :src="imageFullScreen.image"/>
281
260
  </v-card>
261
+
282
262
  </v-dialog>
283
263
  </template>
@@ -2,39 +2,143 @@
2
2
  import { DateTime } from "luxon";
3
3
  import { computed } from "vue";
4
4
 
5
- type Locale = "th" | "en" | "en-US" | "th-TH";
5
+ type Unit = 'years' | 'months' | 'days' | 'hours' | 'minutes' | 'seconds';
6
+ type Locale = 'th' | 'en' | 'en-US' | 'th-TH';
6
7
 
7
8
  interface Props {
8
9
  modelValue: DateTime;
9
10
  endDate?: DateTime;
10
11
  locale?: Locale;
11
- zeroBase?: boolean; // true = start at 0, false = start at 1
12
+ showSuffix?: boolean;
13
+ units?: Unit[];
14
+ zeroBase?: boolean; // true = start at 0, false = start at 1 (inclusive)
12
15
  }
13
16
 
14
17
  const props = withDefaults(defineProps<Props>(), {
15
- locale: "th",
18
+ locale: 'th',
19
+ showSuffix: true,
16
20
  zeroBase: true,
17
21
  });
18
22
 
19
- const normalizeLocale = (locale: Locale): "en" | "th" =>
20
- locale.startsWith("en") ? "en" : "th";
23
+ // Fallback map: map complex locale (e.g., en-US) to base (e.g., en)
24
+ const normalizeLocale = (locale: Locale): 'en' | 'th' => {
25
+ if (locale.startsWith('en')) return 'en';
26
+ if (locale.startsWith('th')) return 'th';
27
+ return 'th'; // default fallback
28
+ };
21
29
 
22
- const countDate = computed(() => {
23
- const base = props.endDate ?? DateTime.now();
30
+ const labelsEnPlural: Record<Unit, string> = {
31
+ years: 'years',
32
+ months: 'months',
33
+ days: 'days',
34
+ hours: 'hours',
35
+ minutes: 'minutes',
36
+ seconds: 'seconds',
37
+ };
38
+ const labelsEnSingular: Record<Unit, string> = {
39
+ years: 'year',
40
+ months: 'month',
41
+ days: 'day',
42
+ hours: 'hour',
43
+ minutes: 'minute',
44
+ seconds: 'second',
45
+ };
46
+
47
+ const localizedLabels: Record<'en' | 'th', Record<Unit, string>> = {
48
+ en: labelsEnPlural, // จะสลับเป็น singular ตามค่าจริงตอน render
49
+ th: {
50
+ years: 'ปี',
51
+ months: 'เดือน',
52
+ days: 'วัน',
53
+ hours: 'ชั่วโมง',
54
+ minutes: 'นาที',
55
+ seconds: 'วินาที',
56
+ },
57
+ };
58
+
59
+ const localizedSuffix: Record<'en' | 'th', string> = {
60
+ en: 'ago',
61
+ th: 'ที่ผ่านมา',
62
+ };
63
+
64
+ const outputText = computed(() => {
65
+ const base = props.endDate ?? DateTime.now(); // ใช้ endDate ถ้ามี ไม่งั้น now
24
66
  const baseLocale = normalizeLocale(props.locale);
25
67
 
26
- let days = Math.floor(base.diff(props.modelValue, "days").days ?? 0);
68
+ const units: Unit[] = props.units?.length
69
+ ? props.units
70
+ : ['years', 'months', 'days', 'hours', 'minutes', 'seconds'];
71
+
72
+ const diffObj = base.diff(props.modelValue, units).toObject();
73
+
74
+ // helper: คืน label ตาม singular/plural (เฉพาะ en)
75
+ const labelFor = (unit: Unit, value: number) => {
76
+ if (baseLocale === 'en') {
77
+ return value === 1 ? labelsEnSingular[unit] : labelsEnPlural[unit];
78
+ }
79
+ return localizedLabels[baseLocale][unit];
80
+ };
81
+
82
+ // ---------- โหมด single unit ----------
83
+ if (!props.units) {
84
+ const foundUnit = units.find((unit) => (diffObj[unit] ?? 0) >= 1);
85
+ if (foundUnit) {
86
+ const raw = Math.floor(diffObj[foundUnit] ?? 0); // >= 1
87
+ const value = props.zeroBase ? raw : raw + 1; // inclusive (+1)
88
+ const label = labelFor(foundUnit, value);
89
+ const suffix = props.showSuffix ? localizedSuffix[baseLocale] : '';
90
+ return `${value} ${label}${suffix ? ` ${suffix}` : ''}`;
91
+ }
92
+
93
+ // ถ้าไม่มีหน่วยใด >= 1 ให้ใช้หน่วยเล็กสุด
94
+ const lastUnit = units.at(-1)!;
95
+ const raw = Math.max(0, Math.floor(diffObj[lastUnit] ?? 0));
96
+ const value = props.zeroBase ? raw : 1; // inclusive: อย่างน้อย 1
97
+ const label = labelFor(lastUnit, value);
98
+ const suffix = props.showSuffix ? localizedSuffix[baseLocale] : '';
99
+ return `${value} ${label}${suffix ? ` ${suffix}` : ''}`;
100
+ }
101
+
102
+ // ---------- โหมด multi-unit ----------
103
+ // เก็บค่าแบบตัวเลขก่อน แล้วค่อยเรนเดอร์
104
+ const values = units.map((unit) => ({
105
+ unit,
106
+ raw: Math.max(0, Math.floor(diffObj[unit] ?? 0)),
107
+ }));
108
+
109
+ // เลือกหน่วยที่เล็กที่สุด
110
+ const smallest = values[values.length - 1];
111
+
112
+ // ถ้ามีค่าอย่างน้อยหนึ่งหน่วย > 0 และเป็น inclusive (zeroBase=false) => +1 ที่หน่วยเล็กสุด
113
+ const anyPositive = values.some((v) => v.raw > 0);
114
+ const adjusted = values.map((v, idx) => {
115
+ if (!props.zeroBase && anyPositive && idx === values.length - 1) {
116
+ return { ...v, raw: v.raw + 1 };
117
+ }
118
+ return v;
119
+ });
27
120
 
28
- if (!props.zeroBase) days += 1;
121
+ // สร้างข้อความจากหน่วยที่มีค่า > 0
122
+ const parts = adjusted
123
+ .filter((v) => v.raw > 0)
124
+ .map((v) => `${v.raw} ${labelFor(v.unit, v.raw)}`);
29
125
 
30
- if (baseLocale === "en") {
31
- return days === 1 ? "1 day" : `${days} days`;
126
+ // หากทั้งหมดเป็น 0:
127
+ if (parts.length === 0) {
128
+ const minValue = props.zeroBase ? 0 : 1;
129
+ const label = labelFor(smallest.unit, minValue);
130
+ const suffix = props.showSuffix ? localizedSuffix[baseLocale] : '';
131
+ return `${minValue} ${label}${suffix ? ` ${suffix}` : ''}`;
32
132
  }
33
133
 
34
- return `${days} วัน`;
134
+ const suffix = props.showSuffix ? localizedSuffix[baseLocale] : '';
135
+ return `${parts.join(' ')}${suffix ? ` ${suffix}` : ''}`;
35
136
  });
36
137
  </script>
37
138
 
38
139
  <template>
39
- <span>{{ countDate }}</span>
140
+ <span>
141
+ <i v-if="props.zeroBase" class="mdi mdi-clock-time-twelve-outline"></i>
142
+ <span v-if="props.zeroBase">&nbsp;</span>{{ outputText }}
143
+ </span>
40
144
  </template>