@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,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>