@adminforth/bulk-ai-flow 2.0.3 → 2.1.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.
@@ -0,0 +1,124 @@
1
+ export type RecordStatus = 'pending' | 'processing' | 'completed' | 'failed';
2
+ export type GenerationAction = 'analyze' | 'analyze_no_images' | 'generate_images';
3
+ export type PromptCategory = 'imageFieldsPrompts' | 'plainFieldsPrompts' | 'generateImages';
4
+ export type FieldKind = 'fromImage' | 'text' | 'image';
5
+ export type FieldChange = 'new' | 'changed' | 'same';
6
+
7
+ export type RecordState = {
8
+ id: string | number;
9
+ status: RecordStatus;
10
+ isChecked: boolean;
11
+ label: string;
12
+ data: Record<string, any>;
13
+ oldData: Record<string, any>;
14
+ // links to the stored images of the image fields, null for an empty field
15
+ oldImagePreviews: Record<string, string | null>;
16
+ // source images returned by attachFiles
17
+ images: string[];
18
+ aiStatus: {
19
+ generatedImages: boolean;
20
+ analyzedImages: boolean;
21
+ analyzedNoImages: boolean;
22
+ };
23
+ imageGenerationErrorMessage: string;
24
+ imageGenerationFailed: boolean;
25
+ imageToTextErrorMessages: Record<string, string>;
26
+ textToTextErrorMessages: Record<string, string>;
27
+ regeneratingFieldsStatus: Record<string, boolean>;
28
+ // image fields which kept their stored image, because values are not overwritten
29
+ listOfImageNotGenerated: Record<string, { originalImage: string }>;
30
+ };
31
+
32
+ export const PROMPT_CATEGORY: Record<GenerationAction, PromptCategory> = {
33
+ analyze: 'imageFieldsPrompts',
34
+ analyze_no_images: 'plainFieldsPrompts',
35
+ generate_images: 'generateImages',
36
+ };
37
+
38
+ export const CATEGORY_KIND: Record<PromptCategory, FieldKind> = {
39
+ imageFieldsPrompts: 'fromImage',
40
+ plainFieldsPrompts: 'text',
41
+ generateImages: 'image',
42
+ };
43
+
44
+ export function fieldKind(meta: any, field: string): FieldKind {
45
+ if (meta.outputImageFields.includes(field)) {
46
+ return 'image';
47
+ }
48
+ return meta.outputFieldsForAnalizeFromImages.includes(field) ? 'fromImage' : 'text';
49
+ }
50
+
51
+ // keys of the plugin options may differ in case from the column names
52
+ export function fieldLabel(meta: any, field: string): string {
53
+ const labels: Record<string, string> = meta.columnLabels;
54
+ const key = Object.keys(labels).find((name) => name.toLowerCase() === field.toLowerCase());
55
+ return key ? labels[key] : field;
56
+ }
57
+
58
+ export function isFieldGenerating(record: RecordState, meta: any, field: string): boolean {
59
+ const kind = fieldKind(meta, field);
60
+ if (kind === 'image') {
61
+ return !record.aiStatus.generatedImages;
62
+ }
63
+ const isDone = kind === 'fromImage' ? record.aiStatus.analyzedImages : record.aiStatus.analyzedNoImages;
64
+ return !isDone || record.regeneratingFieldsStatus[field] === true;
65
+ }
66
+
67
+ // images and single fields of a record regenerate in parallel, its status settles once all of them finished
68
+ export function isRegenerating(record: RecordState): boolean {
69
+ return !record.aiStatus.generatedImages || Object.values(record.regeneratingFieldsStatus).some(Boolean);
70
+ }
71
+
72
+ export function isRecordBusy(record: RecordState): boolean {
73
+ return record.status === 'pending' || record.status === 'processing' || isRegenerating(record);
74
+ }
75
+
76
+ export function fieldError(record: RecordState, meta: any, field: string): string {
77
+ const kind = fieldKind(meta, field);
78
+ if (kind === 'image') {
79
+ return record.imageGenerationFailed ? record.imageGenerationErrorMessage : '';
80
+ }
81
+ return (kind === 'fromImage' ? record.imageToTextErrorMessages : record.textToTextErrorMessages)[field] ?? '';
82
+ }
83
+
84
+ export function recordError(record: RecordState): string {
85
+ return [
86
+ record.imageGenerationFailed ? record.imageGenerationErrorMessage : '',
87
+ ...Object.values(record.imageToTextErrorMessages),
88
+ ...Object.values(record.textToTextErrorMessages),
89
+ ].find(Boolean) ?? '';
90
+ }
91
+
92
+ // the image a field will have after saving: the generated one, or the stored one while nothing new was made
93
+ export function generatedImageUrl(record: RecordState, meta: any, field: string): string | null {
94
+ return fieldChange(record, meta, field) === 'same' ? record.oldImagePreviews[field] ?? null : record.data[field];
95
+ }
96
+
97
+ function isEmpty(value: any): boolean {
98
+ return value === null || value === undefined || value === '';
99
+ }
100
+
101
+ function isSameValue(a: any, b: any): boolean {
102
+ if (isEmpty(a) || isEmpty(b)) {
103
+ return isEmpty(a) && isEmpty(b);
104
+ }
105
+ return typeof a === 'object' || typeof b === 'object'
106
+ ? JSON.stringify(a) === JSON.stringify(b)
107
+ : String(a) === String(b);
108
+ }
109
+
110
+ export function fieldChange(record: RecordState, meta: any, field: string): FieldChange {
111
+ const isKeptImage = fieldKind(meta, field) === 'image' && field in record.listOfImageNotGenerated;
112
+ if (isKeptImage || isSameValue(record.data[field], record.oldData[field])) {
113
+ return 'same';
114
+ }
115
+ return isEmpty(record.oldData[field]) ? 'new' : 'changed';
116
+ }
117
+
118
+ export function changedFieldsCount(record: RecordState, meta: any): number {
119
+ return meta.outputFields.filter((field: string) => fieldChange(record, meta, field) !== 'same').length;
120
+ }
121
+
122
+ export function isRecordChanged(record: RecordState, meta: any): boolean {
123
+ return record.status !== 'pending' && changedFieldsCount(record, meta) > 0;
124
+ }
@@ -0,0 +1,148 @@
1
+ <template>
2
+ <div class="af-confirmation-panel flex flex-col gap-4">
3
+ <section class="af-configure-prompts-card flex flex-col gap-1.5">
4
+ <h4 class="text-sm font-medium text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ t('Fields to generate') }}</h4>
5
+ <ul class="af-settings-panel overflow-hidden rounded-lg border border-lightFormBorder divide-y divide-lightFormBorder dark:border-darkFormBorder dark:divide-darkFormBorder">
6
+ <li
7
+ v-for="row in rows"
8
+ :key="row.field"
9
+ :class="openFields.has(row.field) ? 'bg-lightFormHeading dark:bg-darkFormHeading' : 'bg-lightForm dark:bg-darkForm'"
10
+ >
11
+ <div class="flex items-center gap-3 px-3.5 py-2.5">
12
+ <div class="flex-1 min-w-0">
13
+ <p class="text-sm font-medium text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ fieldLabel(meta, row.field) }}</p>
14
+ <p v-if="!openFields.has(row.field)" class="truncate text-xs text-lightInputIconColor dark:text-darkInputIconColor">
15
+ {{ prompts[row.category][row.field] }}
16
+ </p>
17
+ </div>
18
+ <span
19
+ v-if="isUnsaved(row)"
20
+ class="inline-flex h-5 shrink-0 items-center rounded-full border px-2 text-xs font-medium border-lightWarningBorder bg-lightWarningSoft text-lightWarning dark:border-darkWarningBorder dark:bg-darkWarningSoft dark:text-darkWarning"
21
+ >
22
+ {{ t('Unsaved') }}
23
+ </span>
24
+ <span class="inline-flex h-5 shrink-0 items-center rounded-full px-2 text-xs font-medium bg-lightSecondaryDarken text-lightInputIconColor dark:bg-darkSecondaryLighten dark:text-darkInputIconColor">
25
+ {{ kindLabels[CATEGORY_KIND[row.category]] }}
26
+ </span>
27
+ <Button
28
+ type="button"
29
+ variant="ghost"
30
+ size="sm"
31
+ iconOnly
32
+ :aria-label="openFields.has(row.field) ? t('Hide prompt') : t('Edit prompt')"
33
+ :aria-expanded="openFields.has(row.field)"
34
+ :aria-controls="`${idPrefix}-${row.field}`"
35
+ @click="toggle(row.field)"
36
+ >
37
+ <IconChevronUp v-if="openFields.has(row.field)" aria-hidden="true" />
38
+ <IconPencil v-else aria-hidden="true" />
39
+ </Button>
40
+ </div>
41
+ <div v-if="openFields.has(row.field)" :id="`${idPrefix}-${row.field}`" class="flex flex-col items-start gap-2 px-3.5 pb-3">
42
+ <Textarea
43
+ v-model="prompts[row.category][row.field]"
44
+ rows="4"
45
+ class="resize-y"
46
+ :placeholder="t('Enter prompt instructions...')"
47
+ />
48
+ <div class="flex w-full flex-wrap items-center justify-between gap-2">
49
+ <Button
50
+ type="button"
51
+ variant="ghost"
52
+ size="sm"
53
+ :disabled="prompts[row.category][row.field] === defaultPrompts[row.category][row.field]"
54
+ @click="prompts[row.category][row.field] = defaultPrompts[row.category][row.field]"
55
+ >
56
+ <IconRotateCcw class="w-3.5 h-3.5" aria-hidden="true" />
57
+ {{ t('Reset to default') }}
58
+ </Button>
59
+ <Button
60
+ type="button"
61
+ variant="secondary"
62
+ size="sm"
63
+ :disabled="!isUnsaved(row)"
64
+ @click="emit('save', row.category, row.field)"
65
+ >
66
+ <IconSave class="w-3.5 h-3.5" aria-hidden="true" />
67
+ {{ t('Save prompt') }}
68
+ </Button>
69
+ </div>
70
+ </div>
71
+ </li>
72
+ </ul>
73
+ <p class="text-xs text-lightInputIconColor dark:text-darkInputIconColor">
74
+ {{ t('Saved prompts are kept in this browser, unsaved changes apply to this run only.') }}
75
+ </p>
76
+ </section>
77
+
78
+ <div class="af-overwrite-settings-card rounded-lg border border-lightFormBorder bg-lightForm px-3.5 py-3 dark:border-darkFormBorder dark:bg-darkForm">
79
+ <Toggle v-model="overwrite" class="w-full flex-row-reverse justify-between gap-3 [&>label]:ms-0">
80
+ <span class="flex flex-col">
81
+ <span class="font-medium text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ t('Overwrite existing values') }}</span>
82
+ <span class="text-xs text-lightInputIconColor dark:text-darkInputIconColor">
83
+ {{ overwrite ? t('AI-generated data will replace existing values.') : t('AI fills only empty fields.') }}
84
+ </span>
85
+ </span>
86
+ </Toggle>
87
+ </div>
88
+
89
+ <div
90
+ v-if="overwrite"
91
+ class="flex items-start gap-2.5 rounded-default border px-3 py-2.5 text-sm border-lightWarningBorder bg-lightWarningSoft text-lightWarning dark:border-darkWarningBorder dark:bg-darkWarningSoft dark:text-darkWarning"
92
+ >
93
+ <IconTriangleAlert class="mt-0.5 w-4 h-4 shrink-0" aria-hidden="true" />
94
+ <p>{{ t('Existing values will be replaced when you save.') }}</p>
95
+ </div>
96
+ </div>
97
+ </template>
98
+
99
+ <script setup lang="ts">
100
+ import { computed, reactive, useId } from 'vue';
101
+ import { useI18n } from 'vue-i18n';
102
+ import { Button, Textarea, Toggle } from '@/afcl';
103
+ import { IconChevronUp, IconPencil, IconRotateCcw, IconSave, IconTriangleAlert } from '@iconify-prerendered/vue-lucide';
104
+ import { CATEGORY_KIND, fieldLabel, type FieldKind, type PromptCategory } from './reviewModel';
105
+
106
+ type Prompts = Record<PromptCategory, Record<string, string>>;
107
+ type PromptRow = { category: PromptCategory, field: string };
108
+
109
+ const props = defineProps<{
110
+ meta: any,
111
+ defaultPrompts: Prompts,
112
+ savedPrompts: Prompts,
113
+ }>();
114
+
115
+ const emit = defineEmits<{
116
+ save: [category: PromptCategory, field: string],
117
+ }>();
118
+
119
+ const prompts = defineModel<Prompts>('prompts', { required: true });
120
+ const overwrite = defineModel<boolean>('overwrite', { required: true });
121
+
122
+ const { t } = useI18n();
123
+ const idPrefix = useId();
124
+ const openFields = reactive(new Set<string>());
125
+
126
+ const kindLabels = computed<Record<FieldKind, string>>(() => ({
127
+ fromImage: t('From image'),
128
+ text: t('Text'),
129
+ image: t('Image'),
130
+ }));
131
+
132
+ // in the order of the columns in the review
133
+ const rows = computed<PromptRow[]>(() => (Object.keys(CATEGORY_KIND) as PromptCategory[]).flatMap((category) =>
134
+ Object.keys(prompts.value[category]).map((field) => ({ category, field })),
135
+ ));
136
+
137
+ function isUnsaved(row: PromptRow): boolean {
138
+ return prompts.value[row.category][row.field] !== props.savedPrompts[row.category][row.field];
139
+ }
140
+
141
+ function toggle(field: string) {
142
+ if (openFields.has(field)) {
143
+ openFields.delete(field);
144
+ } else {
145
+ openFields.add(field);
146
+ }
147
+ }
148
+ </script>
@@ -0,0 +1,45 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <div
4
+ v-if="src"
5
+ class="af-image-lightbox fixed inset-0 z-[100] flex items-center justify-center bg-black/90 cursor-zoom-out"
6
+ @click="src = null"
7
+ >
8
+ <img :src="src" alt="" class="max-w-[90vw] max-h-[90vh] rounded-lg object-contain select-none" />
9
+ <button
10
+ type="button"
11
+ class="absolute top-4 right-4 flex items-center justify-center w-9 h-9 rounded-full bg-white/10 text-white/70 hover:bg-white/20 hover:text-white focus:outline-none focus-visible:ring-[3px] focus-visible:ring-white/40 transition-colors"
12
+ :aria-label="t('Close')"
13
+ >
14
+ <IconX class="w-4 h-4" aria-hidden="true" />
15
+ </button>
16
+ </div>
17
+ </Teleport>
18
+ </template>
19
+
20
+ <script setup lang="ts">
21
+ import { onBeforeUnmount, watch } from 'vue';
22
+ import { useI18n } from 'vue-i18n';
23
+ import { IconX } from '@iconify-prerendered/vue-lucide';
24
+
25
+ // a photo is dark in both themes, so the lightbox keeps black and white tints instead of tokens
26
+ const src = defineModel<string | null>({ required: true });
27
+
28
+ const { t } = useI18n();
29
+
30
+ function onKeydown(event: KeyboardEvent) {
31
+ if (event.key === 'Escape') {
32
+ src.value = null;
33
+ }
34
+ }
35
+
36
+ watch(src, (value) => {
37
+ if (value) {
38
+ document.addEventListener('keydown', onKeydown);
39
+ } else {
40
+ document.removeEventListener('keydown', onKeydown);
41
+ }
42
+ });
43
+
44
+ onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown));
45
+ </script>
@@ -0,0 +1,276 @@
1
+ <template>
2
+ <div class="af-review-record @container/detail flex min-h-0 flex-col">
3
+ <div class="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-lightFormBorder px-4 py-3 dark:border-darkFormBorder">
4
+ <div class="min-w-0 flex-1 basis-full @md/detail:basis-0">
5
+ <h4 class="truncate text-sm font-semibold text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ label }}</h4>
6
+ <p class="text-xs text-lightInputIconColor dark:text-darkInputIconColor">
7
+ {{ t('Record {position} of {total}', { position, total }) }}
8
+ <i18n-t keypath="· {up} {down} to move, {space} to include" tag="span" class="hidden @3xl/review:inline">
9
+ <template #up><kbd :class="KBD_CLASSES">↑</kbd></template>
10
+ <template #down><kbd :class="KBD_CLASSES">↓</kbd></template>
11
+ <template #space><kbd :class="KBD_CLASSES">Space</kbd></template>
12
+ </i18n-t>
13
+ </p>
14
+ </div>
15
+ <Checkbox v-model="record.isChecked">{{ t('Save this record') }}</Checkbox>
16
+ <Button type="button" variant="secondary" size="sm" :disabled="!isSettled" @click="emit('regenerateRecord')">
17
+ <IconRefreshCw class="w-3.5 h-3.5" aria-hidden="true" />
18
+ {{ t('Regenerate') }}
19
+ </Button>
20
+ <div class="flex gap-1">
21
+ <Button type="button" variant="secondary" size="sm" iconOnly :disabled="position === 1" :aria-label="t('Previous record')" @click="emit('prev')">
22
+ <IconChevronUp aria-hidden="true" />
23
+ </Button>
24
+ <Button type="button" variant="secondary" size="sm" iconOnly :disabled="position === total" :aria-label="t('Next record')" @click="emit('next')">
25
+ <IconChevronDown aria-hidden="true" />
26
+ </Button>
27
+ </div>
28
+ </div>
29
+
30
+ <div class="min-h-0 flex-1 overflow-y-auto">
31
+ <div v-if="record.status === 'pending'" class="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
32
+ <IconClock class="w-6 h-6 text-lightInputPlaceholderText dark:text-darkInputPlaceholderText" aria-hidden="true" />
33
+ <p class="text-sm text-lightInputIconColor dark:text-darkInputIconColor">{{ t('This record is waiting for its turn.') }}</p>
34
+ </div>
35
+
36
+ <template v-else>
37
+ <div class="hidden gap-3 border-b border-lightFormBorder bg-lightFormHeading px-4 py-2 text-xs font-medium text-lightInputIconColor dark:border-darkFormBorder dark:bg-darkFormHeading dark:text-darkInputIconColor @lg/detail:grid @lg/detail:grid-cols-[minmax(0,1fr)_1rem_minmax(0,1fr)]">
38
+ <span>{{ t('Current') }}</span>
39
+ <span></span>
40
+ <span>{{ t('Generated') }}</span>
41
+ </div>
42
+
43
+ <div v-if="meta.isAttachFiles" class="flex flex-col gap-2 border-b border-lightFormBorder px-4 py-3 dark:border-darkFormBorder">
44
+ <span class="text-sm font-medium text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ t('Source images') }}</span>
45
+ <div v-if="record.images.length" class="flex flex-wrap gap-2">
46
+ <button
47
+ v-for="(image, index) in record.images"
48
+ :key="image"
49
+ type="button"
50
+ :class="IMAGE_BUTTON_CLASSES"
51
+ :aria-label="t('Open source image {number}', { number: index + 1 })"
52
+ @click="emit('zoom', image)"
53
+ >
54
+ <img :src="image" alt="" class="h-16 w-24 object-cover" />
55
+ </button>
56
+ </div>
57
+ <Skeleton v-else-if="record.status === 'processing'" type="image" class="h-16 w-24 rounded-default" />
58
+ <p v-else class="text-sm text-lightInputPlaceholderText dark:text-darkInputPlaceholderText">{{ t('No source images') }}</p>
59
+ </div>
60
+
61
+ <div
62
+ v-for="field in meta.outputFields"
63
+ :key="field"
64
+ class="flex flex-col gap-2 border-b border-lightFormBorder px-4 py-3 last:border-b-0 dark:border-darkFormBorder"
65
+ >
66
+ <div class="flex items-center gap-2">
67
+ <span class="text-sm font-medium text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ fieldLabel(meta, field) }}</span>
68
+ <span v-if="!isFieldGenerating(record, meta, field)" :class="[BADGE_CLASSES, badgeClasses(field)]">{{ badgeLabel(field) }}</span>
69
+ <span class="flex-1"></span>
70
+ <Tooltip>
71
+ <Button
72
+ type="button"
73
+ variant="ghost"
74
+ size="sm"
75
+ iconOnly
76
+ :loader="record.regeneratingFieldsStatus[field] === true"
77
+ :disabled="!isSettled || isFieldGenerating(record, meta, field)"
78
+ :aria-label="t('Regenerate {field}', { field: fieldLabel(meta, field) })"
79
+ @click="regenerate(field)"
80
+ >
81
+ <IconRefreshCw aria-hidden="true" />
82
+ </Button>
83
+ <template #tooltip>{{ t('Regenerate') }}</template>
84
+ </Tooltip>
85
+ </div>
86
+
87
+ <div class="grid grid-cols-1 items-start gap-2 @lg/detail:grid-cols-[minmax(0,1fr)_1rem_minmax(0,1fr)] @lg/detail:gap-3">
88
+ <div class="flex min-w-0 flex-col gap-1">
89
+ <span class="text-xs text-lightInputIconColor dark:text-darkInputIconColor @lg/detail:hidden">{{ t('Current') }}</span>
90
+ <template v-if="fieldKind(meta, field) === 'image'">
91
+ <button
92
+ v-if="record.oldImagePreviews[field]"
93
+ type="button"
94
+ :class="IMAGE_BUTTON_CLASSES"
95
+ :aria-label="t('Open current image')"
96
+ @click="emit('zoom', record.oldImagePreviews[field])"
97
+ >
98
+ <img :src="record.oldImagePreviews[field]" alt="" class="aspect-[16/10] w-full object-cover" />
99
+ </button>
100
+ <div v-else :class="IMAGE_PLACEHOLDER_CLASSES">
101
+ <IconImageOff class="w-5 h-5" aria-hidden="true" />
102
+ {{ t('No image') }}
103
+ </div>
104
+ </template>
105
+ <div
106
+ v-else
107
+ class="min-h-[2.125rem] max-h-40 overflow-y-auto whitespace-pre-wrap break-words rounded-default bg-lightSecondaryDarken px-2.5 py-1.5 text-sm leading-5 text-lightInputIconColor dark:bg-darkSecondaryLighten dark:text-darkInputIconColor"
108
+ >
109
+ <span v-if="isEmpty(record.oldData[field])" class="text-lightInputPlaceholderText dark:text-darkInputPlaceholderText">-</span>
110
+ <template v-else>{{ displayValue(field, record.oldData[field]) }}</template>
111
+ </div>
112
+ </div>
113
+
114
+ <IconArrowRight class="mt-2.5 hidden w-4 h-4 text-lightInputPlaceholderText dark:text-darkInputPlaceholderText @lg/detail:block" aria-hidden="true" />
115
+
116
+ <div class="flex min-w-0 flex-col gap-1">
117
+ <span class="text-xs text-lightInputIconColor dark:text-darkInputIconColor @lg/detail:hidden">{{ t('Generated') }}</span>
118
+ <template v-if="fieldKind(meta, field) === 'image'">
119
+ <Skeleton v-if="isFieldGenerating(record, meta, field)" type="image" class="aspect-[16/10] w-full rounded-lg" />
120
+ <button
121
+ v-else-if="generatedImageUrl(record, meta, field)"
122
+ type="button"
123
+ :class="IMAGE_BUTTON_CLASSES"
124
+ :aria-label="t('Open generated image')"
125
+ @click="emit('zoom', generatedImageUrl(record, meta, field))"
126
+ >
127
+ <img :src="generatedImageUrl(record, meta, field)" alt="" class="aspect-[16/10] w-full object-cover" />
128
+ </button>
129
+ <div v-else :class="IMAGE_PLACEHOLDER_CLASSES">
130
+ <IconImageOff class="w-5 h-5" aria-hidden="true" />
131
+ {{ t('Not generated') }}
132
+ </div>
133
+ </template>
134
+ <Skeleton
135
+ v-else-if="isFieldGenerating(record, meta, field)"
136
+ type="input"
137
+ class="w-full"
138
+ :class="editorKind(field) === 'multiline' ? 'h-24' : 'h-[2.125rem]'"
139
+ />
140
+ <Select
141
+ v-else-if="editorKind(field) === 'enum'"
142
+ v-model="record.data[field]"
143
+ class="w-full"
144
+ :options="enumOptions(field)"
145
+ teleportToTop
146
+ />
147
+ <div v-else-if="editorKind(field) === 'boolean'" class="flex h-[2.125rem] items-center">
148
+ <Toggle v-model="record.data[field]" />
149
+ </div>
150
+ <Input v-else-if="editorKind(field) === 'number'" v-model="record.data[field]" type="number" fullWidth />
151
+ <Input v-else-if="editorKind(field) === 'line'" v-model="record.data[field]" type="text" fullWidth />
152
+ <Textarea
153
+ v-else
154
+ :modelValue="record.data[field] ?? ''"
155
+ rows="4"
156
+ class="resize-y"
157
+ @update:modelValue="(value) => record.data[field] = value"
158
+ />
159
+ </div>
160
+ </div>
161
+
162
+ <p v-if="fieldError(record, meta, field)" class="flex items-start gap-1.5 text-xs text-lightDanger dark:text-darkDanger">
163
+ <IconCircleAlert class="mt-px w-3.5 h-3.5 shrink-0" aria-hidden="true" />
164
+ {{ fieldError(record, meta, field) }}
165
+ </p>
166
+ </div>
167
+ </template>
168
+ </div>
169
+ </div>
170
+ </template>
171
+
172
+ <script setup lang="ts">
173
+ import { computed } from 'vue';
174
+ import { useI18n } from 'vue-i18n';
175
+ import { Button, Checkbox, Input, Select, Skeleton, Textarea, Toggle, Tooltip } from '@/afcl';
176
+ import {
177
+ IconArrowRight, IconChevronDown, IconChevronUp, IconCircleAlert, IconClock, IconImageOff, IconRefreshCw,
178
+ } from '@iconify-prerendered/vue-lucide';
179
+ import {
180
+ fieldChange, fieldError, fieldKind, fieldLabel, generatedImageUrl, isFieldGenerating, isRecordBusy, type RecordState,
181
+ } from './reviewModel';
182
+
183
+ type EditorKind = 'enum' | 'boolean' | 'number' | 'line' | 'multiline';
184
+
185
+ const NUMBER_TYPES = ['integer', 'decimal', 'float'];
186
+ const KBD_CLASSES = 'rounded border border-lightFormBorder px-1 font-mono text-[11px] dark:border-darkFormBorder';
187
+ const BADGE_CLASSES = 'inline-flex h-5 items-center rounded-full border px-2 text-xs font-medium';
188
+ const IMAGE_BUTTON_CLASSES = 'block overflow-hidden rounded-lg border border-lightFormBorder cursor-zoom-in hover:border-lightPrimary focus:outline-none focus-visible:ring-[3px] focus-visible:ring-lightFocusRing dark:border-darkFormBorder dark:hover:border-darkPrimary dark:focus-visible:ring-darkFocusRing transition-colors';
189
+ const IMAGE_PLACEHOLDER_CLASSES = 'flex aspect-[16/10] w-full flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-lightFormBorder bg-lightFormHeading text-xs text-lightInputPlaceholderText dark:border-darkFormBorder dark:bg-darkFormHeading dark:text-darkInputPlaceholderText';
190
+
191
+ const props = defineProps<{
192
+ record: RecordState,
193
+ meta: any,
194
+ label: string,
195
+ position: number,
196
+ total: number,
197
+ }>();
198
+
199
+ const emit = defineEmits<{
200
+ prev: [],
201
+ next: [],
202
+ regenerateRecord: [],
203
+ regenerateImages: [],
204
+ regenerateField: [field: string],
205
+ zoom: [url: string],
206
+ }>();
207
+
208
+ const { t } = useI18n();
209
+
210
+ const isSettled = computed(() => !isRecordBusy(props.record));
211
+
212
+ function columnEnum(field: string) {
213
+ return props.meta.columnEnums.find((column: any) => column.name === field);
214
+ }
215
+
216
+ function editorKind(field: string): EditorKind {
217
+ if (columnEnum(field)) {
218
+ return 'enum';
219
+ }
220
+ const type = props.meta.columnTypes[field];
221
+ if (type === 'boolean') {
222
+ return 'boolean';
223
+ }
224
+ if (NUMBER_TYPES.includes(type)) {
225
+ return 'number';
226
+ }
227
+ return type === 'string' ? 'line' : 'multiline';
228
+ }
229
+
230
+ function enumOptions(field: string) {
231
+ return columnEnum(field).enum.map((item: any) => ({ label: item.label, value: item.value }));
232
+ }
233
+
234
+ function isEmpty(value: any) {
235
+ return value === null || value === undefined || value === '';
236
+ }
237
+
238
+ function displayValue(field: string, value: any): string {
239
+ const option = columnEnum(field)?.enum.find((item: any) => item.value === value);
240
+ if (option) {
241
+ return option.label;
242
+ }
243
+ if (typeof value === 'boolean') {
244
+ return value ? t('Yes') : t('No');
245
+ }
246
+ return typeof value === 'object' ? JSON.stringify(value) : String(value);
247
+ }
248
+
249
+ function badgeLabel(field: string): string {
250
+ if (fieldError(props.record, props.meta, field)) {
251
+ return t('Failed');
252
+ }
253
+ return {
254
+ new: t('New'),
255
+ changed: t('Changed'),
256
+ same: t('Unchanged'),
257
+ }[fieldChange(props.record, props.meta, field)];
258
+ }
259
+
260
+ function badgeClasses(field: string): string {
261
+ if (fieldError(props.record, props.meta, field)) {
262
+ return 'border-lightDangerBorder bg-lightDangerSoft text-lightDanger dark:border-darkDangerBorder dark:bg-darkDangerSoft dark:text-darkDanger';
263
+ }
264
+ return fieldChange(props.record, props.meta, field) === 'same'
265
+ ? 'border-transparent bg-lightSecondaryDarken text-lightInputIconColor dark:bg-darkSecondaryLighten dark:text-darkInputIconColor'
266
+ : 'border-lightPrimary/30 bg-lightPrimaryOpacity text-lightPrimary dark:border-darkPrimary/40 dark:bg-darkPrimaryOpacity dark:text-darkPrimary';
267
+ }
268
+
269
+ function regenerate(field: string) {
270
+ if (fieldKind(props.meta, field) === 'image') {
271
+ emit('regenerateImages');
272
+ } else {
273
+ emit('regenerateField', field);
274
+ }
275
+ }
276
+ </script>