@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.
- package/dist/index.js +1667 -0
- package/package.json +24 -0
- package/src/components/EditModal.vue +237 -0
- package/src/components/ImageCard.vue +380 -0
- package/src/components/StyleCard.vue +258 -0
- package/src/composables/useDirectusApi.ts +493 -0
- package/src/index.ts +44 -0
- package/src/interface.vue +970 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
git<template>
|
|
2
|
+
<div class="image-style-widget">
|
|
3
|
+
<!-- Processing State -->
|
|
4
|
+
<div v-if="processing" class="widget__processing">
|
|
5
|
+
<div class="widget__progress">
|
|
6
|
+
<v-icon name="refresh" class="spinning" />
|
|
7
|
+
<span>{{ processingMessage }}</span>
|
|
8
|
+
</div>
|
|
9
|
+
<div class="widget__progress-bar">
|
|
10
|
+
<div class="widget__progress-fill" :style="{ width: progressPercent + '%' }"></div>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<!-- Loading State -->
|
|
15
|
+
<div v-else-if="loading && !styles.length" class="widget__loading">
|
|
16
|
+
<v-icon name="refresh" />
|
|
17
|
+
<span>Loading styles...</span>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<!-- Error State -->
|
|
21
|
+
<div v-else-if="error" class="widget__error">
|
|
22
|
+
<v-icon name="error" />
|
|
23
|
+
<span>{{ error }}</span>
|
|
24
|
+
<button class="widget__retry" @click="loadStyles">Retry</button>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<!-- Mode 1: Style Selection -->
|
|
28
|
+
<template v-else-if="currentMode === 'style-select'">
|
|
29
|
+
<div class="widget__header">
|
|
30
|
+
<div class="widget__header-row">
|
|
31
|
+
<div class="widget__header-text">
|
|
32
|
+
<h2 class="widget__title">
|
|
33
|
+
<v-icon name="palette" />
|
|
34
|
+
Select Image Style
|
|
35
|
+
</h2>
|
|
36
|
+
<p class="widget__subtitle">
|
|
37
|
+
Choose a style for your generated images
|
|
38
|
+
</p>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="widget__model-selector">
|
|
41
|
+
<label class="widget__model-label">AI Model:</label>
|
|
42
|
+
<div class="widget__model-buttons">
|
|
43
|
+
<button
|
|
44
|
+
v-for="model in IMAGE_MODELS"
|
|
45
|
+
:key="model.id"
|
|
46
|
+
class="widget__model-btn"
|
|
47
|
+
:class="{ 'widget__model-btn--active': selectedModel === model.id }"
|
|
48
|
+
@click="selectedModel = model.id"
|
|
49
|
+
>
|
|
50
|
+
{{ model.name }}
|
|
51
|
+
</button>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div class="widget__list">
|
|
58
|
+
<StyleCard
|
|
59
|
+
v-for="style in styles"
|
|
60
|
+
:key="style.id"
|
|
61
|
+
:style="style"
|
|
62
|
+
:is-selected="selectedStyleId === style.id"
|
|
63
|
+
:loading="regeneratingStyleId === style.id"
|
|
64
|
+
:radio-group-name="`style-select-${uid}`"
|
|
65
|
+
:get-file-url="getFileUrl"
|
|
66
|
+
@select="handleStyleSelect"
|
|
67
|
+
@edit="openEditModal(style)"
|
|
68
|
+
@regenerate="handleStyleRegenerate"
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div v-if="styles.length === 0" class="widget__empty">
|
|
73
|
+
<v-icon name="palette" />
|
|
74
|
+
<p>No styles available</p>
|
|
75
|
+
<span>Add styles to the {{ stylesCollection }} collection</span>
|
|
76
|
+
</div>
|
|
77
|
+
</template>
|
|
78
|
+
|
|
79
|
+
<!-- Mode 2: Image Review -->
|
|
80
|
+
<template v-else-if="currentMode === 'image-review'">
|
|
81
|
+
<div class="widget__header">
|
|
82
|
+
<div class="widget__header-row">
|
|
83
|
+
<div class="widget__header-text">
|
|
84
|
+
<h2 class="widget__title">
|
|
85
|
+
<v-icon name="photo_library" />
|
|
86
|
+
Review Generated Images
|
|
87
|
+
</h2>
|
|
88
|
+
<p class="widget__subtitle">
|
|
89
|
+
Select the best image for each placeholder
|
|
90
|
+
</p>
|
|
91
|
+
</div>
|
|
92
|
+
<div class="widget__model-selector">
|
|
93
|
+
<label class="widget__model-label">AI Model:</label>
|
|
94
|
+
<div class="widget__model-buttons">
|
|
95
|
+
<button
|
|
96
|
+
v-for="model in IMAGE_MODELS"
|
|
97
|
+
:key="model.id"
|
|
98
|
+
class="widget__model-btn"
|
|
99
|
+
:class="{ 'widget__model-btn--active': selectedModel === model.id }"
|
|
100
|
+
@click="selectedModel = model.id"
|
|
101
|
+
>
|
|
102
|
+
{{ model.name }}
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<div class="widget__list">
|
|
110
|
+
<ImageCard
|
|
111
|
+
v-for="item in imageItems"
|
|
112
|
+
:key="item.placeholder"
|
|
113
|
+
:item="item"
|
|
114
|
+
:loading="regeneratingPlaceholder === item.placeholder"
|
|
115
|
+
:get-file-url="getFileUrl"
|
|
116
|
+
@select-image="handleImageSelect"
|
|
117
|
+
@edit="openImageEditModal(item)"
|
|
118
|
+
@regenerate="handleImageRegenerate"
|
|
119
|
+
@delete="handleImageDelete"
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div v-if="imageItems.length === 0" class="widget__empty">
|
|
124
|
+
<v-icon name="photo_library" />
|
|
125
|
+
<p>No images to review</p>
|
|
126
|
+
<span>Generate images first</span>
|
|
127
|
+
</div>
|
|
128
|
+
</template>
|
|
129
|
+
|
|
130
|
+
<!-- Footer Buttons -->
|
|
131
|
+
<div class="widget__footer" v-if="!processing">
|
|
132
|
+
<!-- Back button always visible in image-review mode -->
|
|
133
|
+
<button
|
|
134
|
+
v-if="currentMode === 'image-review'"
|
|
135
|
+
class="widget__back"
|
|
136
|
+
@click="goBackToStyleSelect"
|
|
137
|
+
>
|
|
138
|
+
<v-icon name="arrow_back" />
|
|
139
|
+
<span>Back</span>
|
|
140
|
+
</button>
|
|
141
|
+
<!-- View Images button in style-select mode when styles exist -->
|
|
142
|
+
<button
|
|
143
|
+
v-if="currentMode === 'style-select' && styles.length > 0"
|
|
144
|
+
class="widget__view-images"
|
|
145
|
+
@click="viewGeneratedImages"
|
|
146
|
+
>
|
|
147
|
+
<v-icon name="photo_library" />
|
|
148
|
+
<span>View Generated Images</span>
|
|
149
|
+
</button>
|
|
150
|
+
<button
|
|
151
|
+
class="widget__confirm"
|
|
152
|
+
:disabled="!isValid"
|
|
153
|
+
@click="handleConfirm"
|
|
154
|
+
>
|
|
155
|
+
<v-icon :name="currentMode === 'style-select' ? 'arrow_forward' : 'check'" />
|
|
156
|
+
<span>{{ confirmButtonText }}</span>
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<!-- Edit Modal -->
|
|
161
|
+
<EditModal
|
|
162
|
+
:is-open="editModalOpen"
|
|
163
|
+
:title="editModalTitle"
|
|
164
|
+
:prompt="editModalPrompt"
|
|
165
|
+
:saving="savingPrompt"
|
|
166
|
+
placeholder="Enter prompt text..."
|
|
167
|
+
@close="closeEditModal"
|
|
168
|
+
@save="handleSavePrompt"
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
</template>
|
|
172
|
+
|
|
173
|
+
<script setup lang="ts">
|
|
174
|
+
import { ref, computed, onMounted, watch, inject } from 'vue';
|
|
175
|
+
import StyleCard from './components/StyleCard.vue';
|
|
176
|
+
import ImageCard from './components/ImageCard.vue';
|
|
177
|
+
import EditModal from './components/EditModal.vue';
|
|
178
|
+
import { useDirectusApi, type Style, type GeneratedImageSet, type ImageVariant, type ImageModel, IMAGE_MODELS } from './composables/useDirectusApi';
|
|
179
|
+
import type { ImageItem } from './components/ImageCard.vue';
|
|
180
|
+
|
|
181
|
+
// Generate unique ID for radio group
|
|
182
|
+
const uid = Math.random().toString(36).substring(2, 9);
|
|
183
|
+
|
|
184
|
+
// Props from Directus interface options
|
|
185
|
+
const props = withDefaults(defineProps<{
|
|
186
|
+
value: any;
|
|
187
|
+
mode?: 'style-select' | 'image-review';
|
|
188
|
+
stylesCollection?: string;
|
|
189
|
+
primaryKey?: string | number;
|
|
190
|
+
collection?: string;
|
|
191
|
+
}>(), {
|
|
192
|
+
mode: 'style-select',
|
|
193
|
+
stylesCollection: 'ImageStyles',
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const emit = defineEmits<{
|
|
197
|
+
(e: 'input', value: any): void;
|
|
198
|
+
}>();
|
|
199
|
+
|
|
200
|
+
// Get injected values from Directus form context
|
|
201
|
+
// In Directus, the 'values' context is provided as a Ref containing all field values
|
|
202
|
+
const injectedValuesRef = inject<any>('values');
|
|
203
|
+
|
|
204
|
+
// Helper to get actual values (handles both ref and plain object)
|
|
205
|
+
function getValues(): Record<string, any> {
|
|
206
|
+
if (!injectedValuesRef) return {};
|
|
207
|
+
// If it's a Vue ref, access .value
|
|
208
|
+
if (injectedValuesRef.value !== undefined) {
|
|
209
|
+
return injectedValuesRef.value;
|
|
210
|
+
}
|
|
211
|
+
return injectedValuesRef;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Computed to get the lesson ID
|
|
215
|
+
const lessonId = computed(() => {
|
|
216
|
+
const vals = getValues();
|
|
217
|
+
return props.primaryKey || vals?.id || null;
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Computed to get the Description content
|
|
221
|
+
// Note: field name is 'description' (lowercase) in Directus schema
|
|
222
|
+
const lessonContent = computed(() => {
|
|
223
|
+
const vals = getValues();
|
|
224
|
+
// Try both 'description' (lowercase - actual field) and 'Description' (for compatibility)
|
|
225
|
+
const desc = vals?.description || vals?.Description || '';
|
|
226
|
+
return desc;
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// API composable
|
|
230
|
+
const {
|
|
231
|
+
loading,
|
|
232
|
+
error,
|
|
233
|
+
fetchStyles,
|
|
234
|
+
updateStylePrompt,
|
|
235
|
+
regenerateStyleExamples,
|
|
236
|
+
regeneratePlaceholderImages,
|
|
237
|
+
getFileUrl,
|
|
238
|
+
extractPlaceholders,
|
|
239
|
+
generateImagePrompts,
|
|
240
|
+
generateSingleImage,
|
|
241
|
+
updateContentWithImages,
|
|
242
|
+
updateLessonContent,
|
|
243
|
+
fetchLessonContent,
|
|
244
|
+
getImageVariants,
|
|
245
|
+
saveGeneratedImages,
|
|
246
|
+
deleteImageVariant,
|
|
247
|
+
} = useDirectusApi();
|
|
248
|
+
|
|
249
|
+
// State for mode management (can switch dynamically)
|
|
250
|
+
const currentMode = ref<'style-select' | 'image-review'>(props.mode);
|
|
251
|
+
|
|
252
|
+
// State for Style Selection mode
|
|
253
|
+
const styles = ref<Style[]>([]);
|
|
254
|
+
const selectedStyleId = ref<string | null>(null);
|
|
255
|
+
const regeneratingStyleId = ref<string | null>(null);
|
|
256
|
+
const selectedModel = ref<ImageModel>('flux-kontext-pro');
|
|
257
|
+
|
|
258
|
+
// State for Image Review mode
|
|
259
|
+
const imageItems = ref<ImageItem[]>([]);
|
|
260
|
+
const regeneratingPlaceholder = ref<string | null>(null);
|
|
261
|
+
|
|
262
|
+
// Processing state for generation flow
|
|
263
|
+
const processing = ref(false);
|
|
264
|
+
const processingMessage = ref('');
|
|
265
|
+
const progressPercent = ref(0);
|
|
266
|
+
|
|
267
|
+
// Edit Modal state
|
|
268
|
+
const editModalOpen = ref(false);
|
|
269
|
+
const editModalTitle = ref('');
|
|
270
|
+
const editModalPrompt = ref('');
|
|
271
|
+
const editingStyle = ref<Style | null>(null);
|
|
272
|
+
const editingImageItem = ref<ImageItem | null>(null);
|
|
273
|
+
const savingPrompt = ref(false);
|
|
274
|
+
|
|
275
|
+
// Computed
|
|
276
|
+
const canConfirm = computed(() => {
|
|
277
|
+
if (currentMode.value === 'style-select') {
|
|
278
|
+
return styles.value.length > 0;
|
|
279
|
+
}
|
|
280
|
+
return imageItems.value.length > 0;
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const isValid = computed(() => {
|
|
284
|
+
if (currentMode.value === 'style-select') {
|
|
285
|
+
return selectedStyleId.value !== null;
|
|
286
|
+
}
|
|
287
|
+
return imageItems.value.every(item => item.selected);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const confirmButtonText = computed(() => {
|
|
291
|
+
if (currentMode.value === 'style-select') {
|
|
292
|
+
return 'Generate Images';
|
|
293
|
+
}
|
|
294
|
+
return 'Confirm Selections';
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Initialize from value prop
|
|
298
|
+
function initFromValue() {
|
|
299
|
+
if (!props.value) return;
|
|
300
|
+
|
|
301
|
+
if (typeof props.value === 'object') {
|
|
302
|
+
if (props.value.selectedStyleId) {
|
|
303
|
+
selectedStyleId.value = props.value.selectedStyleId;
|
|
304
|
+
}
|
|
305
|
+
if (props.value.items && props.value.items.length > 0) {
|
|
306
|
+
imageItems.value = props.value.items;
|
|
307
|
+
currentMode.value = 'image-review';
|
|
308
|
+
}
|
|
309
|
+
if (props.value.currentMode) {
|
|
310
|
+
currentMode.value = props.value.currentMode;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Load styles for style-select mode
|
|
316
|
+
async function loadStyles() {
|
|
317
|
+
styles.value = await fetchStyles(props.stylesCollection);
|
|
318
|
+
|
|
319
|
+
// Auto-select first style if none selected
|
|
320
|
+
if (!selectedStyleId.value && styles.value.length > 0) {
|
|
321
|
+
selectedStyleId.value = styles.value[0].id;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Go back to style selection
|
|
326
|
+
function goBackToStyleSelect() {
|
|
327
|
+
currentMode.value = 'style-select';
|
|
328
|
+
emitValue();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Navigate to view generated images in ImageVariants collection
|
|
332
|
+
async function viewGeneratedImages() {
|
|
333
|
+
if (!lessonId.value) return;
|
|
334
|
+
|
|
335
|
+
processing.value = true;
|
|
336
|
+
processingMessage.value = 'Loading existing images...';
|
|
337
|
+
progressPercent.value = 10;
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
// Fetch existing ImageVariants for this lesson
|
|
341
|
+
const variants = await getImageVariants(String(lessonId.value));
|
|
342
|
+
|
|
343
|
+
progressPercent.value = 50;
|
|
344
|
+
|
|
345
|
+
if (!variants || variants.length === 0) {
|
|
346
|
+
error.value = 'No generated images found for this lesson. Generate images first.';
|
|
347
|
+
processing.value = false;
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Transform ImageVariants to ImageItem format
|
|
352
|
+
const existingItems: ImageItem[] = variants.map((variant: ImageVariant) => {
|
|
353
|
+
const images = variant.images || [];
|
|
354
|
+
// Get first two images (if available)
|
|
355
|
+
const img1 = images[0];
|
|
356
|
+
const img2 = images[1];
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
placeholder: variant.title || 'Untitled',
|
|
360
|
+
prompt1: img1?.prompt || '',
|
|
361
|
+
prompt2: img2?.prompt || img1?.prompt || '',
|
|
362
|
+
image1: img1?.image || null,
|
|
363
|
+
image2: img2?.image || null,
|
|
364
|
+
selected: img1?.image ? 1 : (img2?.image ? 2 : null),
|
|
365
|
+
};
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
progressPercent.value = 90;
|
|
369
|
+
|
|
370
|
+
// Set the image items and switch to review mode
|
|
371
|
+
imageItems.value = existingItems;
|
|
372
|
+
currentMode.value = 'image-review';
|
|
373
|
+
|
|
374
|
+
progressPercent.value = 100;
|
|
375
|
+
emitValue();
|
|
376
|
+
|
|
377
|
+
} catch (e: any) {
|
|
378
|
+
error.value = e.message || 'Failed to load existing images';
|
|
379
|
+
} finally {
|
|
380
|
+
processing.value = false;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Handle style selection
|
|
385
|
+
function handleStyleSelect(styleId: string) {
|
|
386
|
+
selectedStyleId.value = styleId;
|
|
387
|
+
emitValue();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Handle style regenerate
|
|
391
|
+
async function handleStyleRegenerate(style: Style) {
|
|
392
|
+
regeneratingStyleId.value = style.id;
|
|
393
|
+
|
|
394
|
+
const result = await regenerateStyleExamples(style.id, style.prompt, props.stylesCollection, selectedModel.value);
|
|
395
|
+
|
|
396
|
+
if (result) {
|
|
397
|
+
// Update local style data
|
|
398
|
+
const index = styles.value.findIndex(s => s.id === style.id);
|
|
399
|
+
if (index >= 0) {
|
|
400
|
+
styles.value[index] = {
|
|
401
|
+
...styles.value[index],
|
|
402
|
+
example_1: result.example_1,
|
|
403
|
+
example_2: result.example_2,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
regeneratingStyleId.value = null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Handle image selection in review mode
|
|
412
|
+
function handleImageSelect(placeholder: string, imageNum: 1 | 2) {
|
|
413
|
+
const item = imageItems.value.find(i => i.placeholder === placeholder);
|
|
414
|
+
if (item) {
|
|
415
|
+
item.selected = imageNum;
|
|
416
|
+
emitValue();
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Handle image regenerate
|
|
421
|
+
async function handleImageRegenerate(item: ImageItem) {
|
|
422
|
+
regeneratingPlaceholder.value = item.placeholder;
|
|
423
|
+
|
|
424
|
+
const result = await regeneratePlaceholderImages(item.prompt1, item.prompt2, selectedModel.value);
|
|
425
|
+
|
|
426
|
+
if (result) {
|
|
427
|
+
const index = imageItems.value.findIndex(i => i.placeholder === item.placeholder);
|
|
428
|
+
if (index >= 0) {
|
|
429
|
+
imageItems.value[index] = {
|
|
430
|
+
...imageItems.value[index],
|
|
431
|
+
image1: result.image1,
|
|
432
|
+
image2: result.image2,
|
|
433
|
+
selected: 1, // Reset selection to first image
|
|
434
|
+
};
|
|
435
|
+
emitValue();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
regeneratingPlaceholder.value = null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Handle image delete
|
|
443
|
+
async function handleImageDelete(item: ImageItem) {
|
|
444
|
+
if (!lessonId.value) return;
|
|
445
|
+
|
|
446
|
+
// Confirm deletion
|
|
447
|
+
if (!confirm(`Delete "${item.placeholder}" and its images? This cannot be undone.`)) {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
regeneratingPlaceholder.value = item.placeholder;
|
|
452
|
+
|
|
453
|
+
const success = await deleteImageVariant(String(lessonId.value), item.placeholder);
|
|
454
|
+
|
|
455
|
+
if (success) {
|
|
456
|
+
// Remove from local state
|
|
457
|
+
const index = imageItems.value.findIndex(i => i.placeholder === item.placeholder);
|
|
458
|
+
if (index >= 0) {
|
|
459
|
+
imageItems.value.splice(index, 1);
|
|
460
|
+
emitValue();
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
regeneratingPlaceholder.value = null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Edit Modal handlers
|
|
468
|
+
function openEditModal(style: Style) {
|
|
469
|
+
editingStyle.value = style;
|
|
470
|
+
editingImageItem.value = null;
|
|
471
|
+
editModalTitle.value = `Style: ${style.name}`;
|
|
472
|
+
editModalPrompt.value = style.prompt;
|
|
473
|
+
editModalOpen.value = true;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function openImageEditModal(item: ImageItem) {
|
|
477
|
+
editingStyle.value = null;
|
|
478
|
+
editingImageItem.value = item;
|
|
479
|
+
editModalTitle.value = `Image: ${item.placeholder}`;
|
|
480
|
+
editModalPrompt.value = item.prompt1; // Edit prompt1 (used for regeneration)
|
|
481
|
+
editModalOpen.value = true;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function closeEditModal() {
|
|
485
|
+
editModalOpen.value = false;
|
|
486
|
+
editingStyle.value = null;
|
|
487
|
+
editingImageItem.value = null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function handleSavePrompt(newPrompt: string) {
|
|
491
|
+
savingPrompt.value = true;
|
|
492
|
+
|
|
493
|
+
if (editingStyle.value) {
|
|
494
|
+
// Save style prompt
|
|
495
|
+
const success = await updateStylePrompt(
|
|
496
|
+
props.stylesCollection,
|
|
497
|
+
editingStyle.value.id,
|
|
498
|
+
newPrompt
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
if (success) {
|
|
502
|
+
const index = styles.value.findIndex(s => s.id === editingStyle.value!.id);
|
|
503
|
+
if (index >= 0) {
|
|
504
|
+
styles.value[index] = {
|
|
505
|
+
...styles.value[index],
|
|
506
|
+
prompt: newPrompt,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
closeEditModal();
|
|
510
|
+
}
|
|
511
|
+
} else if (editingImageItem.value) {
|
|
512
|
+
// Save image item prompt (local only - updates prompt1 used for regeneration)
|
|
513
|
+
const index = imageItems.value.findIndex(
|
|
514
|
+
i => i.placeholder === editingImageItem.value!.placeholder
|
|
515
|
+
);
|
|
516
|
+
if (index >= 0) {
|
|
517
|
+
imageItems.value[index] = {
|
|
518
|
+
...imageItems.value[index],
|
|
519
|
+
prompt1: newPrompt,
|
|
520
|
+
};
|
|
521
|
+
emitValue();
|
|
522
|
+
closeEditModal();
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
savingPrompt.value = false;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Emit value to Directus
|
|
530
|
+
function emitValue() {
|
|
531
|
+
emit('input', {
|
|
532
|
+
currentMode: currentMode.value,
|
|
533
|
+
selectedStyleId: selectedStyleId.value,
|
|
534
|
+
selectedStyle: styles.value.find(s => s.id === selectedStyleId.value) || null,
|
|
535
|
+
items: imageItems.value,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Handle confirm
|
|
540
|
+
async function handleConfirm() {
|
|
541
|
+
if (!isValid.value) return;
|
|
542
|
+
|
|
543
|
+
if (currentMode.value === 'style-select') {
|
|
544
|
+
// Phase 1: Generate images for placeholders
|
|
545
|
+
await runImageGenerationFlow();
|
|
546
|
+
} else {
|
|
547
|
+
// Phase 2: Apply selected images to content
|
|
548
|
+
await applySelectedImages();
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Run the full image generation flow
|
|
553
|
+
async function runImageGenerationFlow() {
|
|
554
|
+
const selectedStyle = styles.value.find(s => s.id === selectedStyleId.value);
|
|
555
|
+
|
|
556
|
+
// Provide specific error messages
|
|
557
|
+
if (!selectedStyle) {
|
|
558
|
+
error.value = 'No style selected. Please select an image style first.';
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (!lessonContent.value) {
|
|
563
|
+
error.value = 'No content available in the Description field. Add text with [image placeholders] first.';
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
processing.value = true;
|
|
568
|
+
progressPercent.value = 0;
|
|
569
|
+
|
|
570
|
+
try {
|
|
571
|
+
// Step 1: Generate image prompts using the Python service
|
|
572
|
+
processingMessage.value = 'Generating image prompts with AI...';
|
|
573
|
+
progressPercent.value = 10;
|
|
574
|
+
|
|
575
|
+
const promptResponse = await generateImagePrompts(selectedStyle.prompt, lessonContent.value);
|
|
576
|
+
|
|
577
|
+
if (!promptResponse || !promptResponse.images || promptResponse.images.length === 0) {
|
|
578
|
+
error.value = 'No [image placeholders] found in content. Add placeholders like [Rabbit image] to generate images.';
|
|
579
|
+
processing.value = false;
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
progressPercent.value = 30;
|
|
584
|
+
|
|
585
|
+
// Step 2: Generate 2 images per placeholder using the AI-generated prompts
|
|
586
|
+
processingMessage.value = `Generating images for ${promptResponse.images.length} placeholder(s)...`;
|
|
587
|
+
|
|
588
|
+
const newImageItems: ImageItem[] = [];
|
|
589
|
+
const progressPerItem = 60 / promptResponse.images.length;
|
|
590
|
+
|
|
591
|
+
for (let i = 0; i < promptResponse.images.length; i++) {
|
|
592
|
+
const imagePrompt = promptResponse.images[i];
|
|
593
|
+
processingMessage.value = `Generating images for "${imagePrompt.placeholder}"... (${i + 1}/${promptResponse.images.length})`;
|
|
594
|
+
|
|
595
|
+
// Use the 2 AI-generated prompts to create 2 different images
|
|
596
|
+
const prompt1 = imagePrompt.prompts[0] || `${selectedStyle.prompt}. Subject: ${imagePrompt.placeholder}`;
|
|
597
|
+
const prompt2 = imagePrompt.prompts[1] || prompt1;
|
|
598
|
+
|
|
599
|
+
// Generate one image per prompt (2 API calls total)
|
|
600
|
+
const [img1Result, img2Result] = await Promise.all([
|
|
601
|
+
generateSingleImage(prompt1, selectedModel.value),
|
|
602
|
+
generateSingleImage(prompt2, selectedModel.value),
|
|
603
|
+
]);
|
|
604
|
+
|
|
605
|
+
newImageItems.push({
|
|
606
|
+
placeholder: imagePrompt.placeholder,
|
|
607
|
+
prompt1: prompt1,
|
|
608
|
+
prompt2: prompt2,
|
|
609
|
+
image1: img1Result,
|
|
610
|
+
image2: img2Result,
|
|
611
|
+
selected: img1Result ? 1 : (img2Result ? 2 : null), // Default select first available image
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
progressPercent.value = 30 + (i + 1) * progressPerItem;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Step 3: Save generated images to database
|
|
618
|
+
processingMessage.value = 'Saving generated images...';
|
|
619
|
+
progressPercent.value = 90;
|
|
620
|
+
|
|
621
|
+
if (lessonId.value) {
|
|
622
|
+
await saveGeneratedImages(String(lessonId.value), newImageItems);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Step 4: Switch to image review mode
|
|
626
|
+
processingMessage.value = 'Preparing image review...';
|
|
627
|
+
progressPercent.value = 95;
|
|
628
|
+
|
|
629
|
+
imageItems.value = newImageItems;
|
|
630
|
+
currentMode.value = 'image-review';
|
|
631
|
+
|
|
632
|
+
progressPercent.value = 100;
|
|
633
|
+
emitValue();
|
|
634
|
+
|
|
635
|
+
} catch (e: any) {
|
|
636
|
+
error.value = e.message || 'Image generation failed';
|
|
637
|
+
} finally {
|
|
638
|
+
processing.value = false;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Apply selected images to content
|
|
643
|
+
async function applySelectedImages() {
|
|
644
|
+
if (!lessonId.value) {
|
|
645
|
+
error.value = 'No lesson ID available';
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Capture selections immediately (before any await) so we don't lose them
|
|
650
|
+
// if props.value watch overwrites imageItems during the async flow
|
|
651
|
+
const selections = imageItems.value
|
|
652
|
+
.filter(item => item.selected && (item.selected === 1 ? item.image1 : item.image2))
|
|
653
|
+
.map(item => ({
|
|
654
|
+
caption: item.placeholder,
|
|
655
|
+
fileId: item.selected === 1 ? item.image1! : item.image2!,
|
|
656
|
+
}));
|
|
657
|
+
|
|
658
|
+
if (selections.length === 0) {
|
|
659
|
+
error.value = 'No images selected. Please select at least one image.';
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
processing.value = true;
|
|
664
|
+
processingMessage.value = 'Fetching current content...';
|
|
665
|
+
progressPercent.value = 20;
|
|
666
|
+
|
|
667
|
+
try {
|
|
668
|
+
// Fetch the current content directly from the API (more reliable than inject context)
|
|
669
|
+
const currentContent = await fetchLessonContent(String(lessonId.value));
|
|
670
|
+
|
|
671
|
+
if (!currentContent) {
|
|
672
|
+
error.value = 'Could not fetch lesson content. Make sure Description field has content.';
|
|
673
|
+
processing.value = false;
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
progressPercent.value = 40;
|
|
678
|
+
processingMessage.value = 'Applying selected images to content...';
|
|
679
|
+
|
|
680
|
+
// Update content with HTML image tags (using captured selections)
|
|
681
|
+
const updatedContent = updateContentWithImages(currentContent, selections);
|
|
682
|
+
|
|
683
|
+
// Save back to lesson
|
|
684
|
+
progressPercent.value = 80;
|
|
685
|
+
const success = await updateLessonContent(String(lessonId.value), updatedContent);
|
|
686
|
+
|
|
687
|
+
if (success) {
|
|
688
|
+
progressPercent.value = 100;
|
|
689
|
+
processingMessage.value = 'Images applied successfully! Refreshing...';
|
|
690
|
+
|
|
691
|
+
// Reload the page to show updated content
|
|
692
|
+
setTimeout(() => {
|
|
693
|
+
window.location.reload();
|
|
694
|
+
}, 1000);
|
|
695
|
+
} else {
|
|
696
|
+
error.value = 'Failed to update lesson content';
|
|
697
|
+
}
|
|
698
|
+
} catch (e: any) {
|
|
699
|
+
error.value = e.message || 'Failed to apply images';
|
|
700
|
+
} finally {
|
|
701
|
+
processing.value = false;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Lifecycle
|
|
706
|
+
onMounted(() => {
|
|
707
|
+
initFromValue();
|
|
708
|
+
loadStyles();
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
// Watch for mode changes
|
|
712
|
+
watch(() => props.mode, (newMode) => {
|
|
713
|
+
if (newMode) {
|
|
714
|
+
currentMode.value = newMode;
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
// Watch for value changes from parent
|
|
719
|
+
watch(() => props.value, () => {
|
|
720
|
+
initFromValue();
|
|
721
|
+
}, { deep: true });
|
|
722
|
+
</script>
|
|
723
|
+
|
|
724
|
+
<style scoped>
|
|
725
|
+
.image-style-widget {
|
|
726
|
+
display: flex;
|
|
727
|
+
flex-direction: column;
|
|
728
|
+
gap: 20px;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
.widget__loading,
|
|
732
|
+
.widget__error,
|
|
733
|
+
.widget__empty {
|
|
734
|
+
display: flex;
|
|
735
|
+
flex-direction: column;
|
|
736
|
+
align-items: center;
|
|
737
|
+
justify-content: center;
|
|
738
|
+
gap: 12px;
|
|
739
|
+
padding: 40px 20px;
|
|
740
|
+
text-align: center;
|
|
741
|
+
color: var(--theme--foreground-subdued, #666);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
.widget__loading v-icon,
|
|
745
|
+
.widget__error v-icon,
|
|
746
|
+
.widget__empty v-icon {
|
|
747
|
+
font-size: 32px;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
.widget__error {
|
|
751
|
+
color: var(--theme--danger, #f44336);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
.widget__retry {
|
|
755
|
+
padding: 8px 16px;
|
|
756
|
+
border: 1px solid currentColor;
|
|
757
|
+
border-radius: 4px;
|
|
758
|
+
background: transparent;
|
|
759
|
+
color: inherit;
|
|
760
|
+
cursor: pointer;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
.widget__header {
|
|
764
|
+
padding-bottom: 16px;
|
|
765
|
+
border-bottom: 1px solid var(--theme--border-color-subdued, #e0e0e0);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
.widget__header-row {
|
|
769
|
+
display: flex;
|
|
770
|
+
justify-content: space-between;
|
|
771
|
+
align-items: flex-start;
|
|
772
|
+
gap: 16px;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
.widget__header-text {
|
|
776
|
+
flex: 1;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
.widget__model-selector {
|
|
780
|
+
display: flex;
|
|
781
|
+
flex-direction: column;
|
|
782
|
+
gap: 6px;
|
|
783
|
+
align-items: flex-end;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
.widget__model-label {
|
|
787
|
+
font-size: 12px;
|
|
788
|
+
font-weight: 500;
|
|
789
|
+
color: var(--theme--foreground-subdued, #666);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
.widget__model-buttons {
|
|
793
|
+
display: flex;
|
|
794
|
+
gap: 4px;
|
|
795
|
+
background: var(--theme--background-subdued, #f5f5f5);
|
|
796
|
+
padding: 4px;
|
|
797
|
+
border-radius: 6px;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
.widget__model-btn {
|
|
801
|
+
padding: 6px 12px;
|
|
802
|
+
border: none;
|
|
803
|
+
border-radius: 4px;
|
|
804
|
+
background: transparent;
|
|
805
|
+
color: var(--theme--foreground-subdued, #666);
|
|
806
|
+
font-size: 12px;
|
|
807
|
+
font-weight: 500;
|
|
808
|
+
cursor: pointer;
|
|
809
|
+
transition: all 0.2s ease;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
.widget__model-btn:hover {
|
|
813
|
+
color: var(--theme--foreground, #333);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
.widget__model-btn--active {
|
|
817
|
+
background: var(--theme--primary, #6644ff);
|
|
818
|
+
color: #fff;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
.widget__model-btn--active:hover {
|
|
822
|
+
color: #fff;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
.widget__title {
|
|
826
|
+
display: flex;
|
|
827
|
+
align-items: center;
|
|
828
|
+
gap: 8px;
|
|
829
|
+
margin: 0;
|
|
830
|
+
font-size: 18px;
|
|
831
|
+
font-weight: 600;
|
|
832
|
+
color: var(--theme--foreground, #333);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
.widget__subtitle {
|
|
836
|
+
margin: 4px 0 0 0;
|
|
837
|
+
font-size: 13px;
|
|
838
|
+
color: var(--theme--foreground-subdued, #666);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
.widget__list {
|
|
842
|
+
display: flex;
|
|
843
|
+
flex-direction: column;
|
|
844
|
+
gap: 12px;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
.widget__footer {
|
|
848
|
+
display: flex;
|
|
849
|
+
justify-content: flex-end;
|
|
850
|
+
padding-top: 16px;
|
|
851
|
+
border-top: 1px solid var(--theme--border-color-subdued, #e0e0e0);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
.widget__confirm {
|
|
855
|
+
display: inline-flex;
|
|
856
|
+
align-items: center;
|
|
857
|
+
gap: 8px;
|
|
858
|
+
padding: 12px 24px;
|
|
859
|
+
border: none;
|
|
860
|
+
border-radius: 6px;
|
|
861
|
+
background: var(--theme--primary, #6644ff);
|
|
862
|
+
color: #fff;
|
|
863
|
+
font-size: 14px;
|
|
864
|
+
font-weight: 600;
|
|
865
|
+
cursor: pointer;
|
|
866
|
+
transition: all 0.2s ease;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
.widget__confirm:hover:not(:disabled) {
|
|
870
|
+
background: var(--theme--primary-accent, #5533ee);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
.widget__confirm:disabled {
|
|
874
|
+
opacity: 0.5;
|
|
875
|
+
cursor: not-allowed;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
.widget__back {
|
|
879
|
+
display: inline-flex;
|
|
880
|
+
align-items: center;
|
|
881
|
+
gap: 8px;
|
|
882
|
+
padding: 12px 24px;
|
|
883
|
+
border: 1px solid var(--theme--border-color, #ccc);
|
|
884
|
+
border-radius: 6px;
|
|
885
|
+
background: transparent;
|
|
886
|
+
color: var(--theme--foreground, #333);
|
|
887
|
+
font-size: 14px;
|
|
888
|
+
font-weight: 500;
|
|
889
|
+
cursor: pointer;
|
|
890
|
+
transition: all 0.2s ease;
|
|
891
|
+
margin-right: auto;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
.widget__back:hover {
|
|
895
|
+
background: var(--theme--background-subdued, #f0f0f0);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
.widget__view-images {
|
|
899
|
+
display: inline-flex;
|
|
900
|
+
align-items: center;
|
|
901
|
+
gap: 8px;
|
|
902
|
+
padding: 12px 20px;
|
|
903
|
+
border: 2px solid var(--theme--primary, #6644ff);
|
|
904
|
+
border-radius: 6px;
|
|
905
|
+
background: transparent;
|
|
906
|
+
color: var(--theme--primary, #6644ff);
|
|
907
|
+
font-size: 14px;
|
|
908
|
+
font-weight: 500;
|
|
909
|
+
cursor: pointer;
|
|
910
|
+
transition: all 0.2s ease;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
.widget__view-images:hover {
|
|
914
|
+
background: var(--theme--primary-background, #f5f3ff);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
.widget__processing {
|
|
918
|
+
display: flex;
|
|
919
|
+
flex-direction: column;
|
|
920
|
+
align-items: center;
|
|
921
|
+
justify-content: center;
|
|
922
|
+
gap: 16px;
|
|
923
|
+
padding: 60px 20px;
|
|
924
|
+
text-align: center;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
.widget__progress {
|
|
928
|
+
display: flex;
|
|
929
|
+
align-items: center;
|
|
930
|
+
gap: 12px;
|
|
931
|
+
font-size: 16px;
|
|
932
|
+
font-weight: 500;
|
|
933
|
+
color: var(--theme--foreground, #333);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
.widget__progress-bar {
|
|
937
|
+
width: 100%;
|
|
938
|
+
max-width: 300px;
|
|
939
|
+
height: 6px;
|
|
940
|
+
background: var(--theme--border-color-subdued, #e0e0e0);
|
|
941
|
+
border-radius: 3px;
|
|
942
|
+
overflow: hidden;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
.widget__progress-fill {
|
|
946
|
+
height: 100%;
|
|
947
|
+
background: var(--theme--primary, #6644ff);
|
|
948
|
+
border-radius: 3px;
|
|
949
|
+
transition: width 0.3s ease;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
.spinning {
|
|
953
|
+
animation: spin 1s linear infinite;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
@keyframes spin {
|
|
957
|
+
from { transform: rotate(0deg); }
|
|
958
|
+
to { transform: rotate(360deg); }
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
.widget__empty p {
|
|
962
|
+
margin: 0;
|
|
963
|
+
font-weight: 600;
|
|
964
|
+
color: var(--theme--foreground, #333);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
.widget__empty span {
|
|
968
|
+
font-size: 13px;
|
|
969
|
+
}
|
|
970
|
+
</style>
|