@adminforth/bulk-ai-flow 1.25.2 → 1.26.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.
@@ -1,228 +1,322 @@
1
1
  <template>
2
- <Table
3
- ref="tableRef"
4
- :columns="tableHeaders"
5
- :data="tableDataProvider"
6
- :pageSize="pageSize"
7
- makeHeaderSticky
8
- makePaginationSticky
2
+ <div class="vision-table-container overflow-auto pr-2" style="max-height: calc(100vh - 260px);">
3
+ <div class="records-grid p-1 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6">
4
+ <div
5
+ v-for="item in paginatedRecords"
6
+ :key="item.id"
7
+ class="record-card bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-default p-4 shadow-sm hover:shadow-md transition-shadow relative flex flex-col justify-between gap-4"
9
8
  >
10
- <!-- HEADER TEMPLATE -->
11
- <template #header:checkboxes="{ item }">
12
- {{ $t('MARK FOR SAVE') }}
13
- </template>
14
- <!-- CHECKBOX CELL TEMPLATE -->
15
- <template #cell:checkboxes="{ item }">
16
- <div class="max-w-[100px] flex items-center justify-center">
17
- <Checkbox
18
- v-model="item.isChecked"
19
- />
20
- </div>
21
- </template>
22
- <!-- IMAGE CELL TEMPLATE -->
23
- <template #cell:images="{item}">
24
- <div class="flex flex-shrink-0 gap-2">
25
- <div v-if="item.images.length" v-for="image in item.images" :key="image">
26
- <div class="mt-2 flex items-center justify-center gap-2">
27
- <img
28
- v-if="isValidUrl(image)"
29
- :src="image"
30
- class="w-20 h-20 object-cover rounded cursor-pointer border hover:border-blue-500 transition my-2"
31
- @click="zoomImage(image)"
9
+ <div class="flex flex-col h-full">
10
+ <div class="record-card-header flex items-start justify-between gap-4 mb-4">
11
+ <div class="flex items-center gap-3 w-full min-w-0">
12
+ <Checkbox
13
+ v-model="item.isChecked"
14
+ class="shrink-0"
32
15
  />
33
- <div v-else class="w-20 h-20">
34
- <p>{{ $t('Invalid source image') }}</p>
16
+ <!-- CRITICAL WRAPPER -->
17
+ <div class="flex-1 min-w-0 w-0">
18
+ <h3 class="font-bold text-gray-900 dark:text-white w-full truncate overflow-hidden whitespace-nowrap">
19
+ {{ item.label || item.data?.name || $t('Item Record') }}
20
+ </h3>
21
+ </div>
22
+ </div>
23
+ <div class="flex items-center gap-3 shrink-0" v-if="hasNonImageOutputFields">
24
+ <div class="flex items-center gap-2">
25
+ <span class="text-xs font-medium text-gray-600 dark:text-gray-400">
26
+ {{ cardValueMode?.[String(item.id)] === 'old' ? $t('OLD VALUE') : $t('NEW VALUE') }}
27
+ </span>
28
+ <button
29
+ @click="toggleCardValueMode(item.id)"
30
+ class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
31
+ :class="cardValueMode?.[String(item.id)] === 'old' ? 'bg-lightPrimary dark:bg-darkPrimary' : 'bg-gray-200 dark:bg-gray-700'"
32
+ >
33
+ <span
34
+ class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform dark:bg-gray-200"
35
+ :class="cardValueMode?.[String(item.id)] === 'old' ? 'translate-x-6' : 'translate-x-1'"
36
+ />
37
+ </button>
35
38
  </div>
36
39
  </div>
37
40
  </div>
38
- <div class="flex items-center justify-center text-center w-20 h-20" v-else>
39
- <p>{{ $t('No images found') }}</p>
40
- </div>
41
- <transition name="fade">
41
+
42
+ <div class="flex flex-col gap-3 flex-grow">
42
43
  <div
43
- v-if="zoomedImage"
44
- class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
45
- @click.self="closeZoom"
44
+ v-if="props.meta.isAttachFiles"
45
+ class="source-images-section w-full flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-700 border border-dashed border-gray-200 dark:border-gray-600 h-full max-h-[260px] max-w-[446.5px] rounded-default text-center "
46
46
  >
47
- <transition name="zoom">
48
- <img
49
- v-if="zoomedImage"
50
- :src="zoomedImage"
51
- class="max-w-full max-h-full rounded-lg object-contain cursor-grab z-75"
52
- />
53
- </transition>
54
- </div>
55
- </transition>
56
- </div>
57
- </template>
58
- <!-- CUSTOM FIELD TEMPLATES -->
59
- <template v-for="n in customFieldNames" :key="n" #[`cell:${n}`]="{ item }">
60
- <div v-if="(isAnalyzing(item, n) && !item.regeneratingFieldsStatus?.[n]) && !isInColumnImage(n)" @mouseenter="(() => { setHover(item.id, n, true) })" @mouseleave="(() => { setHover(item.id, n, false) })">
61
- <div class="flex gap-1 justify-end">
62
- <Tooltip v-if="checkForError(item, n)">
63
- <IconExclamationCircleSolid class="my-2 w-5 h-5 text-red-500" />
64
- <template #tooltip>
65
- {{ checkForError(item, n) }}
66
- </template>
67
- </Tooltip>
68
- <Tooltip>
69
- <IconRefreshOutline class="my-2 w-5 h-5 hover:text-blue-500 cursor-pointer" :class="{ 'opacity-50 cursor-not-allowed hover': shouldDisableRegenerateFieldIcon(item, n) }" @click="regerenerateFieldIconClick(item, n)"/>
70
- <template #tooltip>
71
- {{ shouldDisableRegenerateFieldIcon(item, n) ? $t("Can't analyze image without source image") : $t('Regenerate') }}
47
+ <!-- HAS IMAGES -->
48
+ <template v-if="item.images?.length">
49
+ <template
50
+ v-for="(image, index) in item.images"
51
+ :key="image || index"
52
+ >
53
+ <img
54
+ v-if="image && isValidUrl(image)"
55
+ :src="image"
56
+ class="w-full h-full object-cover rounded-default cursor-pointer border hover:border-blue-500 transition"
57
+ @click="zoomImage(image)"
58
+ />
59
+
60
+ <div
61
+ v-else
62
+ class="text-xs text-red-500 flex flex-col items-center justify-center h-full p-2"
63
+ >
64
+ <IconExclamationCircleSolid class="w-6 h-6 mb-1" />
65
+ <p>{{ $t('Invalid source image') }}</p>
66
+ </div>
67
+ </template>
72
68
  </template>
73
- </Tooltip>
74
- </div>
75
- <div v-if="isInColumnEnum(n)" class="flex flex-col items-start justify-end min-h-[90px]">
76
- <Select
77
- class="min-w-[150px]"
78
- :options="convertColumnEnumToSelectOptions(props.meta.columnEnums, n)"
79
- v-model="item.data[n]"
80
- :teleportToTop="true"
81
- :teleportToBody="false"
82
- >
83
- </Select>
84
- <Tooltip>
85
- <div class="mt-2 flex items-center justify-start gap-1 hover:text-blue-500" :class="{ 'opacity-0': !isHovered(item.id, n) }">
86
- <p class="text-sm ">{{ $t('old value') }}</p>
69
+
70
+ <!-- EMPTY ARRAY -->
71
+ <template v-else-if="item.images?.length === 0">
72
+ <div class="text-gray-400 dark:text-gray-500 flex flex-col items-center justify-center w-full h-[258px]">
73
+ <svg
74
+ class="w-20 h-20 mb-2 stroke-1 text-gray-300 dark:text-gray-600"
75
+ fill="none"
76
+ stroke="currentColor"
77
+ viewBox="0 0 24 24"
78
+ >
79
+ <path
80
+ stroke-linecap="round"
81
+ stroke-linejoin="round"
82
+ stroke-width="2"
83
+ d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
84
+ />
85
+ </svg>
86
+ <p class="text-xs font-medium">{{ $t('No images found') }}</p>
87
87
  </div>
88
- <template #tooltip>
89
- {{ item.oldData?.[n] ?? "No old value" }}
90
- </template>
91
- </Tooltip>
92
- </div>
93
- <div v-else-if="typeof item.data?.[n] === 'string' || typeof item.data?.[n] === 'object'" class="flex flex-col items-start justify-end min-h-[90px]">
94
- <Textarea
95
- class="min-w-[150px] h-full"
96
- type="text"
97
- v-model="item.data[n]"
98
- >
99
- </Textarea>
100
- <Tooltip>
101
- <div class="mt-2 flex items-center justify-start gap-1 hover:text-blue-500" :class="{ 'opacity-0': !isHovered(item.id, n) }">
102
- <p class="text-sm ">{{ $t('old value') }}</p>
88
+ </template>
89
+
90
+ <!-- NO DATA -->
91
+ <template v-else>
92
+ <div class="text-gray-400 dark:text-gray-500 flex flex-col items-center justify-center h-full">
93
+ <svg
94
+ class="w-20 h-20 mb-2 stroke-1 text-gray-300 dark:text-gray-600"
95
+ fill="none"
96
+ stroke="currentColor"
97
+ viewBox="0 0 24 24"
98
+ >
99
+ <path
100
+ stroke-linecap="round"
101
+ stroke-linejoin="round"
102
+ stroke-width="2"
103
+ d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
104
+ />
105
+ </svg>
106
+ <p class="text-xs font-medium">{{ $t('No image found') }}</p>
103
107
  </div>
104
- <template #tooltip>
105
- <p class="max-w-[200px]">{{ item.oldData?.[n] ?? "No old value" }}</p>
106
108
  </template>
107
- </Tooltip>
108
109
  </div>
109
- <div v-else-if="typeof item.data?.[n] === 'boolean'" class="flex flex-col items-center justify-end min-h-[90px]">
110
- <Toggle
111
- class="p-2"
112
- v-model="item.data[n]"
113
- >
114
- </Toggle>
115
- <Tooltip>
116
- <div class="mt-2 flex items-center justify-start gap-1 hover:text-blue-500" :class="{ 'opacity-0': !isHovered(item.id, n) }">
117
- <p class="text-sm ">{{ $t('old value') }}</p>
110
+
111
+ <div class="fields-section flex flex-col gap-3">
112
+ <div
113
+ v-for="n in customFieldNames"
114
+ :key="n"
115
+ class="flex flex-col relative"
116
+ @mouseenter="(() => { setHover(item.id, n, true) })"
117
+ @mouseleave="(() => { setHover(item.id, n, false) })"
118
+ >
119
+ <div class="flex justify-between items-center mb-1">
120
+ <label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
121
+ {{ n.replace(/([A-Z])/g, ' $1').trim() }}
122
+ </label>
123
+
124
+ <div class="flex gap-1 items-center" v-if="!isInColumnImage(n)">
125
+ <Tooltip v-if="checkForError(item, n)">
126
+ <IconExclamationCircleSolid class="w-4 h-4 text-red-500" />
127
+ <template #tooltip>{{ checkForError(item, n) }}</template>
128
+ </Tooltip>
129
+ </div>
118
130
  </div>
119
- <template #tooltip>
120
- {{ item.oldData?.[n] ?? "No old value" }}
121
- </template>
122
- </Tooltip>
123
- </div>
124
- <div v-else class="flex flex-col items-start justify-end min-h-[90px]">
125
- <Input
126
- type="number"
127
- v-model="item.data[n]"
128
- class="w-full min-w-[80px]"
129
- :fullWidth="true"
130
- />
131
- <Tooltip>
132
- <div class="mt-2 flex items-center justify-start gap-1 hover:text-blue-500" :class="{ 'opacity-0': !isHovered(item.id, n) }">
133
- <p class="text-sm ">{{ $t('old value') }}</p>
131
+
132
+ <div v-if="isInColumnImage(n)">
133
+ <div v-if="item.aiStatus?.generatedImages" class="flex flex-col gap-2">
134
+ <div v-if="isValidUrl(item.data?.[n])" class="flex items-center gap-3 relative w-full">
135
+ <Skeleton v-if="imageLoadErrors[item.id]?.[n]" type="image" class="w-full h-[280px]" />
136
+ <img
137
+ v-else
138
+ :key="item.data?.[n]"
139
+ :src="item.data?.[n]"
140
+ class="w-full max-h-[280px] object-cover border cursor-pointer hover:border-blue-500 rounded-default"
141
+ @error="() => { imageLoadErrors[item.id] ??= {}; imageLoadErrors[item.id][n] = true }"
142
+ @click="() => zoomImage(item.data?.[n])"
143
+ style="image-rendering: auto;"
144
+ />
145
+ <p
146
+ v-if="isImageHasPreviewUrl[n]"
147
+ class="text-xs text-blue-500 hover:underline cursor-pointer transition-opacity"
148
+ :class="{ 'opacity-0': !isHovered(item.id, n) }"
149
+ @click="() => { item.openImageCompare[n] = true }"
150
+ >
151
+ {{ $t('old image') }}
152
+ </p>
153
+ </div>
154
+ <div v-else>
155
+ <div class="text-gray-400 dark:text-gray-500 flex flex-col items-center justify-center w-full h-[258px] bg-gray-50 dark:bg-gray-700 border border-dashed border-gray-200 dark:border-gray-600">
156
+ <svg
157
+ class="w-20 h-20 mb-2 stroke-1 text-gray-300 dark:text-gray-600"
158
+ fill="none"
159
+ stroke="currentColor"
160
+ viewBox="0 0 24 24"
161
+ >
162
+ <path
163
+ stroke-linecap="round"
164
+ stroke-linejoin="round"
165
+ stroke-width="2"
166
+ d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
167
+ />
168
+ </svg>
169
+ <p class="text-xs font-medium">{{ $t('No images found') }}</p>
170
+ </div>
171
+ </div>
172
+
173
+ <ImageCompare
174
+ v-if="item.openImageCompare?.[n]"
175
+ :meta="props.meta"
176
+ :columnName="n"
177
+ :oldImage="item.oldData?.[n]"
178
+ :newImage="item.data?.[n]"
179
+ @close="item.openImageCompare[n] = false"
180
+ />
181
+ </div>
182
+ <div v-else>
183
+ <Skeleton type="image" class="w-full h-[258px] rounded-default" />
184
+ </div>
134
185
  </div>
135
- <template #tooltip>
136
- {{ item.oldData?.[n] ?? "No old value" }}
137
- </template>
138
- </Tooltip>
139
- </div>
140
- </div>
141
186
 
142
- <div v-if="item.aiStatus.generatedImages" @mouseenter="(() => { setHover(item.id, n, true) })" @mouseleave="(() => { setHover(item.id, n, false) })">
143
- <div v-if="isInColumnImage(n)">
144
- <div class="mt-2 mb-2 flex items-center justify-start gap-2">
145
- <div v-if="isValidUrl(item.data?.[n])" class="flex flex-col items-center relative">
146
- <img
147
- :src="item.data?.[n]"
148
- class="w-20 h-20 object-cover rounded cursor-pointer border hover:border-blue-500 transition"
149
- @click="() => {item.openGenerationCarousel[n] = true}"
150
- />
151
- <p
152
- v-if="isImageHasPreviewUrl[n]"
153
- class="absolute mt-20 text-sm hover:text-blue-500 hover:underline hover:cursor-pointer flex items-center gap-1"
154
- :class="{ 'opacity-0': !isHovered(item.id, n) }"
155
- @click="() => {item.openImageCompare[n] = true}"
156
- >
157
- {{ $t('old image') }}
158
- </p>
159
- </div>
160
- <div v-else class="flex items-center justify-center text-center w-20 h-20">
161
- <Tooltip v-if="item.imageGenerationErrorMessage === 'No source images found'">
162
- <p
163
- >
164
- {{ $t("Can't generate image.") }}
165
- </p>
166
- <template #tooltip>
167
- {{ item.imageGenerationErrorMessage }}
168
- </template>
169
- </Tooltip>
170
- <Tooltip v-else>
171
- <div>
172
- <IconRefreshOutline
173
- @click="() => {regenerateImages(item.id)}"
174
- class="w-20 h-20 hover:text-blue-500 cursor-pointer transition hover:scale-105"
187
+ <div v-else-if="(isAnalyzing(item, n) && !item.regeneratingFieldsStatus?.[n])">
188
+ <div v-if="isInColumnEnum(n)">
189
+ <Select
190
+ v-if="cardValueMode?.[String(item.id)] !== 'old'"
191
+ class="w-full"
192
+ :options="convertColumnEnumToSelectOptions(props.meta.columnEnums, n)"
193
+ v-model="item.data[n]"
194
+ :teleportToTop="true"
195
+ />
196
+
197
+ <input
198
+ v-else
199
+ type="text"
200
+ :value="item.oldData?.[n] || item.oldData?.[n] === 0 ? item.oldData[n] : $t('no old value')"
201
+ disabled
202
+ class="w-full h-10 px-3 border border-gray-200 dark:border-gray-600 rounded-default bg-gray-50 dark:bg-gray-700 text-gray-400 dark:text-gray-500 text-sm focus:outline-none"
203
+ />
204
+ </div>
205
+
206
+ <div v-else-if="typeof item.data?.[n] === 'string' || typeof item.data?.[n] === 'object'">
207
+ <textarea
208
+ :value="cardValueMode?.[String(item.id)] === 'old' ? (item.oldData?.[n] || item.oldData?.[n] === 0 ? item.oldData[n] : $t('no old value')) : item.data[n]"
209
+ @input="(e) => { if (cardValueMode?.[String(item.id)] !== 'old') item.data[n] = (e.target as HTMLInputElement).value }"
210
+ :disabled="cardValueMode?.[String(item.id)] === 'old'"
211
+ class="w-full text-sm p-2 border border-gray-200 dark:border-gray-600 rounded-default bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-200 focus:outline-none focus:border-blue-500 dark:focus:border-blue-400 disabled:bg-gray-50 dark:disabled:bg-gray-700 disabled:text-gray-400 dark:disabled:text-gray-500"
212
+ :class="props.meta.isAttachFiles ? 'min-h-[42px]' : 'min-h-[120px]'"
213
+ />
214
+ </div>
215
+
216
+ <div v-else-if="typeof item.data?.[n] === 'boolean'" class="flex items-center h-10">
217
+ <Toggle v-model="item.data[n]" />
218
+ </div>
219
+
220
+ <div v-else>
221
+ <input
222
+ :type="cardValueMode?.[String(item.id)] === 'old' && !item.oldData?.[n] && item.oldData?.[n] !== 0 ? 'text' : 'number'"
223
+ :value="cardValueMode?.[String(item.id)] === 'old' ? (item.oldData?.[n] || item.oldData?.[n] === 0 ? item.oldData[n] : $t('no old value')) : item.data[n]"
224
+ @input="(e) => { if (cardValueMode?.[String(item.id)] !== 'old') item.data[n] = Number((e.target as HTMLInputElement).value) }"
225
+ :disabled="cardValueMode?.[String(item.id)] === 'old'"
226
+ class="w-full h-10 px-3 border border-gray-200 dark:border-gray-600 rounded-default bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-200 focus:outline-none focus:border-blue-500 dark:focus:border-blue-400 disabled:bg-gray-50 dark:disabled:bg-gray-700 disabled:text-gray-400 dark:disabled:text-gray-500 text-sm"
175
227
  />
176
228
  </div>
177
- <template #tooltip>
178
- {{ item.imageGenerationErrorMessage + '. Click to retry.' }}
179
- </template>
180
- </Tooltip>
229
+ </div>
230
+
231
+ <div v-else>
232
+ <Skeleton type="input" class="w-full h-10 rounded-default" />
233
+ </div>
181
234
  </div>
182
235
  </div>
183
- <div>
184
- <GenerationCarousel
185
- v-if="item.openGenerationCarousel[n]"
186
- :images="item.carouselSaveImages[n]"
187
- :recordId="item.id"
188
- :meta="props.meta"
189
- :fieldName="n"
190
- :carouselImageIndex="item.carouselImageIndex[n]"
191
- :regenerateImagesRefreshRate="regenerateImagesRefreshRate"
192
- :sourceImage="item.images && item.images.length ? item.images : null"
193
- :imageGenerationPrompts="imageGenerationPrompts[n]"
194
- @error="handleError"
195
- @close="item.openGenerationCarousel[n] = false"
196
- @selectImage="updateSelectedImage"
197
- @updateCarouselIndex="updateActiveIndex"
198
- />
199
- <ImageCompare
200
- v-if="item.openImageCompare[n]"
201
- :meta="props.meta"
202
- :columnName="n"
203
- :oldImage="item.oldData?.[n]"
204
- :newImage="item.data?.[n]"
205
- @close="item.openImageCompare[n] = false"
206
- />
207
- </div>
236
+
208
237
  </div>
209
238
  </div>
210
239
 
211
- <div v-if="!item.aiStatus.generatedImages && isInColumnImage(n)">
212
- <Skeleton type="image" class="w-20 h-20" />
240
+ <div class="record-actions flex justify-end shrink-0">
241
+ <button
242
+ @click="() => canRegenerate(item) && regenerateRecord(item.id)"
243
+ :disabled="!canRegenerate(item)"
244
+ class="px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-default hover:bg-gray-50 dark:hover:bg-gray-600 flex items-center gap-1.5 transition disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white dark:disabled:hover:bg-gray-700"
245
+ >
246
+ <IconRefreshOutline class="w-3.5 h-3.5" /> {{ $t('Regenerate') }}
247
+ </button>
213
248
  </div>
249
+ </div>
250
+ </div>
251
+
252
+
214
253
 
215
- <div v-if="(!isAnalyzing(item, n) || item.regeneratingFieldsStatus?.[n]) && !isInColumnImage(n)">
216
- <Skeleton class="w-full h-6" />
254
+ <Teleport to="body">
255
+ <transition name="fade">
256
+ <div v-if="zoomedImage" class="zoom-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" @click="closeZoom">
257
+ <transition name="zoom">
258
+ <img v-if="zoomedImage" :src="zoomedImage" class="max-w-[90vw] max-h-[90vh] rounded-default object-contain shadow-2xl cursor-zoom-out" @click.stop="closeZoom" />
259
+ </transition>
217
260
  </div>
218
- </template>
219
- </Table>
261
+ </transition>
262
+ </Teleport>
263
+
264
+ <nav v-show="totalItems > 0" class="pagination-nav bg-lightTableBackground dark:bg-darkTableBackground py-3 flex flex-col gap-2 items-center sm:flex-row justify-center sm:justify-between px-4 mt-4 rounded-default">
265
+ <span class="text-sm font-normal text-center text-lightTablePaginationText dark:text-darkTablePaginationText sm:mb-4 md:mb-0 block w-full md:inline md:w-auto">
266
+ {{ $t('Showing') }}
267
+ <span class="font-semibold text-lightTablePaginationNumeration dark:text-darkTablePaginationNumeration">{{ fromIndex }}</span>
268
+ {{ $t('to') }}
269
+ <span class="font-semibold text-lightTablePaginationNumeration dark:text-darkTablePaginationNumeration">{{ toIndex }}</span>
270
+ {{ $t('of') }}
271
+ <span class="font-semibold text-lightTablePaginationNumeration dark:text-darkTablePaginationNumeration">{{ totalItems }}</span>
272
+ </span>
273
+ <div class="af-pagination-container flex flex-row items-center gap-3">
274
+ <div class="inline-flex">
275
+ <button
276
+ class="flex items-center py-1 px-3 gap-1 text-sm font-medium text-lightActivePaginationButtonText bg-lightActivePaginationButtonBackground border-r-0 rounded-s hover:opacity-90 dark:bg-darkActivePaginationButtonBackground dark:text-darkActivePaginationButtonText disabled:opacity-50"
277
+ :disabled="currentPage <= 1"
278
+ @click="previousPage"
279
+ >
280
+ <svg class="w-3.5 h-3.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 10">
281
+ <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5H1m0 0 4 4M1 5l4-4"/>
282
+ </svg>
283
+ </button>
284
+ <button
285
+ class="flex items-center py-1 px-3 text-sm font-medium text-lightUnactivePaginationButtonText focus:outline-none bg-lightUnactivePaginationButtonBackground border-r-0 border border-lightUnactivePaginationButtonBorder hover:bg-lightUnactivePaginationButtonHoverBackground hover:text-lightUnactivePaginationButtonHoverText dark:bg-darkUnactivePaginationButtonBackground dark:text-darkUnactivePaginationButtonText dark:border-darkUnactivePaginationButtonBorder dark:hover:text-darkUnactivePaginationButtonHoverText dark:hover:bg-darkUnactivePaginationButtonHoverBackground disabled:opacity-50"
286
+ :disabled="currentPage <= 1"
287
+ @click="goToPage(1)"
288
+ >1</button>
289
+ <input
290
+ type="text"
291
+ v-model="pageInput"
292
+ :style="{ width: `${Math.max(1, pageInput.length + 4)}ch` }"
293
+ class="min-w-10 outline-none inline-block w-auto py-1.5 px-3 text-sm text-center text-lightTablePaginationInputText border border-lightTablePaginationInputBorder bg-lightTablePaginationInputBackground dark:border-darkTablePaginationInputBorder dark:text-darkTablePaginationInputText dark:bg-darkTablePaginationInputBackground z-10"
294
+ @keydown="onPageKeydown"
295
+ @blur="validatePageInput"
296
+ />
297
+ <button
298
+ class="flex items-center py-1 px-3 text-sm font-medium text-lightUnactivePaginationButtonText focus:outline-none bg-lightUnactivePaginationButtonBackground border-l-0 border border-lightUnactivePaginationButtonBorder hover:bg-lightUnactivePaginationButtonHoverBackground hover:text-lightUnactivePaginationButtonHoverText dark:bg-darkUnactivePaginationButtonBackground dark:text-darkUnactivePaginationButtonText dark:border-darkUnactivePaginationButtonBorder dark:hover:text-darkUnactivePaginationButtonHoverText dark:hover:bg-darkUnactivePaginationButtonHoverBackground disabled:opacity-50"
299
+ :disabled="currentPage >= totalPages"
300
+ @click="goToPage(totalPages)"
301
+ >{{ totalPages }}</button>
302
+ <button
303
+ class="flex items-center py-1 px-3 gap-1 text-sm font-medium text-lightActivePaginationButtonText focus:outline-none bg-lightActivePaginationButtonBackground border-l-0 rounded-e hover:opacity-90 dark:bg-darkActivePaginationButtonBackground dark:text-darkActivePaginationButtonText disabled:opacity-50"
304
+ :disabled="currentPage >= totalPages"
305
+ @click="nextPage"
306
+ >
307
+ <svg class="w-3.5 h-3.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 10">
308
+ <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 5h12m0 0L9 1m4 4L9 9"/>
309
+ </svg>
310
+ </button>
311
+ </div>
312
+ </div>
313
+ </nav>
314
+ </div>
220
315
  </template>
221
316
 
222
317
  <script lang="ts" setup>
223
318
  import { ref, watch, computed, reactive } from 'vue'
224
319
  import { Select, Input, Textarea, Table, Checkbox, Skeleton, Toggle, Tooltip } from '@/afcl'
225
- import GenerationCarousel from './ImageGenerationCarousel.vue'
226
320
  import ImageCompare from './ImageCompare.vue';
227
321
  import { IconRefreshOutline, IconExclamationCircleSolid } from '@iconify-prerendered/vue-flowbite';
228
322
 
@@ -239,14 +333,32 @@ const props = defineProps<{
239
333
  outputImageFields: string[],
240
334
  outputFieldsForAnalizeFromImages: string[],
241
335
  outputPlainFields: string[],
336
+ overwriteExistingValues: boolean,
242
337
  }>();
243
- const emit = defineEmits(['error', 'regenerateImages', 'regenerateCell']);
338
+ const emit = defineEmits(['error', 'regenerateImages', 'regenerateCell', 'regenerateRecord']);
339
+
340
+ function regenerateRecord(recordId: any) {
341
+ emit('regenerateRecord', { recordId });
342
+ }
343
+
344
+ function canRegenerate(item: any): boolean {
345
+ if (props.overwriteExistingValues) return true;
346
+ for (const n of props.customFieldNames) {
347
+ const val = item.data?.[n];
348
+ if (val === null || val === undefined || val === '') return true;
349
+ }
350
+ return false;
351
+ }
244
352
 
245
- const pageSize = 6;
246
- const pagination = reactive({ offset: 0, limit: pageSize });
353
+ const DEFAULT_PAGE_SIZE = 6;
354
+ const initialPageSize = props.meta?.pageSize ?? DEFAULT_PAGE_SIZE;
355
+ console.log(props.meta?.pageSize);
356
+
357
+ const pagination = reactive({ offset: 0, limit: initialPageSize });
247
358
  const zoomedImage = ref(null);
248
359
  const hovers = ref<Record<string, Record<string, boolean>>>({});
249
360
  const tableRef = ref(null);
361
+ const cardValueMode = ref<Record<string, 'old' | 'new'>>({});
250
362
 
251
363
  defineExpose({
252
364
  refresh() {
@@ -258,6 +370,13 @@ defineExpose({
258
370
 
259
371
  const paginatedRecords = computed(() => props.records.slice(pagination.offset, pagination.offset + pagination.limit));
260
372
 
373
+ const imageLoadErrors = ref<Record<string, Record<string, boolean>>>({})
374
+
375
+ const totalItems = computed(() => props.records.length);
376
+ const currentPage = computed(() => Math.floor(pagination.offset / pagination.limit) + 1);
377
+ const totalPages = computed(() => Math.max(1, Math.ceil(totalItems.value / pagination.limit)));
378
+ const fromIndex = computed(() => totalItems.value === 0 ? 0 : Math.min(pagination.offset + 1, totalItems.value));
379
+ const toIndex = computed(() => Math.min(pagination.offset + pagination.limit, totalItems.value));
261
380
  const tableDataProvider = async ({ offset, limit }) => {
262
381
  pagination.offset = offset;
263
382
  pagination.limit = limit;
@@ -267,8 +386,29 @@ const tableDataProvider = async ({ offset, limit }) => {
267
386
  };
268
387
  };
269
388
 
389
+ watch(() => props.records.length, (newLength) => {
390
+ const maxOffset = Math.max(0, (Math.ceil(newLength / pagination.limit) - 1) * pagination.limit);
391
+ if (pagination.offset > maxOffset) {
392
+ pagination.offset = maxOffset;
393
+ }
394
+ });
395
+
396
+ watch(() => props.meta?.pageSize, (newSize) => {
397
+ const size = Number(newSize) || DEFAULT_PAGE_SIZE;
398
+ pagination.limit = size;
399
+ const maxOffset = Math.max(0, (Math.ceil(props.records.length / pagination.limit) - 1) * pagination.limit);
400
+ if (pagination.offset > maxOffset) {
401
+ pagination.offset = maxOffset;
402
+ }
403
+ }, { immediate: true });
404
+
270
405
  watch(() => props.records, (newVal) => {
271
406
  hovers.value = Object.fromEntries((newVal || []).map(record => [String(record.id), {}]));
407
+ const newCardValueMode = {};
408
+ (newVal || []).forEach(record => {
409
+ newCardValueMode[String(record.id)] = 'new';
410
+ });
411
+ cardValueMode.value = newCardValueMode;
272
412
  }, { immediate: true, deep: true });
273
413
 
274
414
  function zoomImage(img) {
@@ -291,6 +431,17 @@ function isInColumnImage(key: string): boolean {
291
431
  return props.meta.outputImageFields?.includes(key) || false;
292
432
  }
293
433
 
434
+ const hasNonImageOutputFields = computed(() => {
435
+ if (!props.customFieldNames) return false;
436
+
437
+ for (const fieldName of props.customFieldNames) {
438
+ const isImage = isInColumnImage(fieldName);
439
+ if (!isImage) return true;
440
+ }
441
+
442
+ return false;
443
+ });
444
+
294
445
  function convertColumnEnumToSelectOptions(columnEnumArray: any[], key: string) {
295
446
  const col = columnEnumArray.find(c => c.name === key);
296
447
  if (!col) return [];
@@ -300,31 +451,6 @@ function convertColumnEnumToSelectOptions(columnEnumArray: any[], key: string) {
300
451
  }));
301
452
  }
302
453
 
303
- function updateSelectedImage(image: string, id: any, fieldName: string) {
304
- const record = props.records.find(rec => String(rec.id) === String(id));
305
- if (record) {
306
- record.data[fieldName] = image;
307
- }
308
- }
309
-
310
- function handleError({ isError, errorMessage }) {
311
- emit('error', {
312
- isError,
313
- errorMessage
314
- });
315
- }
316
-
317
- function regenerateImages(recordId: any) {
318
- emit('regenerateImages', { recordId });
319
- }
320
-
321
- function updateActiveIndex(newIndex: number, id: any, fieldName: string) {
322
- const record = props.records.find(rec => String(rec.id) === String(id));
323
- if (record) {
324
- record.carouselImageIndex[fieldName] = newIndex;
325
- }
326
- }
327
-
328
454
  function isValidUrl(str: string): boolean {
329
455
  try {
330
456
  new URL(str);
@@ -399,8 +525,37 @@ function isHovered(recordId: string | number, field: string) {
399
525
  return Boolean(hovers.value?.[String(recordId)]?.[field]);
400
526
  }
401
527
 
528
+ function toggleCardValueMode(recordId: string | number) {
529
+ const key = String(recordId);
530
+ const currentMode = cardValueMode.value[key] || 'new';
531
+ cardValueMode.value[key] = currentMode === 'new' ? 'old' : 'new';
532
+ }
533
+
534
+ const pageInput = ref(String(currentPage.value));
535
+ watch(currentPage, (val) => { pageInput.value = String(val); });
536
+
537
+ function goToPage(page: number) {
538
+ const clampedPage = Math.min(Math.max(page, 1), totalPages.value);
539
+ pagination.offset = (clampedPage - 1) * pagination.limit;
540
+ }
541
+
542
+ function previousPage() {
543
+ goToPage(currentPage.value - 1);
544
+ }
402
545
 
546
+ function nextPage() {
547
+ goToPage(currentPage.value + 1);
548
+ }
549
+
550
+ function onPageKeydown(e: KeyboardEvent) {
551
+ if (e.key === 'Enter') validatePageInput();
552
+ }
403
553
 
554
+ function validatePageInput() {
555
+ const parsed = parseInt(pageInput.value);
556
+ if (!isNaN(parsed)) goToPage(parsed);
557
+ pageInput.value = String(currentPage.value);
558
+ }
404
559
  </script>
405
560
 
406
561
  <style scoped>
@@ -418,4 +573,9 @@ function isHovered(recordId: string | number, field: string) {
418
573
  transform: scale(0.95);
419
574
  opacity: 0;
420
575
  }
576
+
577
+ .overflow-auto {
578
+ scrollbar-width: thin;
579
+ scrollbar-color: #d1d5db transparent;
580
+ }
421
581
  </style>