@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
+ }
package/dist/index.d.ts CHANGED
@@ -57,6 +57,11 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
57
57
  * way the core show route drops them.
58
58
  */
59
59
  private scopeRecordForFrontend;
60
+ /**
61
+ * Links to the stored images of the generated image columns, built by the Upload plugin of each
62
+ * column the same way its own previews are, so the dialog shows the current image next to the new one.
63
+ */
64
+ private imagePreviewUrls;
60
65
  /**
61
66
  * The row as the caller is allowed to read it: columns which are not declared on the resource and
62
67
  * columns whose `backendOnly` resolves true for this request are dropped, exactly the way the
package/dist/index.js CHANGED
@@ -48,10 +48,6 @@ const jobStatusBodySchema = z.object({
48
48
  const updateRateLimitsBodySchema = z.object({
49
49
  actionType: z.string().nullish(),
50
50
  }).strict();
51
- const compileOldImageLinkBodySchema = z.object({
52
- image: z.string().nullish(),
53
- columnName: z.string().nullish(),
54
- }).strict();
55
51
  const getFilteredIdsBodySchema = z.object({
56
52
  filters: z.any(),
57
53
  }).passthrough();
@@ -687,10 +683,18 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
687
683
  outputImagesPluginInstanceIds[key] = plugin.pluginInstanceId;
688
684
  }
689
685
  }
690
- const outputFields = Object.assign(Object.assign(Object.assign({}, this.options.fillFieldsFromImages), this.options.fillPlainFields), (this.options.generateImages || {}));
686
+ const outputFields = [
687
+ ...outputFieldsForAnalizeFromImages,
688
+ ...outputPlainFields,
689
+ ...outputImageFields,
690
+ ];
691
691
  const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
692
692
  // Map of column technical names to their display labels from resource config
693
693
  const columnLabels = Object.fromEntries((this.resourceConfig.columns || []).map((c) => [c.name, c.label || c.name]));
694
+ // the review dialog picks an editor for a generated value by the type of its column
695
+ const columnTypes = Object.fromEntries(this.resourceConfig.columns.filter((c) => outputFields.includes(c.name)).map((c) => [c.name, c.type]));
696
+ // only the prompts go to the browser: an entry of generateImages may hold an adapter with its API key
697
+ const imageGenerationPrompts = Object.fromEntries(Object.entries(this.options.generateImages || {}).map(([key, value]) => [key, value.prompt]));
694
698
  const pageInjection = {
695
699
  file: this.componentPath('VisionAction.vue'),
696
700
  meta: {
@@ -699,6 +703,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
699
703
  actionName: this.options.actionName,
700
704
  columnEnums: columnEnums,
701
705
  columnLabels: columnLabels,
706
+ columnTypes: columnTypes,
702
707
  outputImageFields: outputImageFields,
703
708
  outputFieldsForAnalizeFromImages: outputFieldsForAnalizeFromImages,
704
709
  outputPlainFields: outputPlainFields,
@@ -722,7 +727,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
722
727
  generationPrompts: {
723
728
  plainFieldsPrompts: this.options.fillPlainFields || {},
724
729
  imageFieldsPrompts: this.options.fillFieldsFromImages || {},
725
- imageGenerationPrompts: this.options.generateImages || {},
730
+ imageGenerationPrompts: imageGenerationPrompts,
726
731
  },
727
732
  pageSize: this.options.pageSize
728
733
  }
@@ -985,6 +990,24 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
985
990
  return scoped;
986
991
  });
987
992
  }
993
+ /**
994
+ * Links to the stored images of the generated image columns, built by the Upload plugin of each
995
+ * column the same way its own previews are, so the dialog shows the current image next to the new one.
996
+ */
997
+ imagePreviewUrls(record) {
998
+ return __awaiter(this, void 0, void 0, function* () {
999
+ var _a;
1000
+ const previews = {};
1001
+ for (const key of Object.keys(this.options.generateImages || {})) {
1002
+ const uploadPlugin = this.adminforth.activatedPlugins.find(p => p.resourceConfig.resourceId === this.resourceConfig.resourceId &&
1003
+ p.pluginOptions.pathColumnName === key);
1004
+ const holder = { [key]: record[key] };
1005
+ yield uploadPlugin.genPreviewUrl(holder);
1006
+ previews[key] = (_a = holder[`previewUrl_${uploadPlugin.pluginInstanceId}`]) !== null && _a !== void 0 ? _a : null;
1007
+ }
1008
+ return previews;
1009
+ });
1010
+ }
988
1011
  /**
989
1012
  * The row as the caller is allowed to read it: columns which are not declared on the resource and
990
1013
  * columns whose `backendOnly` resolves true for this request are dropped, exactly the way the
@@ -1022,7 +1045,11 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1022
1045
  if (error) {
1023
1046
  return { ok: false, error };
1024
1047
  }
1025
- return { ok: true, record: yield this.scopeRecordForFrontend(record, ctx) };
1048
+ return {
1049
+ ok: true,
1050
+ record: yield this.scopeRecordForFrontend(record, ctx),
1051
+ imagePreviews: yield this.imagePreviewUrls(record),
1052
+ };
1026
1053
  })
1027
1054
  });
1028
1055
  server.endpoint({
@@ -1325,38 +1352,6 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1325
1352
  return { ok: true, sessionId };
1326
1353
  })
1327
1354
  });
1328
- server.endpoint({
1329
- method: 'POST',
1330
- path: `/plugin/${this.pluginInstanceId}/compile_old_image_link`,
1331
- agent: { hiddenFromAgents: true },
1332
- request_schema: compileOldImageLinkBodySchema,
1333
- handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, headers, response }) {
1334
- var _b, _c;
1335
- const data = body;
1336
- const image = data.image;
1337
- const columnName = data.columnName;
1338
- if (!image) {
1339
- return { ok: false, error: "Can't find image url" };
1340
- }
1341
- if (!columnName) {
1342
- return { ok: false, error: "Can't find column name" };
1343
- }
1344
- try {
1345
- if ((_b = this.options) === null || _b === void 0 ? void 0 : _b.generateImages) {
1346
- const plugin = this.adminforth.activatedPlugins.find(p => p.resourceConfig.resourceId === this.resourceConfig.resourceId &&
1347
- p.pluginOptions.pathColumnName === columnName);
1348
- if ((_c = plugin === null || plugin === void 0 ? void 0 : plugin.pluginOptions) === null || _c === void 0 ? void 0 : _c.preview) {
1349
- const compiledPreviewUrl = plugin.pluginOptions.preview.previewUrl({ filePath: image });
1350
- return { ok: true, previewUrl: compiledPreviewUrl };
1351
- }
1352
- return { ok: false, error: "Can't find plugin for column" };
1353
- }
1354
- }
1355
- catch (e) {
1356
- return { ok: false, error: "Error compiling preview url" };
1357
- }
1358
- })
1359
- });
1360
1355
  server.endpoint({
1361
1356
  method: 'POST',
1362
1357
  path: `/plugin/${this.pluginInstanceId}/get_filtered_ids`,
package/dist/types.d.ts CHANGED
@@ -131,8 +131,8 @@ export interface PluginOptions extends PluginsCommonOptions {
131
131
  everyRecords: number;
132
132
  })[];
133
133
  /**
134
- * Number of cards to show per page in the VisionTable component.
135
- * If omitted, the component falls back to 6.
134
+ * Number of records per page in the list of the review dialog.
135
+ * If omitted, the list shows 10 records per page.
136
136
  */
137
137
  pageSize?: number;
138
138
  }
package/index.ts CHANGED
@@ -50,11 +50,6 @@ const updateRateLimitsBodySchema = z.object({
50
50
  actionType: z.string().nullish(),
51
51
  }).strict();
52
52
 
53
- const compileOldImageLinkBodySchema = z.object({
54
- image: z.string().nullish(),
55
- columnName: z.string().nullish(),
56
- }).strict();
57
-
58
53
  const getFilteredIdsBodySchema = z.object({
59
54
  filters: z.any(),
60
55
  }).passthrough();
@@ -694,18 +689,26 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
694
689
  }
695
690
  }
696
691
 
697
- const outputFields = {
698
- ...this.options.fillFieldsFromImages,
699
- ...this.options.fillPlainFields,
700
- ...(this.options.generateImages || {})
701
- };
692
+ const outputFields = [
693
+ ...outputFieldsForAnalizeFromImages,
694
+ ...outputPlainFields,
695
+ ...outputImageFields,
696
+ ];
702
697
 
703
698
  const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
704
699
  // Map of column technical names to their display labels from resource config
705
700
  const columnLabels: Record<string, string> = Object.fromEntries(
706
701
  (this.resourceConfig.columns || []).map((c: any) => [c.name, c.label || c.name])
707
702
  );
708
-
703
+ // the review dialog picks an editor for a generated value by the type of its column
704
+ const columnTypes: Record<string, string> = Object.fromEntries(
705
+ this.resourceConfig.columns.filter((c) => outputFields.includes(c.name)).map((c) => [c.name, c.type])
706
+ );
707
+ // only the prompts go to the browser: an entry of generateImages may hold an adapter with its API key
708
+ const imageGenerationPrompts: Record<string, string> = Object.fromEntries(
709
+ Object.entries(this.options.generateImages || {}).map(([key, value]) => [key, value.prompt])
710
+ );
711
+
709
712
  const pageInjection = {
710
713
  file: this.componentPath('VisionAction.vue'),
711
714
  meta: {
@@ -714,6 +717,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
714
717
  actionName: this.options.actionName,
715
718
  columnEnums: columnEnums,
716
719
  columnLabels: columnLabels,
720
+ columnTypes: columnTypes,
717
721
  outputImageFields: outputImageFields,
718
722
  outputFieldsForAnalizeFromImages: outputFieldsForAnalizeFromImages,
719
723
  outputPlainFields: outputPlainFields,
@@ -737,7 +741,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
737
741
  generationPrompts: {
738
742
  plainFieldsPrompts: this.options.fillPlainFields || {},
739
743
  imageFieldsPrompts: this.options.fillFieldsFromImages || {},
740
- imageGenerationPrompts: this.options.generateImages || {},
744
+ imageGenerationPrompts: imageGenerationPrompts,
741
745
  },
742
746
  pageSize: this.options.pageSize
743
747
  }
@@ -1036,6 +1040,24 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1036
1040
  return scoped;
1037
1041
  }
1038
1042
 
1043
+ /**
1044
+ * Links to the stored images of the generated image columns, built by the Upload plugin of each
1045
+ * column the same way its own previews are, so the dialog shows the current image next to the new one.
1046
+ */
1047
+ private async imagePreviewUrls(record: any): Promise<Record<string, string | null>> {
1048
+ const previews: Record<string, string | null> = {};
1049
+ for (const key of Object.keys(this.options.generateImages || {})) {
1050
+ const uploadPlugin = this.adminforth.activatedPlugins.find(p =>
1051
+ p.resourceConfig!.resourceId === this.resourceConfig.resourceId &&
1052
+ p.pluginOptions.pathColumnName === key
1053
+ ) as any;
1054
+ const holder = { [key]: record[key] };
1055
+ await uploadPlugin.genPreviewUrl(holder);
1056
+ previews[key] = holder[`previewUrl_${uploadPlugin.pluginInstanceId}`] ?? null;
1057
+ }
1058
+ return previews;
1059
+ }
1060
+
1039
1061
  /**
1040
1062
  * The row as the caller is allowed to read it: columns which are not declared on the resource and
1041
1063
  * columns whose `backendOnly` resolves true for this request are dropped, exactly the way the
@@ -1072,7 +1094,11 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1072
1094
  if (error) {
1073
1095
  return { ok: false, error };
1074
1096
  }
1075
- return { ok: true, record: await this.scopeRecordForFrontend(record, ctx) };
1097
+ return {
1098
+ ok: true,
1099
+ record: await this.scopeRecordForFrontend(record, ctx),
1100
+ imagePreviews: await this.imagePreviewUrls(record),
1101
+ };
1076
1102
  }
1077
1103
  });
1078
1104
 
@@ -1402,39 +1428,6 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1402
1428
  });
1403
1429
 
1404
1430
 
1405
- server.endpoint({
1406
- method: 'POST',
1407
- path: `/plugin/${this.pluginInstanceId}/compile_old_image_link`,
1408
- agent: { hiddenFromAgents: true },
1409
- request_schema: compileOldImageLinkBodySchema,
1410
- handler: async ({ body, adminUser, headers, response }) => {
1411
- const data = body as z.infer<typeof compileOldImageLinkBodySchema>;
1412
- const image = data.image;
1413
- const columnName = data.columnName;
1414
- if (!image) {
1415
- return { ok: false, error: "Can't find image url" };
1416
- }
1417
- if (!columnName) {
1418
- return { ok: false, error: "Can't find column name" };
1419
- }
1420
- try {
1421
- if (this.options?.generateImages) {
1422
- const plugin = this.adminforth.activatedPlugins.find(p =>
1423
- p.resourceConfig!.resourceId === this.resourceConfig.resourceId &&
1424
- p.pluginOptions.pathColumnName === columnName
1425
- );
1426
- if (plugin?.pluginOptions?.preview) {
1427
- const compiledPreviewUrl = plugin.pluginOptions.preview.previewUrl({ filePath: image });
1428
- return { ok: true, previewUrl: compiledPreviewUrl };
1429
- }
1430
- return { ok: false, error: "Can't find plugin for column" };
1431
- }
1432
- } catch (e) {
1433
- return { ok: false, error: "Error compiling preview url" };
1434
- }
1435
- }
1436
- });
1437
-
1438
1431
  server.endpoint({
1439
1432
  method: 'POST',
1440
1433
  path: `/plugin/${this.pluginInstanceId}/get_filtered_ids`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/bulk-ai-flow",
3
- "version": "2.0.3",
3
+ "version": "2.1.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -40,7 +40,7 @@
40
40
  "zod": "^4.3.6"
41
41
  },
42
42
  "peerDependencies": {
43
- "adminforth": "^4.1.0"
43
+ "adminforth": "^4.7.3"
44
44
  },
45
45
  "release": {
46
46
  "plugins": [
package/types.ts CHANGED
@@ -154,8 +154,8 @@ export interface PluginOptions extends PluginsCommonOptions {
154
154
  askConfirmation?: ({ afterRecords: number } | { everyRecords: number })[]
155
155
 
156
156
  /**
157
- * Number of cards to show per page in the VisionTable component.
158
- * If omitted, the component falls back to 6.
157
+ * Number of records per page in the list of the review dialog.
158
+ * If omitted, the list shows 10 records per page.
159
159
  */
160
160
  pageSize?: number;
161
161
 
@@ -1,185 +0,0 @@
1
- <template>
2
- <!-- Popup Overlay -->
3
- <div class="fixed inset-0 z-40 flex items-center justify-center bg-lightScrim dark:bg-darkScrim" @click.self="closePopup">
4
- <div class="image-compare-container shadow-sm p-4 border-white dark:border-black max-w-4xl max-h-[90vh] overflow-y-auto rounded-lg bg-white dark:bg-gray-800">
5
- <!-- Close Button -->
6
- <div class="flex justify-end mb-4">
7
- <button type="button"
8
- @click="closePopup"
9
- class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" >
10
- <svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
11
- <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
12
- </svg>
13
- <span class="sr-only">{{ $t('Close modal') }}</span>
14
- </button>
15
- </div>
16
- <div class="flex gap-4 items-start justify-between">
17
- <h3 class="text-sm font-medium text-gray-700 dark:text-white mb-2">{{ $t('Old Image') }}</h3>
18
- <h3 class="text-sm font-medium text-gray-700 dark:text-white mb-2">{{ $t('New Image') }}</h3>
19
- </div>
20
- <div class="flex gap-4 items-center">
21
- <!-- Old Image -->
22
- <div class="flex-1">
23
- <div class="relative">
24
- <img
25
- v-if="isValidUrl(compiledOldImage)"
26
- ref="oldImg"
27
- :src="compiledOldImage"
28
- alt="Old image"
29
- class="w-full max-w-sm h-auto object-cover rounded-lg cursor-pointer border hover:border-blue-500 transition-colors duration-200"
30
- />
31
- <div v-else class="w-full max-w-sm h-48 bg-gray-100 rounded-lg flex items-center justify-center">
32
- <p class="text-gray-500">{{ $t('No old image') }}</p>
33
- </div>
34
- </div>
35
- </div>
36
-
37
- <!-- Comparison Arrow -->
38
- <div class="flex items-center justify-center">
39
- <div class="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center dark:bg-gray-500">
40
- <svg class="w-4 h-4 text-blue-600 dark:text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24">
41
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
42
- </svg>
43
- </div>
44
- </div>
45
-
46
- <!-- New Image -->
47
- <div class="flex-1">
48
- <div class="relative">
49
- <img
50
- v-if="isValidUrl(newImage)"
51
- ref="newImg"
52
- :src="newImage"
53
- alt="New image"
54
- class="w-full max-w-sm h-auto object-cover rounded-lg cursor-pointer border hover:border-blue-500 transition-colors duration-200"
55
- />
56
- <div v-else class="w-full max-w-sm h-48 bg-gray-100 rounded-lg flex items-center justify-center">
57
- <p class="text-gray-500">{{ $t('No new image') }}</p>
58
- </div>
59
- </div>
60
- </div>
61
- </div>
62
- </div>
63
- </div>
64
-
65
-
66
- </template>
67
-
68
- <script setup lang="ts">
69
- import { ref, onMounted, watch, nextTick } from 'vue'
70
- import mediumZoom from 'medium-zoom'
71
- import { callAdminForthApi } from '@/utils';
72
-
73
- const props = defineProps<{
74
- oldImage: string
75
- newImage: string
76
- meta: any
77
- columnName: string
78
- }>()
79
-
80
- const emit = defineEmits<{
81
- close: []
82
- }>()
83
-
84
- const oldImg = ref<HTMLImageElement | null>(null)
85
- const newImg = ref<HTMLImageElement | null>(null)
86
- const oldZoom = ref<any>(null)
87
- const newZoom = ref<any>(null)
88
- const compiledOldImage = ref<string>('')
89
-
90
- async function compileOldImage() {
91
- try {
92
- const res = await callAdminForthApi({
93
- path: `/plugin/${props.meta.pluginInstanceId}/compile_old_image_link`,
94
- method: 'POST',
95
- body: {
96
- image: props.oldImage,
97
- columnName: props.columnName,
98
- },
99
- });
100
- compiledOldImage.value = res.previewUrl;
101
- } catch (e) {
102
- console.error("Error compiling old image:", e)
103
- return;
104
- }
105
- }
106
-
107
- function closePopup() {
108
- emit('close')
109
- }
110
-
111
- function isValidUrl(str: string): boolean {
112
- if (!str) return false
113
- try {
114
- new URL(str)
115
- return true
116
- } catch {
117
- return false
118
- }
119
- }
120
-
121
- function initializeZoom() {
122
- // Clean up existing zoom instances
123
- if (oldZoom.value) {
124
- oldZoom.value.detach()
125
- }
126
- if (newZoom.value) {
127
- newZoom.value.detach()
128
- }
129
-
130
- // Initialize zoom for old image
131
- if (oldImg.value && isValidUrl(compiledOldImage.value)) {
132
- oldZoom.value = mediumZoom(oldImg.value, {
133
- margin: 24,
134
- background: 'rgba(0, 0, 0, 0.8)',
135
- scrollOffset: 150
136
- })
137
- }
138
-
139
- // Initialize zoom for new image
140
- if (newImg.value && isValidUrl(props.newImage)) {
141
- newZoom.value = mediumZoom(newImg.value, {
142
- margin: 24,
143
- background: 'rgba(0, 0, 0, 0.8)',
144
- scrollOffset: 150
145
- })
146
- }
147
- }
148
-
149
- onMounted(async () => {
150
- await compileOldImage()
151
- await nextTick()
152
- initializeZoom()
153
- })
154
-
155
- // Re-initialize zoom when images change
156
- watch([() => props.oldImage, () => props.newImage, () => compiledOldImage.value], async () => {
157
- await nextTick()
158
- initializeZoom()
159
- })
160
- </script>
161
-
162
- <style scoped>
163
- .medium-zoom-image {
164
- z-index: 999999;
165
- background: rgba(0, 0, 0, 0.8);
166
- border: none;
167
- border-radius: 0;
168
- }
169
- .medium-zoom-overlay {
170
- z-index: 99999;
171
- background: rgba(0, 0, 0, 0.8);
172
- }
173
- html.dark .medium-zoom-overlay {
174
- background: rgba(17, 24, 39, 0.8);
175
- }
176
- body.medium-zoom--opened aside {
177
- filter: grayscale(1);
178
- }
179
- .fade-enter-active, .fade-leave-active {
180
- transition: opacity 0.3s ease;
181
- }
182
- .fade-enter-from, .fade-leave-to {
183
- opacity: 0;
184
- }
185
- </style>