pukaad-ui-lib 1.136.0 → 1.138.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pukaad-ui-lib",
3
3
  "configKey": "pukaadUI",
4
- "version": "1.136.0",
4
+ "version": "1.138.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -1,9 +1,12 @@
1
1
  export interface DrawerPostBlogItem {
2
2
  title: string;
3
3
  content: object[];
4
- tags: string[];
4
+ tags: any[];
5
5
  disableComment: boolean;
6
- coverImage: File[];
6
+ coverImage: {
7
+ file?: File;
8
+ url: string;
9
+ }[];
7
10
  }
8
11
  export interface DrawerPostBlogProps {
9
12
  item?: DrawerPostBlogItem;
@@ -58,8 +58,13 @@
58
58
  <div class="flex justify-end gap-[16px] items-center">
59
59
  <Button variant="outline" @click="onClose">ยกเลิก</Button>
60
60
  <Button @click="onSaveDraft"> บันทึกแบบร่าง </Button>
61
- <Button type="submit" color="primary" :disabled="!meta.valid">
62
- เผยแพร่
61
+ <Button
62
+ type="submit"
63
+ color="primary"
64
+ :disabled="!meta.valid || isLoading"
65
+ >
66
+ <span v-if="isLoading">กำลังบันทึก...</span>
67
+ <span v-else>เผยแพร่</span>
63
68
  </Button>
64
69
  </div>
65
70
  </template>
@@ -68,8 +73,10 @@
68
73
 
69
74
  <script setup>
70
75
  import { ref, watch, computed } from "vue";
71
- import { useNuxtApp } from "nuxt/app";
72
- const { $alert } = useNuxtApp();
76
+ import { useNuxtApp, useRuntimeConfig, useCookie } from "nuxt/app";
77
+ import { useApi } from "../../composables/useApi";
78
+ const { $alert, $toast } = useNuxtApp();
79
+ const api = useApi();
73
80
  const emit = defineEmits(["submit", "saveDraft"]);
74
81
  const props = defineProps({
75
82
  item: { type: Object, required: false, default: () => ({
@@ -81,6 +88,7 @@ const props = defineProps({
81
88
  }) }
82
89
  });
83
90
  const form = ref(JSON.parse(JSON.stringify(props.item)));
91
+ const isLoading = ref(false);
84
92
  const isOpen = defineModel({ type: Boolean, ...{
85
93
  default: false
86
94
  } });
@@ -115,9 +123,89 @@ const onSaveDraft = () => {
115
123
  emit("saveDraft", form.value);
116
124
  console.log("onSaveDraft", form.value);
117
125
  };
118
- const onSubmit = () => {
119
- emit("submit", form.value);
120
- console.log("onSubmit", form.value);
121
- isOpen.value = false;
126
+ const uploadImage = async (file) => {
127
+ try {
128
+ const config = useRuntimeConfig();
129
+ const baseURL = config.public.BASE_URL_API;
130
+ const res = await $fetch(`${baseURL}/storage/presigned-urls`, {
131
+ method: "POST",
132
+ headers: {
133
+ "Content-Type": "application/json"
134
+ // Note: We might need Auth token here if the endpoint requires it,
135
+ // but user instruction said "ห้าม ส่ง Authorization Header ไปยัง S3"
136
+ // It didn't say forbid Auth to /storage/presigned-urls.
137
+ // Let's try adding token manually if useApi was doing it.
138
+ },
139
+ body: JSON.stringify({
140
+ file_name: [file.name],
141
+ state: "blog"
142
+ }),
143
+ onRequest({ options }) {
144
+ const { APP_TYPE } = config.public;
145
+ const secId = useCookie(
146
+ APP_TYPE === "OFFICE" ? "OFFICE_SEC_ID" : "SEC_ID"
147
+ );
148
+ if (secId.value) {
149
+ options.headers = new Headers(options.headers);
150
+ options.headers.set("Authorization", `Bearer ${secId.value}`);
151
+ }
152
+ }
153
+ });
154
+ if (res.code !== 200 || !res.data?.items?.[0]) {
155
+ throw new Error(res.message || "Failed to generate upload URL");
156
+ }
157
+ const item = res.data.items[0];
158
+ console.log("Uploading to S3:", item.upload_url);
159
+ await fetch(item.upload_url, {
160
+ method: "PUT",
161
+ body: file,
162
+ headers: {
163
+ "Content-Type": file.type
164
+ }
165
+ });
166
+ return item.public_url;
167
+ } catch (error) {
168
+ console.error("Upload failed:", error);
169
+ throw error;
170
+ }
171
+ };
172
+ const onSubmit = async () => {
173
+ isLoading.value = true;
174
+ try {
175
+ let coverImageUrl = "";
176
+ if (form.value.coverImage && form.value.coverImage.length > 0) {
177
+ const fileItem = form.value.coverImage[0];
178
+ if (fileItem && fileItem.file) {
179
+ console.log("File found:", fileItem.file);
180
+ console.log("File name:", fileItem.file.name);
181
+ coverImageUrl = await uploadImage(fileItem.file);
182
+ }
183
+ } else {
184
+ console.log("No cover image selected");
185
+ }
186
+ const payload = {
187
+ title: form.value.title,
188
+ content: JSON.stringify(form.value.content),
189
+ tags: form.value.tags.map((t) => t.name || t),
190
+ cover_image_url: coverImageUrl
191
+ };
192
+ console.log("Submitting blog payload:", payload);
193
+ const res = await api("/blogs", {
194
+ method: "POST",
195
+ body: payload
196
+ });
197
+ if (res.code === "SUCCESS_CREATED") {
198
+ $toast.success("\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E1A\u0E17\u0E04\u0E27\u0E32\u0E21\u0E2A\u0E33\u0E40\u0E23\u0E47\u0E08");
199
+ emit("submit", form.value);
200
+ isOpen.value = false;
201
+ } else {
202
+ throw new Error(res.message || "Failed to create blog");
203
+ }
204
+ } catch (error) {
205
+ $toast.error(error.message || "\u0E40\u0E01\u0E34\u0E14\u0E02\u0E49\u0E2D\u0E1C\u0E34\u0E14\u0E1E\u0E25\u0E32\u0E14\u0E43\u0E19\u0E01\u0E32\u0E23\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E1A\u0E17\u0E04\u0E27\u0E32\u0E21");
206
+ console.error("Submit error:", error);
207
+ } finally {
208
+ isLoading.value = false;
209
+ }
122
210
  };
123
211
  </script>
@@ -1,9 +1,12 @@
1
1
  export interface DrawerPostBlogItem {
2
2
  title: string;
3
3
  content: object[];
4
- tags: string[];
4
+ tags: any[];
5
5
  disableComment: boolean;
6
- coverImage: File[];
6
+ coverImage: {
7
+ file?: File;
8
+ url: string;
9
+ }[];
7
10
  }
8
11
  export interface DrawerPostBlogProps {
9
12
  item?: DrawerPostBlogItem;
@@ -36,9 +36,9 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {
36
36
  id: string;
37
37
  name: string;
38
38
  description: string;
39
+ limit: number;
39
40
  options: AutocompleteOption[] | string[] | number[];
40
41
  placeholder: string;
41
- limit: number;
42
42
  disabledErrorMessage: boolean;
43
43
  disabledBorder: boolean;
44
44
  showCounter: boolean;
@@ -36,9 +36,9 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {
36
36
  id: string;
37
37
  name: string;
38
38
  description: string;
39
+ limit: number;
39
40
  options: AutocompleteOption[] | string[] | number[];
40
41
  placeholder: string;
41
- limit: number;
42
42
  disabledErrorMessage: boolean;
43
43
  disabledBorder: boolean;
44
44
  showCounter: boolean;
@@ -26,8 +26,8 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {},
26
26
  }>, {
27
27
  name: string;
28
28
  state: "user" | "admin";
29
- placeholder: string;
30
29
  limit: number;
30
+ placeholder: string;
31
31
  ignore: string[];
32
32
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
33
33
  declare const _default: typeof __VLS_export;
@@ -20,12 +20,12 @@
20
20
  <!-- Selected Tags as Chips -->
21
21
  <Chip
22
22
  v-for="tag in selectedTags"
23
- :key="tag.value"
23
+ :key="tag.id"
24
24
  closable
25
- @closable="onRemoveTag(tag.value)"
25
+ @closable="onRemoveTag(tag.id)"
26
26
  class="bg-bright"
27
27
  >
28
- {{ tag.label }}
28
+ {{ tag.name }}
29
29
  </Chip>
30
30
  <!-- Input for search -->
31
31
  <InputTextField
@@ -61,7 +61,7 @@
61
61
 
62
62
  <!-- Popular Tab -->
63
63
  <ShadTabsContent value="popular" class="m-0">
64
- <ShadCommandList class="max-h-64">
64
+ <ShadCommandList class="max-h-64 overflow-y-auto">
65
65
  <ShadCommandGroup>
66
66
  <ShadCommandItem
67
67
  v-for="tag in filteredPopularTags"
@@ -76,7 +76,13 @@
76
76
  </div>
77
77
  </ShadCommandItem>
78
78
  </ShadCommandGroup>
79
- <ShadCommandEmpty :force-show="filteredPopularTags.length === 0">
79
+
80
+ <!-- Initital Loading / Empty State -->
81
+ <ShadCommandEmpty
82
+ :force-show="
83
+ filteredPopularTags.length === 0 && !popularState.loading
84
+ "
85
+ >
80
86
  <div
81
87
  class="flex flex-col items-center justify-center h-full gap-2"
82
88
  >
@@ -84,12 +90,23 @@
84
90
  <div class="text-gray font-body-medium">ไม่พบข้อมูล</div>
85
91
  </div>
86
92
  </ShadCommandEmpty>
93
+
94
+ <!-- Loading Indicator -->
95
+ <div v-if="popularState.loading" class="flex justify-center p-2">
96
+ <Icon
97
+ name="lucide:loader-2"
98
+ class="animate-spin h-5 w-5 text-primary"
99
+ />
100
+ </div>
101
+
102
+ <!-- Sentinel for Infinite Scroll -->
103
+ <div ref="popularSentinel" class="h-1" />
87
104
  </ShadCommandList>
88
105
  </ShadTabsContent>
89
106
 
90
107
  <!-- Latest Tab -->
91
108
  <ShadTabsContent value="latest" class="m-0">
92
- <ShadCommandList class="max-h-64">
109
+ <ShadCommandList class="max-h-64 overflow-y-auto">
93
110
  <ShadCommandGroup>
94
111
  <ShadCommandItem
95
112
  v-for="tag in filteredLatestTags"
@@ -104,7 +121,13 @@
104
121
  </div>
105
122
  </ShadCommandItem>
106
123
  </ShadCommandGroup>
107
- <ShadCommandEmpty :force-show="filteredLatestTags.length === 0">
124
+
125
+ <!-- Initital Loading / Empty State -->
126
+ <ShadCommandEmpty
127
+ :force-show="
128
+ filteredLatestTags.length === 0 && !latestState.loading
129
+ "
130
+ >
108
131
  <div
109
132
  class="flex flex-col items-center justify-center h-full gap-2"
110
133
  >
@@ -112,6 +135,17 @@
112
135
  <div class="text-gray font-body-medium">ไม่พบข้อมูล</div>
113
136
  </div>
114
137
  </ShadCommandEmpty>
138
+
139
+ <!-- Loading Indicator -->
140
+ <div v-if="latestState.loading" class="flex justify-center p-2">
141
+ <Icon
142
+ name="lucide:loader-2"
143
+ class="animate-spin h-5 w-5 text-primary"
144
+ />
145
+ </div>
146
+
147
+ <!-- Sentinel for Infinite Scroll -->
148
+ <div ref="latestSentinel" class="h-1" />
115
149
  </ShadCommandList>
116
150
  </ShadTabsContent>
117
151
  </ShadTabs>
@@ -121,9 +155,10 @@
121
155
  </template>
122
156
 
123
157
  <script setup>
124
- import { ref, computed, onMounted } from "vue";
158
+ import { ref, computed, onMounted, reactive, watch } from "vue";
125
159
  import { useNuxtApp } from "nuxt/app";
126
160
  import { useApi } from "../../composables/useApi";
161
+ import { useIntersectionObserver } from "@vueuse/core";
127
162
  const { $convert } = useNuxtApp();
128
163
  const convertNumber = (val) => $convert?.convertNumber(val) || val;
129
164
  const api = useApi();
@@ -146,34 +181,78 @@ const popoverOpen = ref(false);
146
181
  const currentTab = ref("popular");
147
182
  const searchQuery = ref("");
148
183
  const inputRef = ref();
149
- const isLoading = ref(false);
150
- const allTags = ref([]);
151
- const popularTags = ref([]);
152
- const latestTags = ref([]);
184
+ const popularState = reactive({
185
+ data: [],
186
+ page: 1,
187
+ hasMore: true,
188
+ loading: false
189
+ });
190
+ const latestState = reactive({
191
+ data: [],
192
+ page: 1,
193
+ hasMore: true,
194
+ loading: false
195
+ });
153
196
  const mapTag = (tag) => ({
154
197
  label: tag.name,
155
198
  value: tag.id,
156
199
  postCount: tag.usage_count
157
200
  });
158
- const fetchTags = async () => {
159
- isLoading.value = true;
201
+ const PAGE_SIZE = 20;
202
+ const fetchTags = async (type) => {
203
+ const state = type === "popular" ? popularState : latestState;
204
+ if (state.loading || !state.hasMore) return;
205
+ state.loading = true;
160
206
  try {
161
- const res = await api("/tags");
162
- if (res.code === "SUCCESS_OK" && res.data) {
163
- popularTags.value = res.data.popular.map(mapTag);
164
- latestTags.value = res.data.last.map(mapTag);
165
- allTags.value = [...popularTags.value, ...latestTags.value];
207
+ const res = await api(
208
+ `/tags/${type}?page=${state.page}&page_size=${PAGE_SIZE}`
209
+ );
210
+ if ((res.code === 200 || res.code === "SUCCESS_OK") && res.data) {
211
+ const newTags = res.data.map(mapTag);
212
+ state.data = [...state.data, ...newTags];
213
+ state.page++;
214
+ state.hasMore = state.page <= res.meta.total_page;
215
+ } else {
216
+ state.hasMore = false;
166
217
  }
167
218
  } catch (e) {
168
- console.error("Failed to fetch tags:", e);
219
+ console.error(`Failed to fetch ${type} tags:`, e);
220
+ state.hasMore = false;
169
221
  } finally {
170
- isLoading.value = false;
222
+ state.loading = false;
171
223
  }
172
224
  };
173
- onMounted(fetchTags);
174
- const selectedTags = computed(() => {
175
- return modelValue.value.map((selected) => allTags.value.find((tag) => tag.value === selected.id)).filter((tag) => tag !== void 0);
225
+ watch(popoverOpen, (isOpen) => {
226
+ if (isOpen) {
227
+ if (currentTab.value === "popular" && popularState.data.length === 0) {
228
+ fetchTags("popular");
229
+ } else if (currentTab.value === "latest" && latestState.data.length === 0) {
230
+ fetchTags("latest");
231
+ }
232
+ }
233
+ });
234
+ const popularSentinel = ref(null);
235
+ const latestSentinel = ref(null);
236
+ useIntersectionObserver(popularSentinel, (entries) => {
237
+ const entry = entries[0];
238
+ if (entry?.isIntersecting && currentTab.value === "popular" && popoverOpen.value) {
239
+ fetchTags("popular");
240
+ }
241
+ });
242
+ useIntersectionObserver(latestSentinel, (entries) => {
243
+ const entry = entries[0];
244
+ if (entry?.isIntersecting && currentTab.value === "latest" && popoverOpen.value) {
245
+ fetchTags("latest");
246
+ }
247
+ });
248
+ watch(currentTab, (newTab) => {
249
+ if (newTab === "popular" && popularState.data.length === 0) {
250
+ fetchTags("popular");
251
+ } else if (newTab === "latest" && latestState.data.length === 0) {
252
+ fetchTags("latest");
253
+ }
176
254
  });
255
+ const selectedTags = computed(() => modelValue.value);
177
256
  const isLimitReached = computed(() => {
178
257
  if (props.limit <= 0) return false;
179
258
  return modelValue.value.length >= props.limit;
@@ -196,8 +275,8 @@ const handleInteractOutside = (event) => {
196
275
  event.preventDefault();
197
276
  }
198
277
  };
199
- const filteredPopularTags = computed(() => {
200
- const availableTags = popularTags.value.filter(
278
+ const filterTags = (tags) => {
279
+ const availableTags = tags.filter(
201
280
  (tag) => !isSelected(tag.value) && !props.ignore.includes(tag.value) && !props.ignore.includes(tag.label)
202
281
  );
203
282
  if (!searchQuery.value) return availableTags;
@@ -205,17 +284,9 @@ const filteredPopularTags = computed(() => {
205
284
  return availableTags.filter(
206
285
  (tag) => tag.label.toLowerCase().includes(query) || tag.value.toLowerCase().includes(query)
207
286
  );
208
- });
209
- const filteredLatestTags = computed(() => {
210
- const availableTags = latestTags.value.filter(
211
- (tag) => !isSelected(tag.value) && !props.ignore.includes(tag.value) && !props.ignore.includes(tag.label)
212
- );
213
- if (!searchQuery.value) return availableTags;
214
- const query = searchQuery.value.toLowerCase();
215
- return availableTags.filter(
216
- (tag) => tag.label.toLowerCase().includes(query) || tag.value.toLowerCase().includes(query)
217
- );
218
- });
287
+ };
288
+ const filteredPopularTags = computed(() => filterTags(popularState.data));
289
+ const filteredLatestTags = computed(() => filterTags(latestState.data));
219
290
  const onSelectTag = (tag) => {
220
291
  if (isSelected(tag.value)) {
221
292
  modelValue.value = modelValue.value.filter((t) => t.id !== tag.value);
@@ -247,7 +318,7 @@ const onAddCustomTag = () => {
247
318
  return;
248
319
  }
249
320
  const newValue = trimmedQuery.toLowerCase().replace(/\s+/g, "-");
250
- const existingTag = allTags.value.find(
321
+ const existingTag = [...popularState.data, ...latestState.data].find(
251
322
  (tag) => tag.value === newValue || tag.label === trimmedQuery
252
323
  );
253
324
  if (existingTag) {
@@ -262,12 +333,6 @@ const onAddCustomTag = () => {
262
333
  ];
263
334
  }
264
335
  } else {
265
- const newTag = {
266
- label: trimmedQuery,
267
- value: newValue,
268
- postCount: 0
269
- };
270
- allTags.value = [...allTags.value, newTag];
271
336
  modelValue.value = [
272
337
  ...modelValue.value,
273
338
  { id: newValue, name: trimmedQuery, usage_count: 0 }
@@ -26,8 +26,8 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {},
26
26
  }>, {
27
27
  name: string;
28
28
  state: "user" | "admin";
29
- placeholder: string;
30
29
  limit: number;
30
+ placeholder: string;
31
31
  ignore: string[];
32
32
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
33
33
  declare const _default: typeof __VLS_export;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pukaad-ui-lib",
3
- "version": "1.136.0",
3
+ "version": "1.138.0",
4
4
  "description": "pukaad-ui for MeMSG",
5
5
  "repository": {
6
6
  "type": "git",