@stellartech/image-style-widget-directus 1.0.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,493 @@
1
+ import { useApi } from '@directus/extensions-sdk';
2
+ import { ref } from 'vue';
3
+
4
+ export interface Style {
5
+ id: string;
6
+ name: string;
7
+ prompt: string;
8
+ example_1: string | null;
9
+ example_2: string | null;
10
+ is_active: boolean;
11
+ sort: number;
12
+ }
13
+
14
+ export interface RegenerateResponse {
15
+ file_id: string;
16
+ url: string;
17
+ }
18
+
19
+ export interface ContentImage {
20
+ id: number;
21
+ image: string | null; // file UUID
22
+ prompt: string | null;
23
+ status: string;
24
+ }
25
+
26
+ export interface ImageVariant {
27
+ id: string;
28
+ title: string; // placeholder/caption
29
+ status: string;
30
+ lesson?: string;
31
+ images?: ContentImage[]; // related ContentImage items
32
+ }
33
+
34
+ export interface GeneratedImageSet {
35
+ placeholder: string;
36
+ prompt: string;
37
+ image1: string | null;
38
+ image2: string | null;
39
+ selected: number | null;
40
+ }
41
+
42
+ // Response from the prompt generation service
43
+ export interface ImagePrompt {
44
+ placeholder: string;
45
+ prompts: string[];
46
+ }
47
+
48
+ export interface PromptGenerationResponse {
49
+ images: ImagePrompt[];
50
+ }
51
+
52
+ // Supported image generation models
53
+ export type ImageModel = 'flux-kontext-pro' | 'gemini-2.5-flash-image';
54
+
55
+ export const IMAGE_MODELS: { id: ImageModel; name: string }[] = [
56
+ { id: 'flux-kontext-pro', name: 'Flux' },
57
+ { id: 'gemini-2.5-flash-image', name: 'Nano Banana' },
58
+ ];
59
+
60
+ const MISC_SERVICE_URL = 'http://localhost:8004';
61
+
62
+ export function useDirectusApi() {
63
+ const api = useApi();
64
+ const loading = ref(false);
65
+ const error = ref<string | null>(null);
66
+
67
+ /**
68
+ * Fetch all active styles from the ImageStyles collection
69
+ */
70
+ async function fetchStyles(collection: string = 'ImageStyles'): Promise<Style[]> {
71
+ loading.value = true;
72
+ error.value = null;
73
+
74
+ try {
75
+ const response = await api.get(`/items/${collection}`, {
76
+ params: {
77
+ filter: { is_active: { _eq: true } },
78
+ sort: ['sort'],
79
+ fields: ['id', 'name', 'prompt', 'example_1', 'example_2', 'is_active', 'sort'],
80
+ },
81
+ });
82
+ return response.data.data || [];
83
+ } catch (e: any) {
84
+ error.value = e.message || 'Failed to fetch styles';
85
+ return [];
86
+ } finally {
87
+ loading.value = false;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Update a style's prompt
93
+ */
94
+ async function updateStylePrompt(collection: string, styleId: string, newPrompt: string): Promise<boolean> {
95
+ loading.value = true;
96
+ error.value = null;
97
+
98
+ try {
99
+ await api.patch(`/items/${collection}/${styleId}`, {
100
+ prompt: newPrompt,
101
+ });
102
+ return true;
103
+ } catch (e: any) {
104
+ error.value = e.message || 'Failed to update prompt';
105
+ return false;
106
+ } finally {
107
+ loading.value = false;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Helper function to generate a single image via misc service
113
+ */
114
+ async function generateSingleImage(prompt: string, model: ImageModel = 'flux-kontext-pro'): Promise<string | null> {
115
+ const miscServiceUrl = `${MISC_SERVICE_URL}/api/misc/images/upload`;
116
+
117
+ const response = await fetch(miscServiceUrl, {
118
+ method: 'POST',
119
+ headers: {
120
+ 'Content-Type': 'application/json',
121
+ },
122
+ body: JSON.stringify({
123
+ prompt,
124
+ model,
125
+ format: 'png',
126
+ aspect_ratio: '16:9',
127
+ })
128
+ });
129
+
130
+ if (!response.ok) {
131
+ throw new Error(`Image generation failed: ${response.status}`);
132
+ }
133
+
134
+ const data = await response.json();
135
+ return data?.id || null; // Backend returns 'id' not 'file_id'
136
+ }
137
+
138
+ /**
139
+ * Regenerate example images for a style
140
+ */
141
+ async function regenerateStyleExamples(
142
+ styleId: string,
143
+ prompt: string,
144
+ collection: string = 'ImageStyles',
145
+ model: ImageModel = 'flux-kontext-pro'
146
+ ): Promise<{ example_1: string; example_2: string } | null> {
147
+ loading.value = true;
148
+ error.value = null;
149
+
150
+ try {
151
+ // Generate two new images via misc service directly
152
+ const [example_1, example_2] = await Promise.all([
153
+ generateSingleImage(prompt, model),
154
+ generateSingleImage(prompt, model),
155
+ ]);
156
+
157
+ if (example_1 && example_2) {
158
+ // Update the style with new example images
159
+ await api.patch(`/items/${collection}/${styleId}`, {
160
+ example_1,
161
+ example_2,
162
+ });
163
+ return { example_1, example_2 };
164
+ }
165
+
166
+ error.value = 'Failed to generate images';
167
+ return null;
168
+ } catch (e: any) {
169
+ error.value = e.message || 'Failed to regenerate images';
170
+ return null;
171
+ } finally {
172
+ loading.value = false;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Regenerate images for a specific placeholder using both prompts
178
+ */
179
+ async function regeneratePlaceholderImages(
180
+ prompt1: string,
181
+ prompt2: string,
182
+ model: ImageModel = 'flux-kontext-pro'
183
+ ): Promise<{ image1: string; image2: string } | null> {
184
+ loading.value = true;
185
+ error.value = null;
186
+
187
+ try {
188
+ // Generate one image per prompt
189
+ const [image1, image2] = await Promise.all([
190
+ generateSingleImage(prompt1, model),
191
+ generateSingleImage(prompt2, model),
192
+ ]);
193
+
194
+ if (image1 && image2) {
195
+ return { image1, image2 };
196
+ }
197
+
198
+ error.value = 'Failed to generate images';
199
+ return null;
200
+ } catch (e: any) {
201
+ error.value = e.message || 'Failed to regenerate images';
202
+ return null;
203
+ } finally {
204
+ loading.value = false;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Get the URL for a file by its ID
210
+ */
211
+ function getFileUrl(fileId: string | null): string {
212
+ if (!fileId) return '';
213
+ return `/assets/${fileId}?width=200&height=200&fit=cover`;
214
+ }
215
+
216
+ /**
217
+ * Generate image prompts using the Python misc service
218
+ * Calls the misc service directly (not through Directus)
219
+ */
220
+ async function generateImagePrompts(
221
+ style: string,
222
+ description: string
223
+ ): Promise<PromptGenerationResponse | null> {
224
+ loading.value = true;
225
+ error.value = null;
226
+
227
+ try {
228
+ const miscServiceUrl = `${MISC_SERVICE_URL}/api/misc/prompts`;
229
+
230
+ const response = await fetch(miscServiceUrl, {
231
+ method: 'POST',
232
+ headers: {
233
+ 'Content-Type': 'application/json',
234
+ },
235
+ body: JSON.stringify({
236
+ style,
237
+ description,
238
+ model: 'gpt-4o-mini'
239
+ })
240
+ });
241
+
242
+ if (!response.ok) {
243
+ const errorData = await response.json().catch(() => ({ detail: response.statusText }));
244
+ throw new Error(errorData.detail || `HTTP ${response.status}`);
245
+ }
246
+
247
+ return await response.json() as PromptGenerationResponse;
248
+ } catch (e: any) {
249
+ error.value = e.message || 'Failed to generate image prompts';
250
+ return null;
251
+ } finally {
252
+ loading.value = false;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Get ImageVariants created for a specific lesson, including their images
258
+ */
259
+ async function getImageVariants(lessonId: string): Promise<ImageVariant[]> {
260
+ loading.value = true;
261
+ error.value = null;
262
+
263
+ try {
264
+ const response = await api.get('/items/ImageVariants', {
265
+ params: {
266
+ filter: { lesson: { _eq: lessonId } },
267
+ fields: ['id', 'title', 'status', 'lesson', 'images.id', 'images.image', 'images.prompt', 'images.status'],
268
+ },
269
+ });
270
+ return response.data.data || [];
271
+ } catch (e: any) {
272
+ error.value = e.message || 'Failed to fetch image variants';
273
+ return [];
274
+ } finally {
275
+ loading.value = false;
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Save generated images to ImageVariants and ContentImage collections
281
+ */
282
+ async function saveGeneratedImages(
283
+ lessonId: string,
284
+ items: Array<{
285
+ placeholder: string;
286
+ prompt1: string;
287
+ prompt2: string;
288
+ image1: string | null;
289
+ image2: string | null;
290
+ }>
291
+ ): Promise<boolean> {
292
+ loading.value = true;
293
+ error.value = null;
294
+
295
+ try {
296
+ for (const item of items) {
297
+ // Create ImageVariant record
298
+ const variantResponse = await api.post('/items/ImageVariants', {
299
+ title: item.placeholder,
300
+ lesson: lessonId,
301
+ status: 'found',
302
+ });
303
+
304
+ const variantId = variantResponse.data.data.id;
305
+
306
+ // Create ContentImage records for each image
307
+ const contentImages = [];
308
+
309
+ if (item.image1) {
310
+ contentImages.push({
311
+ image: item.image1,
312
+ prompt: item.prompt1,
313
+ variants: variantId,
314
+ status: 'generated',
315
+ });
316
+ }
317
+
318
+ if (item.image2) {
319
+ contentImages.push({
320
+ image: item.image2,
321
+ prompt: item.prompt2,
322
+ variants: variantId,
323
+ status: 'generated',
324
+ });
325
+ }
326
+
327
+ // Batch create ContentImage records
328
+ if (contentImages.length > 0) {
329
+ await api.post('/items/ContentImage', contentImages);
330
+ }
331
+ }
332
+
333
+ return true;
334
+ } catch (e: any) {
335
+ error.value = e.message || 'Failed to save generated images';
336
+ return false;
337
+ } finally {
338
+ loading.value = false;
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Delete an ImageVariant and its associated ContentImage records and files
344
+ */
345
+ async function deleteImageVariant(lessonId: string, placeholder: string): Promise<boolean> {
346
+ loading.value = true;
347
+ error.value = null;
348
+
349
+ try {
350
+ // Find the ImageVariant by lesson and title (placeholder), including image file IDs
351
+ const response = await api.get('/items/ImageVariants', {
352
+ params: {
353
+ filter: {
354
+ lesson: { _eq: lessonId },
355
+ title: { _eq: placeholder }
356
+ },
357
+ fields: ['id', 'images.id', 'images.image'],
358
+ },
359
+ });
360
+
361
+ const variants = response.data.data || [];
362
+
363
+ for (const variant of variants) {
364
+ // Collect file IDs to delete
365
+ const fileIdsToDelete: string[] = [];
366
+
367
+ // Delete associated ContentImage records first
368
+ if (variant.images && variant.images.length > 0) {
369
+ for (const img of variant.images) {
370
+ // Collect the file ID if present
371
+ if (img.image) {
372
+ fileIdsToDelete.push(img.image);
373
+ }
374
+ // Delete the ContentImage record
375
+ await api.delete(`/items/ContentImage/${img.id}`);
376
+ }
377
+ }
378
+
379
+ // Delete the ImageVariant
380
+ await api.delete(`/items/ImageVariants/${variant.id}`);
381
+
382
+ // Delete the actual files from directus_files
383
+ for (const fileId of fileIdsToDelete) {
384
+ try {
385
+ await api.delete(`/files/${fileId}`);
386
+ } catch (fileError: any) {
387
+ // Log but don't fail if file deletion fails (file might already be deleted)
388
+ console.warn(`Failed to delete file ${fileId}:`, fileError.message);
389
+ }
390
+ }
391
+ }
392
+
393
+ return true;
394
+ } catch (e: any) {
395
+ error.value = e.message || 'Failed to delete image variant';
396
+ return false;
397
+ } finally {
398
+ loading.value = false;
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Extract [placeholder] patterns from content
404
+ */
405
+ function extractPlaceholders(content: string): string[] {
406
+ const regex = /\[([^\]]+)\]/g;
407
+ const matches: string[] = [];
408
+ let match;
409
+ while ((match = regex.exec(content)) !== null) {
410
+ matches.push(match[1]);
411
+ }
412
+ return matches;
413
+ }
414
+
415
+ /**
416
+ * Update lesson content by replacing [placeholder] with HTML image tag
417
+ * Uses HTML format for WYSIWYG compatibility
418
+ */
419
+ function updateContentWithImages(
420
+ content: string,
421
+ selections: { caption: string; fileId: string }[]
422
+ ): string {
423
+ let updated = content;
424
+ for (const { caption, fileId } of selections) {
425
+ const placeholder = `[${caption}]`;
426
+ const html = `<img src="/assets/${fileId}" alt="${caption}" />`;
427
+ updated = updated.replace(placeholder, html);
428
+ }
429
+ return updated;
430
+ }
431
+
432
+ /**
433
+ * Save updated content back to the lesson
434
+ * Note: field name is 'description' (lowercase) in Directus schema
435
+ */
436
+ async function updateLessonContent(
437
+ lessonId: string,
438
+ content: string
439
+ ): Promise<boolean> {
440
+ loading.value = true;
441
+ error.value = null;
442
+
443
+ try {
444
+ await api.patch(`/items/SM_Lessons/${lessonId}`, {
445
+ description: content,
446
+ });
447
+ return true;
448
+ } catch (e: any) {
449
+ error.value = e.message || 'Failed to update lesson content';
450
+ return false;
451
+ } finally {
452
+ loading.value = false;
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Fetch lesson content from the API
458
+ * This is more reliable than relying on inject('values') context
459
+ */
460
+ async function fetchLessonContent(lessonId: string): Promise<string | null> {
461
+ try {
462
+ const response = await api.get(`/items/SM_Lessons/${lessonId}`, {
463
+ params: {
464
+ fields: ['description'],
465
+ },
466
+ });
467
+ // Try both lowercase and capitalized field names for compatibility
468
+ return response.data.data?.description || response.data.data?.Description || null;
469
+ } catch (e: any) {
470
+ console.error('Failed to fetch lesson content:', e);
471
+ return null;
472
+ }
473
+ }
474
+
475
+ return {
476
+ loading,
477
+ error,
478
+ fetchStyles,
479
+ updateStylePrompt,
480
+ regenerateStyleExamples,
481
+ regeneratePlaceholderImages,
482
+ getFileUrl,
483
+ generateImagePrompts,
484
+ getImageVariants,
485
+ saveGeneratedImages,
486
+ deleteImageVariant,
487
+ extractPlaceholders,
488
+ generateSingleImage,
489
+ updateContentWithImages,
490
+ updateLessonContent,
491
+ fetchLessonContent,
492
+ };
493
+ }
package/src/index.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { defineInterface } from '@directus/extensions-sdk';
2
+ import InterfaceComponent from './interface.vue';
3
+
4
+ export default defineInterface({
5
+ id: 'image-style-widget',
6
+ name: 'Image Style Widget',
7
+ icon: 'palette',
8
+ description: 'Visual style selector and image reviewer',
9
+ component: InterfaceComponent,
10
+ options: [
11
+ {
12
+ field: 'mode',
13
+ name: 'Mode',
14
+ type: 'string',
15
+ meta: {
16
+ width: 'half',
17
+ interface: 'select-dropdown',
18
+ options: {
19
+ choices: [
20
+ { text: 'Style Selection', value: 'style-select' },
21
+ { text: 'Image Review', value: 'image-review' },
22
+ ],
23
+ },
24
+ },
25
+ schema: {
26
+ default_value: 'style-select',
27
+ },
28
+ },
29
+ {
30
+ field: 'stylesCollection',
31
+ name: 'Styles Collection',
32
+ type: 'string',
33
+ meta: {
34
+ width: 'half',
35
+ interface: 'input',
36
+ note: 'Collection containing style definitions (default: ImageStyles)',
37
+ },
38
+ schema: {
39
+ default_value: 'ImageStyles',
40
+ },
41
+ },
42
+ ],
43
+ types: ['json', 'string'],
44
+ });