sushilideaclan01 commited on
Commit
8158a5c
·
1 Parent(s): 7906542

Update frontend dependencies and enhance UI components for better user experience

Browse files

- Updated Tailwind CSS version to 4.1.18 in package.json and package-lock.json.
- Introduced a reusable GradientBadge component for consistent styling across the application.
- Improved layout and typography in DashboardAdCard and AdCard components for better readability and interaction.
- Added loading states and error handling in new AnglesPage and ConceptsPage components.
- Enhanced progress components in the generation workflow to provide better user feedback during ad generation.

frontend/app/{matrix → browse}/angles/page.tsx RENAMED
File without changes
frontend/app/{matrix → browse}/concepts/page.tsx RENAMED
File without changes
frontend/app/generate/batch/page.tsx CHANGED
@@ -1,9 +1,9 @@
1
  "use client";
2
 
3
- import React, { useState } from "react";
4
  import { BatchForm } from "@/components/generation/BatchForm";
5
- import { ProgressBar } from "@/components/ui/ProgressBar";
6
- import { Card, CardContent } from "@/components/ui/Card";
7
  import { generateBatch } from "@/lib/api/endpoints";
8
  import { toast } from "react-hot-toast";
9
  import { AdPreview } from "@/components/generation/AdPreview";
@@ -15,22 +15,57 @@ export default function BatchGeneratePage() {
15
  const [isGenerating, setIsGenerating] = useState(false);
16
  const [progress, setProgress] = useState(0);
17
  const [currentIndex, setCurrentIndex] = useState(0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  const handleGenerate = async (data: { niche: Niche; count: number; images_per_ad: number; image_model?: string | null }) => {
20
  setResults([]);
21
  setIsGenerating(true);
22
  setProgress(0);
23
  setCurrentIndex(0);
 
 
 
24
 
25
  // Estimate time per ad (roughly 30-60 seconds per ad)
26
  const estimatedTimePerAd = 45; // seconds
27
  const totalEstimatedTime = data.count * estimatedTimePerAd;
28
  let elapsedTime = 0;
 
29
  const progressInterval = 500; // Update every 500ms
30
 
31
- // Start progress simulation
32
  const progressIntervalId = setInterval(() => {
33
  elapsedTime += progressInterval / 1000; // Convert to seconds
 
 
 
 
 
 
 
 
34
  // Calculate progress: start at 5%, reach 90% by estimated time
35
  const progress = Math.min(90, 5 + (elapsedTime / totalEstimatedTime) * 85);
36
  setProgress(progress);
@@ -40,11 +75,13 @@ export default function BatchGeneratePage() {
40
  const result = await generateBatch(data);
41
  clearInterval(progressIntervalId);
42
  setResults(result.ads);
 
43
  setProgress(100);
44
  toast.success(`Successfully generated ${result.count} ads!`);
45
  } catch (error: any) {
46
  clearInterval(progressIntervalId);
47
  setProgress(0);
 
48
  toast.error(error.message || "Failed to generate batch");
49
  } finally {
50
  setIsGenerating(false);
@@ -70,38 +107,21 @@ export default function BatchGeneratePage() {
70
  </div>
71
 
72
  <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
 
 
 
 
 
 
 
 
 
 
 
73
  <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
74
  {/* Left Column - Form */}
75
  <div className="lg:col-span-1">
76
  <BatchForm onSubmit={handleGenerate} isLoading={isGenerating} />
77
-
78
- {isGenerating && (
79
- <Card variant="glass" className="mt-6 animate-scale-in">
80
- <CardContent className="pt-6">
81
- <div className="space-y-4">
82
- <div className="flex items-center justify-between">
83
- <div className="flex items-center space-x-3">
84
- <div className="relative">
85
- <div className="absolute inset-0 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-full animate-ping opacity-20"></div>
86
- <div className="relative bg-gradient-to-r from-blue-500 to-cyan-500 rounded-full p-2">
87
- <Sparkles className="h-5 w-5 text-white animate-pulse" />
88
- </div>
89
- </div>
90
- <div>
91
- <p className="font-semibold text-gray-900">Generating Batch Ads</p>
92
- <p className="text-sm text-gray-600">Creating multiple ad variations...</p>
93
- </div>
94
- </div>
95
- </div>
96
- <ProgressBar
97
- progress={progress}
98
- label="Batch Generation Progress"
99
- showPercentage={true}
100
- />
101
- </div>
102
- </CardContent>
103
- </Card>
104
- )}
105
  </div>
106
 
107
  {/* Right Column - Results */}
 
1
  "use client";
2
 
3
+ import React, { useState, useEffect } from "react";
4
  import { BatchForm } from "@/components/generation/BatchForm";
5
+ import { BatchProgressComponent } from "@/components/generation/BatchProgress";
6
+ import { Card } from "@/components/ui/Card";
7
  import { generateBatch } from "@/lib/api/endpoints";
8
  import { toast } from "react-hot-toast";
9
  import { AdPreview } from "@/components/generation/AdPreview";
 
15
  const [isGenerating, setIsGenerating] = useState(false);
16
  const [progress, setProgress] = useState(0);
17
  const [currentIndex, setCurrentIndex] = useState(0);
18
+ const [batchCount, setBatchCount] = useState(0);
19
+ const [batchImagesPerAd, setBatchImagesPerAd] = useState(1);
20
+ const [generationStartTime, setGenerationStartTime] = useState<number | null>(null);
21
+
22
+ // Request notification permission
23
+ useEffect(() => {
24
+ if ("Notification" in window && Notification.permission === "default") {
25
+ Notification.requestPermission();
26
+ }
27
+ }, []);
28
+
29
+ // Show notification when batch completes
30
+ useEffect(() => {
31
+ if (progress >= 100 && results.length > 0) {
32
+ if ("Notification" in window && Notification.permission === "granted") {
33
+ new Notification("Batch Generation Complete!", {
34
+ body: `Successfully generated ${results.length} ads!`,
35
+ icon: "/favicon.ico",
36
+ tag: "batch-complete",
37
+ });
38
+ }
39
+ }
40
+ }, [progress, results.length]);
41
 
42
  const handleGenerate = async (data: { niche: Niche; count: number; images_per_ad: number; image_model?: string | null }) => {
43
  setResults([]);
44
  setIsGenerating(true);
45
  setProgress(0);
46
  setCurrentIndex(0);
47
+ setBatchCount(data.count);
48
+ setBatchImagesPerAd(data.images_per_ad);
49
+ setGenerationStartTime(Date.now());
50
 
51
  // Estimate time per ad (roughly 30-60 seconds per ad)
52
  const estimatedTimePerAd = 45; // seconds
53
  const totalEstimatedTime = data.count * estimatedTimePerAd;
54
  let elapsedTime = 0;
55
+ let currentAdIndex = 0;
56
  const progressInterval = 500; // Update every 500ms
57
 
58
+ // Start progress simulation with ad tracking
59
  const progressIntervalId = setInterval(() => {
60
  elapsedTime += progressInterval / 1000; // Convert to seconds
61
+
62
+ // Estimate which ad we're on based on elapsed time
63
+ const estimatedAdIndex = Math.min(data.count - 1, Math.floor(elapsedTime / estimatedTimePerAd));
64
+ if (estimatedAdIndex !== currentAdIndex) {
65
+ currentAdIndex = estimatedAdIndex;
66
+ setCurrentIndex(currentAdIndex);
67
+ }
68
+
69
  // Calculate progress: start at 5%, reach 90% by estimated time
70
  const progress = Math.min(90, 5 + (elapsedTime / totalEstimatedTime) * 85);
71
  setProgress(progress);
 
75
  const result = await generateBatch(data);
76
  clearInterval(progressIntervalId);
77
  setResults(result.ads);
78
+ setCurrentIndex(data.count - 1); // Set to last ad
79
  setProgress(100);
80
  toast.success(`Successfully generated ${result.count} ads!`);
81
  } catch (error: any) {
82
  clearInterval(progressIntervalId);
83
  setProgress(0);
84
+ setCurrentIndex(0);
85
  toast.error(error.message || "Failed to generate batch");
86
  } finally {
87
  setIsGenerating(false);
 
107
  </div>
108
 
109
  <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
110
+ {/* Progress Component - Sticky at top */}
111
+ {isGenerating && (
112
+ <BatchProgressComponent
113
+ progress={progress}
114
+ currentIndex={currentIndex}
115
+ totalCount={batchCount}
116
+ imagesPerAd={batchImagesPerAd}
117
+ generationStartTime={generationStartTime}
118
+ />
119
+ )}
120
+
121
  <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
122
  {/* Left Column - Form */}
123
  <div className="lg:col-span-1">
124
  <BatchForm onSubmit={handleGenerate} isLoading={isGenerating} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  </div>
126
 
127
  {/* Right Column - Results */}
frontend/app/generate/matrix/page.tsx CHANGED
@@ -1,6 +1,6 @@
1
  "use client";
2
 
3
- import React, { useState } from "react";
4
  import { AngleSelector } from "@/components/matrix/AngleSelector";
5
  import { ConceptSelector } from "@/components/matrix/ConceptSelector";
6
  import { GenerationForm } from "@/components/generation/GenerationForm";
@@ -22,10 +22,12 @@ export default function MatrixGeneratePage() {
22
  currentGeneration,
23
  progress,
24
  isGenerating,
 
25
  setCurrentGeneration,
26
  setProgress,
27
  setIsGenerating,
28
  setError,
 
29
  reset,
30
  } = useGenerationStore();
31
 
@@ -35,6 +37,31 @@ export default function MatrixGeneratePage() {
35
  const [numImages, setNumImages] = useState(1);
36
  const [imageModel, setImageModel] = useState<string | null>(null);
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  const handleGenerate = async () => {
39
  if (!selectedAngle || !selectedConcept) {
40
  toast.error("Please select both an angle and a concept");
@@ -43,6 +70,7 @@ export default function MatrixGeneratePage() {
43
 
44
  reset();
45
  setIsGenerating(true);
 
46
  setProgress({
47
  step: "copy",
48
  progress: 10,
@@ -98,18 +126,25 @@ export default function MatrixGeneratePage() {
98
  </div>
99
 
100
  <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
 
 
 
 
 
101
  <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
102
  {/* Left Column - Selection */}
103
  <div className="lg:col-span-1 space-y-6">
104
- <Card variant="glass" className="animate-slide-in">
105
  <CardHeader>
106
- <CardTitle>Configuration</CardTitle>
 
 
107
  </CardHeader>
108
  <CardContent className="space-y-4">
109
  <div>
110
  <label className="block text-sm font-semibold text-gray-700 mb-2">Niche</label>
111
  <select
112
- className="w-full px-4 py-3 rounded-xl border-2 border-gray-300 bg-white/80 backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-250"
113
  value={niche}
114
  onChange={(e) => setNiche(e.target.value as Niche)}
115
  >
@@ -121,7 +156,7 @@ export default function MatrixGeneratePage() {
121
  <div>
122
  <label className="block text-sm font-semibold text-gray-700 mb-2">Image Model</label>
123
  <select
124
- className="w-full px-4 py-3 rounded-xl border-2 border-gray-300 bg-white/80 backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-250"
125
  value={imageModel || ""}
126
  onChange={(e) => setImageModel(e.target.value || null)}
127
  >
@@ -135,14 +170,17 @@ export default function MatrixGeneratePage() {
135
 
136
  <div>
137
  <label className="block text-sm font-semibold text-gray-700 mb-2">
138
- Number of Images: <span className="text-blue-600 font-bold">{numImages}</span>
139
  </label>
140
  <input
141
  type="range"
142
  min="1"
143
  max="5"
144
  step="1"
145
- className="w-full accent-blue-500"
 
 
 
146
  value={numImages}
147
  onChange={(e) => setNumImages(Number(e.target.value))}
148
  />
@@ -175,10 +213,6 @@ export default function MatrixGeneratePage() {
175
  >
176
  Generate Ad
177
  </Button>
178
-
179
- {isGenerating && (
180
- <GenerationProgressComponent progress={progress} />
181
- )}
182
  </div>
183
 
184
  {/* Right Column - Preview */}
 
1
  "use client";
2
 
3
+ import React, { useState, useEffect } from "react";
4
  import { AngleSelector } from "@/components/matrix/AngleSelector";
5
  import { ConceptSelector } from "@/components/matrix/ConceptSelector";
6
  import { GenerationForm } from "@/components/generation/GenerationForm";
 
22
  currentGeneration,
23
  progress,
24
  isGenerating,
25
+ generationStartTime,
26
  setCurrentGeneration,
27
  setProgress,
28
  setIsGenerating,
29
  setError,
30
+ setGenerationStartTime,
31
  reset,
32
  } = useGenerationStore();
33
 
 
37
  const [numImages, setNumImages] = useState(1);
38
  const [imageModel, setImageModel] = useState<string | null>(null);
39
 
40
+ // Request notification permission and show notification when generation completes
41
+ const showNotification = (title: string, body: string) => {
42
+ if ("Notification" in window && Notification.permission === "granted") {
43
+ new Notification(title, {
44
+ body,
45
+ icon: "/favicon.ico",
46
+ tag: "generation-complete",
47
+ });
48
+ }
49
+ };
50
+
51
+ // Request notification permission on mount
52
+ useEffect(() => {
53
+ if ("Notification" in window && Notification.permission === "default") {
54
+ Notification.requestPermission();
55
+ }
56
+ }, []);
57
+
58
+ // Show notification when generation completes
59
+ useEffect(() => {
60
+ if (progress.step === "complete" && currentGeneration) {
61
+ showNotification("Ad Generated Successfully!", "Your ad is ready to view.");
62
+ }
63
+ }, [progress.step, currentGeneration]);
64
+
65
  const handleGenerate = async () => {
66
  if (!selectedAngle || !selectedConcept) {
67
  toast.error("Please select both an angle and a concept");
 
70
 
71
  reset();
72
  setIsGenerating(true);
73
+ setGenerationStartTime(Date.now());
74
  setProgress({
75
  step: "copy",
76
  progress: 10,
 
126
  </div>
127
 
128
  <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
129
+ {/* Progress Component - Sticky at top */}
130
+ {isGenerating && (
131
+ <GenerationProgressComponent progress={progress} generationStartTime={generationStartTime} />
132
+ )}
133
+
134
  <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
135
  {/* Left Column - Selection */}
136
  <div className="lg:col-span-1 space-y-6">
137
+ <Card variant="glass" className="animate-slide-in border-2 border-transparent hover:border-blue-200/50 transition-all duration-300">
138
  <CardHeader>
139
+ <CardTitle className="bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">
140
+ Configuration
141
+ </CardTitle>
142
  </CardHeader>
143
  <CardContent className="space-y-4">
144
  <div>
145
  <label className="block text-sm font-semibold text-gray-700 mb-2">Niche</label>
146
  <select
147
+ className="w-full px-4 py-3 rounded-xl border-2 border-gray-300 bg-white/80 backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-250 hover:border-blue-300"
148
  value={niche}
149
  onChange={(e) => setNiche(e.target.value as Niche)}
150
  >
 
156
  <div>
157
  <label className="block text-sm font-semibold text-gray-700 mb-2">Image Model</label>
158
  <select
159
+ className="w-full px-4 py-3 rounded-xl border-2 border-gray-300 bg-white/80 backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500 transition-all duration-250 hover:border-cyan-300"
160
  value={imageModel || ""}
161
  onChange={(e) => setImageModel(e.target.value || null)}
162
  >
 
170
 
171
  <div>
172
  <label className="block text-sm font-semibold text-gray-700 mb-2">
173
+ Number of Images: <span className="text-cyan-600 font-bold">{numImages}</span>
174
  </label>
175
  <input
176
  type="range"
177
  min="1"
178
  max="5"
179
  step="1"
180
+ className="w-full accent-gradient-to-r from-blue-500 to-cyan-500"
181
+ style={{
182
+ accentColor: '#06b6d4'
183
+ }}
184
  value={numImages}
185
  onChange={(e) => setNumImages(Number(e.target.value))}
186
  />
 
213
  >
214
  Generate Ad
215
  </Button>
 
 
 
 
216
  </div>
217
 
218
  {/* Right Column - Preview */}
frontend/app/generate/page.tsx CHANGED
@@ -1,10 +1,11 @@
1
  "use client";
2
 
3
- import React, { useState } from "react";
4
  import { GenerationForm } from "@/components/generation/GenerationForm";
5
  import { BatchForm } from "@/components/generation/BatchForm";
6
  import { ExtensiveForm } from "@/components/generation/ExtensiveForm";
7
  import { GenerationProgressComponent } from "@/components/generation/GenerationProgress";
 
8
  import { AdPreview } from "@/components/generation/AdPreview";
9
  import { AngleSelector } from "@/components/matrix/AngleSelector";
10
  import { ConceptSelector } from "@/components/matrix/ConceptSelector";
@@ -29,23 +30,53 @@ export default function GeneratePage() {
29
  const [batchResults, setBatchResults] = useState<GenerateResponse[]>([]);
30
  const [currentBatchIndex, setCurrentBatchIndex] = useState(0);
31
  const [batchProgress, setBatchProgress] = useState(0);
 
 
32
 
33
  const {
34
  currentGeneration,
35
  progress,
36
  isGenerating,
 
37
  setCurrentGeneration,
38
  setProgress,
39
  setIsGenerating,
40
  setError,
 
41
  reset,
42
  } = useGenerationStore();
43
 
44
  const { selectedAngle, selectedConcept, setSelectedAngle, setSelectedConcept } = useMatrixStore();
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  const handleStandardGenerate = async (data: { niche: Niche; num_images: number; image_model?: string | null }) => {
47
  reset();
48
  setIsGenerating(true);
 
49
  setProgress({
50
  step: "copy",
51
  progress: 10,
@@ -104,6 +135,7 @@ export default function GeneratePage() {
104
 
105
  reset();
106
  setIsGenerating(true);
 
107
  setProgress({
108
  step: "copy",
109
  progress: 10,
@@ -143,18 +175,30 @@ export default function GeneratePage() {
143
  const handleBatchGenerate = async (data: { niche: Niche; count: number; images_per_ad: number; image_model?: string | null }) => {
144
  setBatchResults([]);
145
  setIsGenerating(true);
 
146
  setBatchProgress(0);
147
  setCurrentBatchIndex(0);
 
 
148
 
149
  // Estimate time per ad (roughly 30-60 seconds per ad)
150
  const estimatedTimePerAd = 45; // seconds
151
  const totalEstimatedTime = data.count * estimatedTimePerAd;
152
  let elapsedTime = 0;
 
153
  const progressInterval = 500; // Update every 500ms
154
 
155
- // Start progress simulation
156
  const progressIntervalId = setInterval(() => {
157
  elapsedTime += progressInterval / 1000; // Convert to seconds
 
 
 
 
 
 
 
 
158
  // Calculate progress: start at 5%, reach 90% by estimated time
159
  const progress = Math.min(90, 5 + (elapsedTime / totalEstimatedTime) * 85);
160
  setBatchProgress(progress);
@@ -164,11 +208,23 @@ export default function GeneratePage() {
164
  const result = await generateBatch(data);
165
  clearInterval(progressIntervalId);
166
  setBatchResults(result.ads);
 
167
  setBatchProgress(100);
 
 
 
 
 
 
 
 
 
 
168
  toast.success(`Successfully generated ${result.count} ads!`);
169
  } catch (error: any) {
170
  clearInterval(progressIntervalId);
171
  setBatchProgress(0);
 
172
  toast.error(error.message || "Failed to generate batch");
173
  } finally {
174
  setIsGenerating(false);
@@ -185,6 +241,7 @@ export default function GeneratePage() {
185
  }) => {
186
  reset();
187
  setIsGenerating(true);
 
188
  setProgress({
189
  step: "copy",
190
  progress: 10,
@@ -316,6 +373,23 @@ export default function GeneratePage() {
316
  </div>
317
 
318
  <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
320
  {/* Left Column - Form and Configuration */}
321
  <div className="lg:col-span-1 space-y-6">
@@ -327,12 +401,6 @@ export default function GeneratePage() {
327
  isLoading={isGenerating}
328
  />
329
  </div>
330
-
331
- {isGenerating && (
332
- <div className="animate-scale-in">
333
- <GenerationProgressComponent progress={progress} />
334
- </div>
335
- )}
336
  </>
337
  ) : mode === "matrix" ? (
338
  <>
@@ -413,10 +481,6 @@ export default function GeneratePage() {
413
  >
414
  Generate Ad
415
  </Button>
416
-
417
- {isGenerating && (
418
- <GenerationProgressComponent progress={progress} />
419
- )}
420
  </>
421
  ) : mode === "batch" ? (
422
  <>
@@ -426,34 +490,6 @@ export default function GeneratePage() {
426
  isLoading={isGenerating}
427
  />
428
  </div>
429
-
430
- {isGenerating && (
431
- <Card variant="glass" className="mt-6 animate-scale-in">
432
- <CardContent className="pt-6">
433
- <div className="space-y-4">
434
- <div className="flex items-center justify-between">
435
- <div className="flex items-center space-x-3">
436
- <div className="relative">
437
- <div className="absolute inset-0 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-full animate-ping opacity-20"></div>
438
- <div className="relative bg-gradient-to-r from-blue-500 to-cyan-500 rounded-full p-2">
439
- <Sparkles className="h-5 w-5 text-white animate-pulse" />
440
- </div>
441
- </div>
442
- <div>
443
- <p className="font-semibold text-gray-900">Generating Batch Ads</p>
444
- <p className="text-sm text-gray-600">Creating multiple ad variations...</p>
445
- </div>
446
- </div>
447
- </div>
448
- <ProgressBar
449
- progress={batchProgress}
450
- label="Batch Generation Progress"
451
- showPercentage={true}
452
- />
453
- </div>
454
- </CardContent>
455
- </Card>
456
- )}
457
  </>
458
  ) : (
459
  <>
@@ -463,12 +499,6 @@ export default function GeneratePage() {
463
  isLoading={isGenerating}
464
  />
465
  </div>
466
-
467
- {isGenerating && (
468
- <div className="animate-scale-in">
469
- <GenerationProgressComponent progress={progress} />
470
- </div>
471
- )}
472
  </>
473
  )}
474
  </div>
 
1
  "use client";
2
 
3
+ import React, { useState, useEffect } from "react";
4
  import { GenerationForm } from "@/components/generation/GenerationForm";
5
  import { BatchForm } from "@/components/generation/BatchForm";
6
  import { ExtensiveForm } from "@/components/generation/ExtensiveForm";
7
  import { GenerationProgressComponent } from "@/components/generation/GenerationProgress";
8
+ import { BatchProgressComponent } from "@/components/generation/BatchProgress";
9
  import { AdPreview } from "@/components/generation/AdPreview";
10
  import { AngleSelector } from "@/components/matrix/AngleSelector";
11
  import { ConceptSelector } from "@/components/matrix/ConceptSelector";
 
30
  const [batchResults, setBatchResults] = useState<GenerateResponse[]>([]);
31
  const [currentBatchIndex, setCurrentBatchIndex] = useState(0);
32
  const [batchProgress, setBatchProgress] = useState(0);
33
+ const [batchCount, setBatchCount] = useState(0);
34
+ const [batchImagesPerAd, setBatchImagesPerAd] = useState(1);
35
 
36
  const {
37
  currentGeneration,
38
  progress,
39
  isGenerating,
40
+ generationStartTime,
41
  setCurrentGeneration,
42
  setProgress,
43
  setIsGenerating,
44
  setError,
45
+ setGenerationStartTime,
46
  reset,
47
  } = useGenerationStore();
48
 
49
  const { selectedAngle, selectedConcept, setSelectedAngle, setSelectedConcept } = useMatrixStore();
50
 
51
+ // Request notification permission and show notification when generation completes
52
+ const showNotification = (title: string, body: string) => {
53
+ if ("Notification" in window && Notification.permission === "granted") {
54
+ new Notification(title, {
55
+ body,
56
+ icon: "/favicon.ico",
57
+ tag: "generation-complete",
58
+ });
59
+ }
60
+ };
61
+
62
+ // Request notification permission on mount
63
+ useEffect(() => {
64
+ if ("Notification" in window && Notification.permission === "default") {
65
+ Notification.requestPermission();
66
+ }
67
+ }, []);
68
+
69
+ // Show notification when generation completes
70
+ useEffect(() => {
71
+ if (progress.step === "complete" && currentGeneration) {
72
+ showNotification("Ad Generated Successfully!", "Your ad is ready to view.");
73
+ }
74
+ }, [progress.step, currentGeneration]);
75
+
76
  const handleStandardGenerate = async (data: { niche: Niche; num_images: number; image_model?: string | null }) => {
77
  reset();
78
  setIsGenerating(true);
79
+ setGenerationStartTime(Date.now());
80
  setProgress({
81
  step: "copy",
82
  progress: 10,
 
135
 
136
  reset();
137
  setIsGenerating(true);
138
+ setGenerationStartTime(Date.now());
139
  setProgress({
140
  step: "copy",
141
  progress: 10,
 
175
  const handleBatchGenerate = async (data: { niche: Niche; count: number; images_per_ad: number; image_model?: string | null }) => {
176
  setBatchResults([]);
177
  setIsGenerating(true);
178
+ setGenerationStartTime(Date.now());
179
  setBatchProgress(0);
180
  setCurrentBatchIndex(0);
181
+ setBatchCount(data.count);
182
+ setBatchImagesPerAd(data.images_per_ad);
183
 
184
  // Estimate time per ad (roughly 30-60 seconds per ad)
185
  const estimatedTimePerAd = 45; // seconds
186
  const totalEstimatedTime = data.count * estimatedTimePerAd;
187
  let elapsedTime = 0;
188
+ let currentAdIndex = 0;
189
  const progressInterval = 500; // Update every 500ms
190
 
191
+ // Start progress simulation with ad tracking
192
  const progressIntervalId = setInterval(() => {
193
  elapsedTime += progressInterval / 1000; // Convert to seconds
194
+
195
+ // Estimate which ad we're on based on elapsed time
196
+ const estimatedAdIndex = Math.min(data.count - 1, Math.floor(elapsedTime / estimatedTimePerAd));
197
+ if (estimatedAdIndex !== currentAdIndex) {
198
+ currentAdIndex = estimatedAdIndex;
199
+ setCurrentBatchIndex(currentAdIndex);
200
+ }
201
+
202
  // Calculate progress: start at 5%, reach 90% by estimated time
203
  const progress = Math.min(90, 5 + (elapsedTime / totalEstimatedTime) * 85);
204
  setBatchProgress(progress);
 
208
  const result = await generateBatch(data);
209
  clearInterval(progressIntervalId);
210
  setBatchResults(result.ads);
211
+ setCurrentBatchIndex(data.count - 1); // Set to last ad
212
  setBatchProgress(100);
213
+
214
+ // Show notification
215
+ if ("Notification" in window && Notification.permission === "granted") {
216
+ new Notification("Batch Generation Complete!", {
217
+ body: `Successfully generated ${result.count} ads!`,
218
+ icon: "/favicon.ico",
219
+ tag: "batch-complete",
220
+ });
221
+ }
222
+
223
  toast.success(`Successfully generated ${result.count} ads!`);
224
  } catch (error: any) {
225
  clearInterval(progressIntervalId);
226
  setBatchProgress(0);
227
+ setCurrentBatchIndex(0);
228
  toast.error(error.message || "Failed to generate batch");
229
  } finally {
230
  setIsGenerating(false);
 
241
  }) => {
242
  reset();
243
  setIsGenerating(true);
244
+ setGenerationStartTime(Date.now());
245
  setProgress({
246
  step: "copy",
247
  progress: 10,
 
373
  </div>
374
 
375
  <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
376
+ {/* Progress Component - Sticky at top */}
377
+ {isGenerating && (
378
+ <>
379
+ {mode === "batch" ? (
380
+ <BatchProgressComponent
381
+ progress={batchProgress}
382
+ currentIndex={currentBatchIndex}
383
+ totalCount={batchCount}
384
+ imagesPerAd={batchImagesPerAd}
385
+ generationStartTime={generationStartTime}
386
+ />
387
+ ) : (
388
+ <GenerationProgressComponent progress={progress} generationStartTime={generationStartTime} />
389
+ )}
390
+ </>
391
+ )}
392
+
393
  <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
394
  {/* Left Column - Form and Configuration */}
395
  <div className="lg:col-span-1 space-y-6">
 
401
  isLoading={isGenerating}
402
  />
403
  </div>
 
 
 
 
 
 
404
  </>
405
  ) : mode === "matrix" ? (
406
  <>
 
481
  >
482
  Generate Ad
483
  </Button>
 
 
 
 
484
  </>
485
  ) : mode === "batch" ? (
486
  <>
 
490
  isLoading={isGenerating}
491
  />
492
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
  </>
494
  ) : (
495
  <>
 
499
  isLoading={isGenerating}
500
  />
501
  </div>
 
 
 
 
 
 
502
  </>
503
  )}
504
  </div>
frontend/app/matrix/page.tsx CHANGED
@@ -27,78 +27,98 @@ export default function MatrixPage() {
27
 
28
  <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
29
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
30
- <Card variant="glass" className="animate-scale-in" style={{ animationDelay: "0.1s" }}>
31
- <CardHeader>
32
- <div className="p-3 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-500 w-fit mb-3">
33
- <Layers className="h-6 w-6 text-white" />
34
- </div>
35
- <CardTitle>Generate with Matrix</CardTitle>
36
- <CardDescription>
37
- Select specific angle and concept combinations
38
- </CardDescription>
39
- </CardHeader>
40
- <CardContent>
41
- <Link href="/generate/matrix">
42
- <Button variant="primary" className="w-full group">
 
 
 
 
43
  <Sparkles className="h-4 w-4 mr-2 group-hover:rotate-12 transition-transform duration-300" />
44
  Generate Ad
45
  </Button>
46
- </Link>
47
- </CardContent>
48
  </Card>
49
 
50
- <Card variant="glass" className="animate-scale-in" style={{ animationDelay: "0.2s" }}>
51
- <CardHeader>
52
- <div className="p-3 rounded-xl bg-gradient-to-br from-cyan-500 to-pink-600 w-fit mb-3">
53
- <Search className="h-6 w-6 text-white" />
54
- </div>
55
- <CardTitle>Browse Angles</CardTitle>
56
- <CardDescription>
57
- Explore all 100 available angles
58
- </CardDescription>
59
- </CardHeader>
60
- <CardContent>
61
- <Link href="/matrix/angles">
62
- <Button variant="outline" className="w-full">View Angles</Button>
63
- </Link>
64
- </CardContent>
 
 
 
 
 
 
65
  </Card>
66
 
67
- <Card variant="glass" className="animate-scale-in" style={{ animationDelay: "0.3s" }}>
68
- <CardHeader>
69
- <div className="p-3 rounded-xl bg-gradient-to-br from-green-500 to-emerald-600 w-fit mb-3">
70
- <Search className="h-6 w-6 text-white" />
71
- </div>
72
- <CardTitle>Browse Concepts</CardTitle>
73
- <CardDescription>
74
- Explore all 100 available concepts
75
- </CardDescription>
76
- </CardHeader>
77
- <CardContent>
78
- <Link href="/matrix/concepts">
79
- <Button variant="outline" className="w-full">View Concepts</Button>
80
- </Link>
81
- </CardContent>
 
 
 
 
 
 
82
  </Card>
83
 
84
- <Card variant="glass" className="md:col-span-2 lg:col-span-3 animate-scale-in" style={{ animationDelay: "0.4s" }}>
85
- <CardHeader>
86
- <div className="p-3 rounded-xl bg-gradient-to-br from-orange-500 to-pink-600 w-fit mb-3">
87
- <TestTube className="h-6 w-6 text-white" />
88
- </div>
89
- <CardTitle>Testing Matrix Builder</CardTitle>
90
- <CardDescription>
91
- Generate systematic testing matrices for optimization
92
- </CardDescription>
93
- </CardHeader>
94
- <CardContent>
95
- <Link href="/matrix/testing">
96
- <Button variant="secondary" className="w-full group">
 
 
 
 
97
  <TestTube className="h-4 w-4 mr-2 group-hover:rotate-12 transition-transform duration-300" />
98
  Build Testing Matrix
99
  </Button>
100
- </Link>
101
- </CardContent>
102
  </Card>
103
  </div>
104
  </div>
 
27
 
28
  <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
29
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
30
+ <Card
31
+ variant="glass"
32
+ className="animate-scale-in hover:scale-105 transition-all duration-300 group cursor-pointer"
33
+ style={{ animationDelay: "0.1s" }}
34
+ >
35
+ <Link href="/generate/matrix" className="block">
36
+ <CardHeader>
37
+ <div className="p-3 rounded-xl bg-gradient-to-br from-blue-500 via-blue-600 to-cyan-500 w-fit mb-3 shadow-lg group-hover:shadow-xl group-hover:scale-110 transition-all duration-300">
38
+ <Layers className="h-6 w-6 text-white" />
39
+ </div>
40
+ <CardTitle className="group-hover:text-blue-600 transition-colors">Generate with Matrix</CardTitle>
41
+ <CardDescription>
42
+ Select specific angle and concept combinations
43
+ </CardDescription>
44
+ </CardHeader>
45
+ <CardContent>
46
+ <Button variant="primary" className="w-full group-hover:shadow-lg transition-all">
47
  <Sparkles className="h-4 w-4 mr-2 group-hover:rotate-12 transition-transform duration-300" />
48
  Generate Ad
49
  </Button>
50
+ </CardContent>
51
+ </Link>
52
  </Card>
53
 
54
+ <Card
55
+ variant="glass"
56
+ className="animate-scale-in hover:scale-105 transition-all duration-300 group cursor-pointer"
57
+ style={{ animationDelay: "0.2s" }}
58
+ >
59
+ <Link href="/browse/angles" className="block">
60
+ <CardHeader>
61
+ <div className="p-3 rounded-xl bg-gradient-to-br from-purple-500 via-pink-500 to-rose-500 w-fit mb-3 shadow-lg group-hover:shadow-xl group-hover:scale-110 transition-all duration-300">
62
+ <Search className="h-6 w-6 text-white" />
63
+ </div>
64
+ <CardTitle className="group-hover:text-purple-600 transition-colors">Browse Angles</CardTitle>
65
+ <CardDescription>
66
+ Explore all 100 available angles
67
+ </CardDescription>
68
+ </CardHeader>
69
+ <CardContent>
70
+ <Button variant="outline" className="w-full border-purple-200 hover:border-purple-400 hover:bg-purple-50 transition-colors">
71
+ View Angles
72
+ </Button>
73
+ </CardContent>
74
+ </Link>
75
  </Card>
76
 
77
+ <Card
78
+ variant="glass"
79
+ className="animate-scale-in hover:scale-105 transition-all duration-300 group cursor-pointer"
80
+ style={{ animationDelay: "0.3s" }}
81
+ >
82
+ <Link href="/browse/concepts" className="block">
83
+ <CardHeader>
84
+ <div className="p-3 rounded-xl bg-gradient-to-br from-emerald-500 via-teal-500 to-cyan-500 w-fit mb-3 shadow-lg group-hover:shadow-xl group-hover:scale-110 transition-all duration-300">
85
+ <Search className="h-6 w-6 text-white" />
86
+ </div>
87
+ <CardTitle className="group-hover:text-emerald-600 transition-colors">Browse Concepts</CardTitle>
88
+ <CardDescription>
89
+ Explore all 100 available concepts
90
+ </CardDescription>
91
+ </CardHeader>
92
+ <CardContent>
93
+ <Button variant="outline" className="w-full border-emerald-200 hover:border-emerald-400 hover:bg-emerald-50 transition-colors">
94
+ View Concepts
95
+ </Button>
96
+ </CardContent>
97
+ </Link>
98
  </Card>
99
 
100
+ <Card
101
+ variant="glass"
102
+ className="md:col-span-2 lg:col-span-3 animate-scale-in hover:scale-[1.02] transition-all duration-300 group cursor-pointer"
103
+ style={{ animationDelay: "0.4s" }}
104
+ >
105
+ <Link href="/matrix/testing" className="block">
106
+ <CardHeader>
107
+ <div className="p-3 rounded-xl bg-gradient-to-br from-amber-500 via-orange-500 to-pink-500 w-fit mb-3 shadow-lg group-hover:shadow-xl group-hover:scale-110 transition-all duration-300">
108
+ <TestTube className="h-6 w-6 text-white" />
109
+ </div>
110
+ <CardTitle className="group-hover:text-orange-600 transition-colors">Testing Matrix Builder</CardTitle>
111
+ <CardDescription>
112
+ Generate systematic testing matrices for optimization
113
+ </CardDescription>
114
+ </CardHeader>
115
+ <CardContent>
116
+ <Button variant="secondary" className="w-full group-hover:shadow-lg transition-all">
117
  <TestTube className="h-4 w-4 mr-2 group-hover:rotate-12 transition-transform duration-300" />
118
  Build Testing Matrix
119
  </Button>
120
+ </CardContent>
121
+ </Link>
122
  </Card>
123
  </div>
124
  </div>
frontend/app/page.tsx CHANGED
@@ -3,14 +3,23 @@
3
  import React, { useEffect, useState, memo, useCallback } from "react";
4
  import Link from "next/link";
5
  import Image from "next/image";
 
6
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
7
  import { Button } from "@/components/ui/Button";
8
  import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
9
  import { getDbStats, listAds } from "@/lib/api/endpoints";
10
  import { formatRelativeDate, formatNiche, getImageUrl, getImageUrlFallback } from "@/lib/utils/formatters";
11
  import { Home, Sparkles, Grid, TrendingUp, Database } from "lucide-react";
 
12
  import type { DbStatsResponse, AdCreativeDB } from "@/types/api";
13
 
 
 
 
 
 
 
 
14
  // Component for dashboard ad cards with image error handling
15
  const DashboardAdCard = memo(function DashboardAdCard({
16
  ad,
@@ -38,13 +47,13 @@ const DashboardAdCard = memo(function DashboardAdCard({
38
  return (
39
  <Link
40
  href={`/gallery/${ad.id}`}
41
- className="block group animate-scale-in"
42
  style={{ animationDelay: `${index * 0.1}s` }}
43
  >
44
- <Card variant="elevated" className="h-full overflow-hidden">
45
- <CardContent className="p-0">
46
  {(imageSrc || ad.image_filename || ad.image_url) && (
47
- <div className="aspect-video bg-gradient-to-br from-gray-100 to-gray-200 rounded-t-2xl overflow-hidden relative flex items-center justify-center">
48
  {imageSrc ? (
49
  <Image
50
  src={imageSrc}
@@ -69,20 +78,23 @@ const DashboardAdCard = memo(function DashboardAdCard({
69
  <div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
70
  </div>
71
  )}
72
- <div className="p-5">
73
- <div className="flex items-center justify-between mb-3">
74
- <span className="text-xs font-bold px-3 py-1 bg-gradient-to-r from-blue-500 to-cyan-500 text-white rounded-full">
75
- {formatNiche(ad.niche)}
76
- </span>
77
- <span className="text-xs text-gray-500 font-medium">
78
  {formatRelativeDate(ad.created_at)}
79
  </span>
80
  </div>
81
- <h3 className="font-bold text-gray-900 line-clamp-2 mb-2 group-hover:text-blue-600 transition-colors">
 
 
82
  {ad.headline}
83
  </h3>
 
 
84
  {ad.title && (
85
- <p className="text-sm text-gray-600 line-clamp-1">
86
  {ad.title}
87
  </p>
88
  )}
@@ -94,10 +106,17 @@ const DashboardAdCard = memo(function DashboardAdCard({
94
  });
95
 
96
  export default function Dashboard() {
 
 
97
  const [stats, setStats] = useState<DbStatsResponse | null>(null);
98
  const [recentAds, setRecentAds] = useState<AdCreativeDB[]>([]);
99
  const [isLoading, setIsLoading] = useState(true);
100
 
 
 
 
 
 
101
  useEffect(() => {
102
  const loadData = async () => {
103
  try {
@@ -149,48 +168,66 @@ export default function Dashboard() {
149
  <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
150
  {/* Stats Grid */}
151
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
152
- <Card variant="glass" className="animate-scale-in" style={{ animationDelay: "0.1s" }}>
 
 
 
 
 
 
 
 
153
  <CardContent className="pt-6">
154
  <div className="flex items-center justify-between">
155
  <div>
156
- <p className="text-sm font-semibold text-gray-600 mb-1">Total Ads</p>
157
- <p className="text-3xl font-bold text-gray-900">
158
  {stats?.total_ads ?? 0}
159
  </p>
160
  </div>
161
- <div className="p-3 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-500 shadow-lg">
162
  <Database className="h-8 w-8 text-white" />
163
  </div>
164
  </div>
165
  </CardContent>
166
  </Card>
167
 
168
- <Card variant="glass" className="animate-scale-in" style={{ animationDelay: "0.2s" }}>
 
 
 
 
 
169
  <CardContent className="pt-6">
170
  <div className="flex items-center justify-between">
171
  <div>
172
- <p className="text-sm font-semibold text-gray-600 mb-1">Home Insurance</p>
173
- <p className="text-3xl font-bold text-gray-900">
174
  {stats?.by_niche?.home_insurance ?? 0}
175
  </p>
176
  </div>
177
- <div className="p-3 rounded-xl bg-gradient-to-br from-green-500 to-emerald-600 shadow-lg">
178
  <Home className="h-8 w-8 text-white" />
179
  </div>
180
  </div>
181
  </CardContent>
182
  </Card>
183
 
184
- <Card variant="glass" className="animate-scale-in" style={{ animationDelay: "0.3s" }}>
 
 
 
 
 
185
  <CardContent className="pt-6">
186
  <div className="flex items-center justify-between">
187
  <div>
188
- <p className="text-sm font-semibold text-gray-600 mb-1">GLP-1</p>
189
- <p className="text-3xl font-bold text-gray-900">
190
  {stats?.by_niche?.glp1 ?? 0}
191
  </p>
192
  </div>
193
- <div className="p-3 rounded-xl bg-gradient-to-br from-cyan-500 to-pink-600 shadow-lg">
194
  <TrendingUp className="h-8 w-8 text-white" />
195
  </div>
196
  </div>
 
3
  import React, { useEffect, useState, memo, useCallback } from "react";
4
  import Link from "next/link";
5
  import Image from "next/image";
6
+ import { useRouter } from "next/navigation";
7
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
8
  import { Button } from "@/components/ui/Button";
9
  import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
10
  import { getDbStats, listAds } from "@/lib/api/endpoints";
11
  import { formatRelativeDate, formatNiche, getImageUrl, getImageUrlFallback } from "@/lib/utils/formatters";
12
  import { Home, Sparkles, Grid, TrendingUp, Database } from "lucide-react";
13
+ import { useGalleryStore } from "@/store/galleryStore";
14
  import type { DbStatsResponse, AdCreativeDB } from "@/types/api";
15
 
16
+ // Reusable gradient badge component
17
+ const GradientBadge: React.FC<{ children: React.ReactNode }> = ({ children }) => (
18
+ <span className="inline-flex items-center text-xs font-bold px-3 py-1.5 bg-gradient-to-r from-blue-500 to-cyan-500 text-white rounded-full shadow-sm whitespace-nowrap">
19
+ {children}
20
+ </span>
21
+ );
22
+
23
  // Component for dashboard ad cards with image error handling
24
  const DashboardAdCard = memo(function DashboardAdCard({
25
  ad,
 
47
  return (
48
  <Link
49
  href={`/gallery/${ad.id}`}
50
+ className="block group animate-scale-in h-full"
51
  style={{ animationDelay: `${index * 0.1}s` }}
52
  >
53
+ <Card variant="elevated" className="h-full overflow-hidden flex flex-col hover:scale-[1.02] transition-transform duration-300">
54
+ <CardContent className="p-0 flex flex-col flex-1">
55
  {(imageSrc || ad.image_filename || ad.image_url) && (
56
+ <div className="aspect-video bg-gradient-to-br from-gray-100 to-gray-200 rounded-t-2xl overflow-hidden relative flex items-center justify-center flex-shrink-0">
57
  {imageSrc ? (
58
  <Image
59
  src={imageSrc}
 
78
  <div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
79
  </div>
80
  )}
81
+ <div className="p-5 flex-1 flex flex-col">
82
+ {/* Header with badge and timestamp - properly aligned on same line */}
83
+ <div className="flex items-center justify-between gap-2 mb-3">
84
+ <GradientBadge>{formatNiche(ad.niche)}</GradientBadge>
85
+ <span className="text-xs text-gray-500 font-medium flex-shrink-0">
 
86
  {formatRelativeDate(ad.created_at)}
87
  </span>
88
  </div>
89
+
90
+ {/* Headline - improved typography */}
91
+ <h3 className="font-bold text-lg text-gray-900 line-clamp-2 mb-3 group-hover:text-blue-600 transition-colors leading-tight">
92
  {ad.headline}
93
  </h3>
94
+
95
+ {/* Description section - improved spacing */}
96
  {ad.title && (
97
+ <p className="text-sm text-gray-700 line-clamp-1 font-medium mt-auto">
98
  {ad.title}
99
  </p>
100
  )}
 
106
  });
107
 
108
  export default function Dashboard() {
109
+ const router = useRouter();
110
+ const setFilters = useGalleryStore((state) => state.setFilters);
111
  const [stats, setStats] = useState<DbStatsResponse | null>(null);
112
  const [recentAds, setRecentAds] = useState<AdCreativeDB[]>([]);
113
  const [isLoading, setIsLoading] = useState(true);
114
 
115
+ const handleStatCardClick = useCallback((niche: "home_insurance" | "glp1" | null) => {
116
+ setFilters({ niche });
117
+ router.push("/gallery");
118
+ }, [router, setFilters]);
119
+
120
  useEffect(() => {
121
  const loadData = async () => {
122
  try {
 
168
  <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
169
  {/* Stats Grid */}
170
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
171
+ <Card
172
+ variant="glass"
173
+ className="animate-scale-in cursor-pointer transition-all duration-300 hover:scale-105 hover:shadow-2xl group"
174
+ style={{ animationDelay: "0.1s" }}
175
+ onClick={() => {
176
+ setFilters({ niche: null });
177
+ router.push("/gallery");
178
+ }}
179
+ >
180
  <CardContent className="pt-6">
181
  <div className="flex items-center justify-between">
182
  <div>
183
+ <p className="text-sm font-semibold text-gray-600 mb-1 group-hover:text-gray-900 transition-colors">Total Ads</p>
184
+ <p className="text-3xl font-bold text-gray-900 group-hover:text-blue-600 transition-colors">
185
  {stats?.total_ads ?? 0}
186
  </p>
187
  </div>
188
+ <div className="p-3 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-500 shadow-lg group-hover:shadow-xl transition-shadow">
189
  <Database className="h-8 w-8 text-white" />
190
  </div>
191
  </div>
192
  </CardContent>
193
  </Card>
194
 
195
+ <Card
196
+ variant="glass"
197
+ className="animate-scale-in cursor-pointer transition-all duration-300 hover:scale-105 hover:shadow-2xl group"
198
+ style={{ animationDelay: "0.2s" }}
199
+ onClick={() => handleStatCardClick("home_insurance")}
200
+ >
201
  <CardContent className="pt-6">
202
  <div className="flex items-center justify-between">
203
  <div>
204
+ <p className="text-sm font-semibold text-gray-600 mb-1 group-hover:text-gray-900 transition-colors">Home Insurance</p>
205
+ <p className="text-3xl font-bold text-gray-900 group-hover:text-green-600 transition-colors">
206
  {stats?.by_niche?.home_insurance ?? 0}
207
  </p>
208
  </div>
209
+ <div className="p-3 rounded-xl bg-gradient-to-br from-green-500 to-emerald-600 shadow-lg group-hover:shadow-xl transition-shadow">
210
  <Home className="h-8 w-8 text-white" />
211
  </div>
212
  </div>
213
  </CardContent>
214
  </Card>
215
 
216
+ <Card
217
+ variant="glass"
218
+ className="animate-scale-in cursor-pointer transition-all duration-300 hover:scale-105 hover:shadow-2xl group"
219
+ style={{ animationDelay: "0.3s" }}
220
+ onClick={() => handleStatCardClick("glp1")}
221
+ >
222
  <CardContent className="pt-6">
223
  <div className="flex items-center justify-between">
224
  <div>
225
+ <p className="text-sm font-semibold text-gray-600 mb-1 group-hover:text-gray-900 transition-colors">GLP-1</p>
226
+ <p className="text-3xl font-bold text-gray-900 group-hover:text-pink-600 transition-colors">
227
  {stats?.by_niche?.glp1 ?? 0}
228
  </p>
229
  </div>
230
+ <div className="p-3 rounded-xl bg-gradient-to-br from-cyan-500 to-pink-600 shadow-lg group-hover:shadow-xl transition-shadow">
231
  <TrendingUp className="h-8 w-8 text-white" />
232
  </div>
233
  </div>
frontend/components/gallery/AdCard.tsx CHANGED
@@ -13,6 +13,13 @@ interface AdCardProps {
13
  onSelect?: (adId: string) => void;
14
  }
15
 
 
 
 
 
 
 
 
16
  export const AdCard: React.FC<AdCardProps> = memo(({
17
  ad,
18
  isSelected = false,
@@ -36,15 +43,15 @@ export const AdCard: React.FC<AdCardProps> = memo(({
36
  return (
37
  <Card
38
  variant={isSelected ? "glass" : "elevated"}
39
- className={`cursor-pointer transition-all duration-300 group ${
40
- isSelected ? "ring-4 ring-blue-500 ring-opacity-50 scale-105" : "hover:scale-105"
41
  }`}
42
  onClick={() => onSelect?.(ad.id)}
43
  >
44
- <Link href={`/gallery/${ad.id}`} onClick={(e) => e.stopPropagation()}>
45
- <CardContent className="p-0">
46
  {(imageSrc || ad.image_filename || ad.image_url) && (
47
- <div className="aspect-video bg-gradient-to-br from-gray-100 to-gray-200 rounded-t-2xl overflow-hidden relative flex items-center justify-center">
48
  {imageSrc ? (
49
  <Image
50
  src={imageSrc}
@@ -69,28 +76,33 @@ export const AdCard: React.FC<AdCardProps> = memo(({
69
  <div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
70
  </div>
71
  )}
72
- <div className="p-5">
73
- <div className="flex items-center justify-between mb-3">
74
- <span className="text-xs font-bold px-3 py-1 bg-gradient-to-r from-blue-500 to-cyan-500 text-white rounded-full">
75
- {formatNiche(ad.niche)}
76
- </span>
77
- <span className="text-xs text-gray-500 font-medium">
78
  {formatRelativeDate(ad.created_at)}
79
  </span>
80
  </div>
81
- <h3 className="font-bold text-gray-900 line-clamp-2 mb-2 group-hover:text-blue-600 transition-colors">
82
- {truncateText(ad.headline, 60)}
 
 
83
  </h3>
84
- {ad.title && (
85
- <p className="text-sm text-gray-600 line-clamp-1">
86
- {ad.title}
87
- </p>
88
- )}
89
- {ad.psychological_angle && (
90
- <p className="text-xs text-gray-500 mt-2 line-clamp-1">
91
- {truncateText(ad.psychological_angle, 50)}
92
- </p>
93
- )}
 
 
 
 
94
  </div>
95
  </CardContent>
96
  </Link>
 
13
  onSelect?: (adId: string) => void;
14
  }
15
 
16
+ // Reusable gradient badge component
17
+ const GradientBadge: React.FC<{ children: React.ReactNode }> = ({ children }) => (
18
+ <span className="inline-flex items-center text-xs font-bold px-3 py-1.5 bg-gradient-to-r from-blue-500 to-cyan-500 text-white rounded-full shadow-sm whitespace-nowrap">
19
+ {children}
20
+ </span>
21
+ );
22
+
23
  export const AdCard: React.FC<AdCardProps> = memo(({
24
  ad,
25
  isSelected = false,
 
43
  return (
44
  <Card
45
  variant={isSelected ? "glass" : "elevated"}
46
+ className={`cursor-pointer transition-all duration-300 group h-full flex flex-col ${
47
+ isSelected ? "ring-4 ring-blue-500 ring-opacity-50 scale-105" : "hover:scale-[1.02]"
48
  }`}
49
  onClick={() => onSelect?.(ad.id)}
50
  >
51
+ <Link href={`/gallery/${ad.id}`} onClick={(e) => e.stopPropagation()} className="flex flex-col h-full">
52
+ <CardContent className="p-0 flex flex-col flex-1">
53
  {(imageSrc || ad.image_filename || ad.image_url) && (
54
+ <div className="aspect-video bg-gradient-to-br from-gray-100 to-gray-200 rounded-t-2xl overflow-hidden relative flex items-center justify-center flex-shrink-0">
55
  {imageSrc ? (
56
  <Image
57
  src={imageSrc}
 
76
  <div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
77
  </div>
78
  )}
79
+ <div className="p-5 flex-1 flex flex-col">
80
+ {/* Header with badge and timestamp - properly aligned on same line */}
81
+ <div className="flex items-center justify-between gap-2 mb-3">
82
+ <GradientBadge>{formatNiche(ad.niche)}</GradientBadge>
83
+ <span className="text-xs text-gray-500 font-medium flex-shrink-0">
 
84
  {formatRelativeDate(ad.created_at)}
85
  </span>
86
  </div>
87
+
88
+ {/* Headline - improved typography */}
89
+ <h3 className="font-bold text-lg text-gray-900 line-clamp-2 mb-3 group-hover:text-blue-600 transition-colors leading-tight">
90
+ {ad.headline}
91
  </h3>
92
+
93
+ {/* Description section - improved spacing and hierarchy */}
94
+ <div className="mt-auto space-y-1.5">
95
+ {ad.title && (
96
+ <p className="text-sm text-gray-700 line-clamp-1 font-medium">
97
+ {ad.title}
98
+ </p>
99
+ )}
100
+ {ad.psychological_angle && (
101
+ <p className="text-xs text-gray-500 line-clamp-1 italic">
102
+ {ad.psychological_angle}
103
+ </p>
104
+ )}
105
+ </div>
106
  </div>
107
  </CardContent>
108
  </Link>
frontend/components/generation/BatchProgress.tsx ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useState, useEffect } from "react";
4
+ import { Card, CardContent } from "@/components/ui/Card";
5
+ import { ProgressBar } from "@/components/ui/ProgressBar";
6
+ import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
7
+ import {
8
+ Sparkles,
9
+ Image as ImageIcon,
10
+ Database,
11
+ CheckCircle2,
12
+ AlertCircle,
13
+ Wand2,
14
+ Zap,
15
+ Package
16
+ } from "lucide-react";
17
+
18
+ interface BatchProgressProps {
19
+ progress: number; // 0-100
20
+ currentIndex?: number; // Current ad being generated (0-based)
21
+ totalCount?: number; // Total number of ads
22
+ imagesPerAd?: number; // Number of images per ad
23
+ generationStartTime?: number | null;
24
+ message?: string;
25
+ }
26
+
27
+ const BATCH_MESSAGES = [
28
+ "Creating your ad variations...",
29
+ "Generating unique ad creatives...",
30
+ "Crafting compelling visuals...",
31
+ "Building your ad collection...",
32
+ "Almost there! Finalizing your ads...",
33
+ "Working on the perfect variations...",
34
+ "This batch is going to be amazing!",
35
+ "Great things take time - we're crafting perfection!",
36
+ ] as const;
37
+
38
+ const ENGAGING_MESSAGES = [
39
+ "Batch generation may take a while, but great things are worth waiting for!",
40
+ "Almost there! We're putting the finishing touches on your batch.",
41
+ "Hang tight! We're creating something amazing for you.",
42
+ "This is taking a bit longer, but we're ensuring top quality!",
43
+ "Just a few more moments... Your batch is almost ready!",
44
+ "We're working hard to make this perfect for you!",
45
+ "Great things take time - we're crafting your batch!",
46
+ "Almost done! We're making sure everything is just right.",
47
+ ] as const;
48
+
49
+ export const BatchProgressComponent: React.FC<BatchProgressProps> = ({
50
+ progress,
51
+ currentIndex = 0,
52
+ totalCount = 0,
53
+ imagesPerAd = 1,
54
+ generationStartTime,
55
+ message,
56
+ }) => {
57
+ const [currentMessageIndex, setCurrentMessageIndex] = useState(0);
58
+ const [elapsedTime, setElapsedTime] = useState(0);
59
+ const [estimatedTimeRemaining, setEstimatedTimeRemaining] = useState<number | null>(null);
60
+
61
+ const clampedProgress = Math.min(100, Math.max(0, progress));
62
+ const isComplete = clampedProgress >= 100;
63
+ const isStuckAtHighProgress = clampedProgress >= 85 && !isComplete;
64
+ const currentAdNumber = currentIndex + 1;
65
+ const totalVariations = totalCount * imagesPerAd;
66
+ const currentVariation = currentIndex * imagesPerAd + 1;
67
+
68
+ // Calculate elapsed time
69
+ useEffect(() => {
70
+ if (generationStartTime && !isComplete) {
71
+ const interval = setInterval(() => {
72
+ const elapsed = Math.floor((Date.now() - generationStartTime) / 1000);
73
+ setElapsedTime(elapsed);
74
+
75
+ // Estimate time remaining based on progress
76
+ if (clampedProgress > 5 && clampedProgress < 100) {
77
+ const rate = clampedProgress / elapsed; // % per second
78
+ const remaining = (100 - clampedProgress) / rate;
79
+ setEstimatedTimeRemaining(Math.max(0, Math.ceil(remaining)));
80
+ }
81
+ }, 1000);
82
+ return () => clearInterval(interval);
83
+ }
84
+ }, [generationStartTime, isComplete, clampedProgress]);
85
+
86
+ // Rotate messages when stuck at high progress
87
+ useEffect(() => {
88
+ if (isStuckAtHighProgress) {
89
+ const interval = setInterval(() => {
90
+ setCurrentMessageIndex((prev) => (prev + 1) % ENGAGING_MESSAGES.length);
91
+ }, 5000); // Change message every 5 seconds
92
+ return () => clearInterval(interval);
93
+ }
94
+ }, [isStuckAtHighProgress]);
95
+
96
+ // Rotate batch messages periodically
97
+ useEffect(() => {
98
+ if (!isComplete && !isStuckAtHighProgress) {
99
+ const interval = setInterval(() => {
100
+ setCurrentMessageIndex((prev) => (prev + 1) % BATCH_MESSAGES.length);
101
+ }, 4000); // Change message every 4 seconds
102
+ return () => clearInterval(interval);
103
+ }
104
+ }, [isComplete, isStuckAtHighProgress]);
105
+
106
+ // Get current message
107
+ const getCurrentMessage = () => {
108
+ if (message) return message;
109
+ if (isStuckAtHighProgress) {
110
+ return ENGAGING_MESSAGES[currentMessageIndex];
111
+ }
112
+ if (totalCount > 0 && currentIndex >= 0) {
113
+ return `Generating ad ${currentAdNumber} of ${totalCount}...`;
114
+ }
115
+ return BATCH_MESSAGES[currentMessageIndex];
116
+ };
117
+
118
+ return (
119
+ <div className="sticky top-20 z-30 mb-6 animate-scale-in">
120
+ <Card variant="glass" className="overflow-hidden shadow-xl border-2 border-blue-200/50 backdrop-blur-xl">
121
+ <CardContent className="pt-6">
122
+ <div className="space-y-6">
123
+ {/* Header with animated icon */}
124
+ <div className="flex items-center justify-between">
125
+ <div className="flex items-center space-x-4">
126
+ {isComplete ? (
127
+ <div className="relative">
128
+ <div className="absolute inset-0 bg-green-500 rounded-full animate-ping opacity-75"></div>
129
+ <CheckCircle2 className="h-8 w-8 text-green-500 relative z-10" />
130
+ </div>
131
+ ) : (
132
+ <div className="relative">
133
+ <div className="absolute inset-0 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-full animate-ping opacity-20"></div>
134
+ <div className="relative bg-gradient-to-r from-blue-500 to-cyan-500 rounded-full p-2">
135
+ <Package className="h-5 w-5 text-white animate-pulse" />
136
+ </div>
137
+ </div>
138
+ )}
139
+ <div>
140
+ <h3 className="text-lg font-bold bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">
141
+ {isComplete ? "Batch Generation Complete!" : "Generating Batch Ads"}
142
+ </h3>
143
+ <p className="text-sm text-gray-600 mt-0.5 transition-all duration-500">
144
+ {isComplete ? "All ads are ready!" : getCurrentMessage()}
145
+ </p>
146
+ {elapsedTime > 30 && !isComplete && (
147
+ <p className="text-xs text-gray-500 mt-1">
148
+ {Math.floor(elapsedTime / 60)}m {elapsedTime % 60}s elapsed
149
+ </p>
150
+ )}
151
+ </div>
152
+ </div>
153
+ {estimatedTimeRemaining !== null && !isComplete && (
154
+ <div className="text-right">
155
+ <div className="flex items-center space-x-1 text-sm font-semibold text-gray-700">
156
+ <Zap className="h-4 w-4 text-yellow-500 animate-pulse" />
157
+ <span>~{estimatedTimeRemaining}s</span>
158
+ </div>
159
+ <p className="text-xs text-gray-500">remaining</p>
160
+ </div>
161
+ )}
162
+ </div>
163
+
164
+ {/* Batch Stats */}
165
+ {totalCount > 0 && (
166
+ <div className="grid grid-cols-3 gap-4">
167
+ <div className="bg-gradient-to-br from-blue-50 to-cyan-50 rounded-xl p-4 border border-blue-200">
168
+ <div className="flex items-center space-x-2 mb-1">
169
+ <Package className="h-4 w-4 text-blue-600" />
170
+ <p className="text-xs font-semibold text-gray-600">Total Ads</p>
171
+ </div>
172
+ <p className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">
173
+ {totalCount}
174
+ </p>
175
+ </div>
176
+ <div className="bg-gradient-to-br from-cyan-50 to-pink-50 rounded-xl p-4 border border-cyan-200">
177
+ <div className="flex items-center space-x-2 mb-1">
178
+ <ImageIcon className="h-4 w-4 text-cyan-600" />
179
+ <p className="text-xs font-semibold text-gray-600">Variations</p>
180
+ </div>
181
+ <p className="text-2xl font-bold bg-gradient-to-r from-cyan-600 to-pink-600 bg-clip-text text-transparent">
182
+ {totalVariations}
183
+ </p>
184
+ </div>
185
+ <div className="bg-gradient-to-br from-pink-50 to-purple-50 rounded-xl p-4 border border-pink-200">
186
+ <div className="flex items-center space-x-2 mb-1">
187
+ <Sparkles className="h-4 w-4 text-pink-600" />
188
+ <p className="text-xs font-semibold text-gray-600">Current</p>
189
+ </div>
190
+ <p className="text-2xl font-bold bg-gradient-to-r from-pink-600 to-purple-600 bg-clip-text text-transparent">
191
+ {currentAdNumber}/{totalCount}
192
+ </p>
193
+ </div>
194
+ </div>
195
+ )}
196
+
197
+ {/* Progress Bar */}
198
+ <div className="space-y-2">
199
+ <div className="flex justify-between items-center">
200
+ <span className="text-sm font-semibold text-gray-700">Overall Progress</span>
201
+ <span className="text-sm font-bold bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">
202
+ {Math.round(clampedProgress)}%
203
+ </span>
204
+ </div>
205
+ <ProgressBar
206
+ progress={clampedProgress}
207
+ showPercentage={false}
208
+ />
209
+ {totalCount > 0 && currentIndex >= 0 && (
210
+ <p className="text-xs text-gray-500 text-center mt-2">
211
+ {currentAdNumber} of {totalCount} ads completed
212
+ {imagesPerAd > 1 && ` • ${currentVariation} of ${totalVariations} variations`}
213
+ </p>
214
+ )}
215
+ </div>
216
+
217
+ {/* Success State */}
218
+ {isComplete && (
219
+ <div className="mt-4 p-4 bg-gradient-to-r from-green-50 to-emerald-50 border-2 border-green-200 rounded-xl animate-scale-in">
220
+ <div className="flex items-center space-x-3">
221
+ <CheckCircle2 className="h-6 w-6 text-green-600 flex-shrink-0" />
222
+ <div>
223
+ <p className="text-sm font-semibold text-green-900">
224
+ Batch generation completed successfully!
225
+ </p>
226
+ <p className="text-xs text-green-700 mt-0.5">
227
+ {totalCount > 0 ? `${totalCount} ads with ${totalVariations} total variations are ready!` : "All ads are ready to use"}
228
+ </p>
229
+ </div>
230
+ </div>
231
+ </div>
232
+ )}
233
+ </div>
234
+ </CardContent>
235
+ </Card>
236
+ </div>
237
+ );
238
+ };
frontend/components/generation/GenerationProgress.tsx CHANGED
@@ -1,6 +1,6 @@
1
  "use client";
2
 
3
- import React from "react";
4
  import { Card, CardContent } from "@/components/ui/Card";
5
  import { ProgressBar } from "@/components/ui/ProgressBar";
6
  import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
@@ -17,6 +17,7 @@ import type { GenerationProgress } from "@/types";
17
 
18
  interface GenerationProgressProps {
19
  progress: GenerationProgress;
 
20
  }
21
 
22
  const STEPS = [
@@ -31,17 +32,40 @@ const STEPS = [
31
  "Bringing your vision to life...",
32
  "Rendering high-quality images...",
33
  "Adding creative flair...",
 
 
 
 
 
 
34
  ]},
35
  { key: "saving", label: "Saving", icon: Database, color: "from-pink-500 to-purple-500", messages: [
36
  "Storing your creative...",
37
  "Securing your masterpiece...",
38
  "Almost done...",
 
39
  ]},
40
  ] as const;
41
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  export const GenerationProgressComponent: React.FC<GenerationProgressProps> = ({
43
  progress,
 
44
  }) => {
 
 
 
45
  const stepProgress = {
46
  idle: 0,
47
  copy: 33,
@@ -55,21 +79,61 @@ export const GenerationProgressComponent: React.FC<GenerationProgressProps> = ({
55
  const currentStepIndex = STEPS.findIndex(s => s.key === progress.step);
56
  const isComplete = progress.step === "complete";
57
  const isError = progress.step === "error";
 
58
 
59
- // Get random message for current step
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  const getStepMessage = () => {
61
  if (progress.message) return progress.message;
 
 
 
 
 
 
62
  const step = STEPS.find(s => s.key === progress.step);
63
  if (step && step.messages.length > 0) {
64
- const randomIndex = Math.floor(Math.random() * step.messages.length);
65
- return step.messages[randomIndex];
66
  }
67
  return "Processing...";
68
  };
69
 
70
  return (
71
- <Card variant="glass" className="overflow-hidden">
72
- <CardContent className="pt-6">
 
73
  <div className="space-y-6">
74
  {/* Header with animated icon */}
75
  <div className="flex items-center justify-between">
@@ -93,9 +157,14 @@ export const GenerationProgressComponent: React.FC<GenerationProgressProps> = ({
93
  <h3 className="text-lg font-bold bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">
94
  {isComplete ? "Generation Complete!" : isError ? "Generation Failed" : "Creating Your Ad"}
95
  </h3>
96
- <p className="text-sm text-gray-600 mt-0.5">
97
  {isComplete ? "Your ad is ready!" : isError ? "Something went wrong" : getStepMessage()}
98
  </p>
 
 
 
 
 
99
  </div>
100
  </div>
101
  {progress.estimatedTimeRemaining && !isComplete && !isError && (
@@ -213,5 +282,6 @@ export const GenerationProgressComponent: React.FC<GenerationProgressProps> = ({
213
  </div>
214
  </CardContent>
215
  </Card>
 
216
  );
217
  };
 
1
  "use client";
2
 
3
+ import React, { useState, useEffect } from "react";
4
  import { Card, CardContent } from "@/components/ui/Card";
5
  import { ProgressBar } from "@/components/ui/ProgressBar";
6
  import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
 
17
 
18
  interface GenerationProgressProps {
19
  progress: GenerationProgress;
20
+ generationStartTime?: number | null;
21
  }
22
 
23
  const STEPS = [
 
32
  "Bringing your vision to life...",
33
  "Rendering high-quality images...",
34
  "Adding creative flair...",
35
+ "Generation may take a while...",
36
+ "Almost there!",
37
+ "Working on the perfect image...",
38
+ "This is worth the wait...",
39
+ "Crafting something amazing...",
40
+ "Just a few more moments...",
41
  ]},
42
  { key: "saving", label: "Saving", icon: Database, color: "from-pink-500 to-purple-500", messages: [
43
  "Storing your creative...",
44
  "Securing your masterpiece...",
45
  "Almost done...",
46
+ "Finalizing everything...",
47
  ]},
48
  ] as const;
49
 
50
+ // Engaging messages for when stuck at high progress
51
+ const ENGAGING_MESSAGES = [
52
+ "Generation may take a while, but great things are worth waiting for!",
53
+ "Almost there! We're putting the finishing touches on your ad.",
54
+ "Hang tight! We're creating something amazing for you.",
55
+ "This is taking a bit longer, but we're ensuring top quality!",
56
+ "Just a few more moments... Your ad is almost ready!",
57
+ "We're working hard to make this perfect for you!",
58
+ "Great things take time - we're crafting your masterpiece!",
59
+ "Almost done! We're making sure everything is just right.",
60
+ ] as const;
61
+
62
  export const GenerationProgressComponent: React.FC<GenerationProgressProps> = ({
63
  progress,
64
+ generationStartTime,
65
  }) => {
66
+ const [currentMessageIndex, setCurrentMessageIndex] = useState(0);
67
+ const [elapsedTime, setElapsedTime] = useState(0);
68
+
69
  const stepProgress = {
70
  idle: 0,
71
  copy: 33,
 
79
  const currentStepIndex = STEPS.findIndex(s => s.key === progress.step);
80
  const isComplete = progress.step === "complete";
81
  const isError = progress.step === "error";
82
+ const isStuckAtHighProgress = currentProgress >= 85 && !isComplete && !isError;
83
 
84
+ // Calculate elapsed time
85
+ useEffect(() => {
86
+ if (generationStartTime && !isComplete && !isError) {
87
+ const interval = setInterval(() => {
88
+ setElapsedTime(Math.floor((Date.now() - generationStartTime) / 1000));
89
+ }, 1000);
90
+ return () => clearInterval(interval);
91
+ }
92
+ }, [generationStartTime, isComplete, isError]);
93
+
94
+ // Rotate messages when stuck at high progress
95
+ useEffect(() => {
96
+ if (isStuckAtHighProgress) {
97
+ const interval = setInterval(() => {
98
+ setCurrentMessageIndex((prev) => (prev + 1) % ENGAGING_MESSAGES.length);
99
+ }, 5000); // Change message every 5 seconds
100
+ return () => clearInterval(interval);
101
+ }
102
+ }, [isStuckAtHighProgress]);
103
+
104
+ // Rotate step messages periodically
105
+ useEffect(() => {
106
+ if (!isComplete && !isError && !isStuckAtHighProgress) {
107
+ const step = STEPS.find(s => s.key === progress.step);
108
+ if (step && step.messages.length > 1) {
109
+ const interval = setInterval(() => {
110
+ setCurrentMessageIndex((prev) => (prev + 1) % step.messages.length);
111
+ }, 4000); // Change message every 4 seconds
112
+ return () => clearInterval(interval);
113
+ }
114
+ }
115
+ }, [progress.step, isComplete, isError, isStuckAtHighProgress]);
116
+
117
+ // Get message for current step
118
  const getStepMessage = () => {
119
  if (progress.message) return progress.message;
120
+
121
+ // If stuck at high progress, show engaging messages
122
+ if (isStuckAtHighProgress) {
123
+ return ENGAGING_MESSAGES[currentMessageIndex];
124
+ }
125
+
126
  const step = STEPS.find(s => s.key === progress.step);
127
  if (step && step.messages.length > 0) {
128
+ return step.messages[currentMessageIndex % step.messages.length];
 
129
  }
130
  return "Processing...";
131
  };
132
 
133
  return (
134
+ <div className="sticky top-20 z-30 mb-6 animate-scale-in">
135
+ <Card variant="glass" className="overflow-hidden shadow-xl border-2 border-blue-200/50 backdrop-blur-xl">
136
+ <CardContent className="pt-6">
137
  <div className="space-y-6">
138
  {/* Header with animated icon */}
139
  <div className="flex items-center justify-between">
 
157
  <h3 className="text-lg font-bold bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">
158
  {isComplete ? "Generation Complete!" : isError ? "Generation Failed" : "Creating Your Ad"}
159
  </h3>
160
+ <p className="text-sm text-gray-600 mt-0.5 transition-all duration-500">
161
  {isComplete ? "Your ad is ready!" : isError ? "Something went wrong" : getStepMessage()}
162
  </p>
163
+ {elapsedTime > 30 && !isComplete && !isError && (
164
+ <p className="text-xs text-gray-500 mt-1">
165
+ {Math.floor(elapsedTime / 60)}m {elapsedTime % 60}s elapsed
166
+ </p>
167
+ )}
168
  </div>
169
  </div>
170
  {progress.estimatedTimeRemaining && !isComplete && !isError && (
 
282
  </div>
283
  </CardContent>
284
  </Card>
285
+ </div>
286
  );
287
  };
frontend/components/matrix/AngleSelector.tsx CHANGED
@@ -84,9 +84,11 @@ export const AngleSelector: React.FC<AngleSelectorProps> = ({
84
  }));
85
 
86
  return (
87
- <Card variant="glass">
88
  <CardHeader>
89
- <CardTitle>Select Angle</CardTitle>
 
 
90
  </CardHeader>
91
  <CardContent className="space-y-4">
92
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -110,16 +112,28 @@ export const AngleSelector: React.FC<AngleSelectorProps> = ({
110
  <div
111
  key={angle.key}
112
  onClick={() => onSelect?.(angle)}
113
- className={`p-3 rounded-lg border cursor-pointer transition-colors ${
114
  selectedAngle?.key === angle.key
115
- ? "border-blue-500 bg-blue-50"
116
- : "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
117
  }`}
118
  >
119
  <div className="flex items-start justify-between">
120
  <div className="flex-1">
121
- <h4 className="font-semibold text-gray-900">{angle.name}</h4>
122
- <p className="text-sm text-gray-600 mt-1">{angle.trigger}</p>
 
 
 
 
 
 
 
 
 
 
 
 
123
  <p className="text-xs text-gray-500 mt-1">{category}</p>
124
  </div>
125
  </div>
 
84
  }));
85
 
86
  return (
87
+ <Card variant="glass" className="border-2 border-transparent hover:border-blue-200/50 transition-all duration-300">
88
  <CardHeader>
89
+ <CardTitle className="bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">
90
+ Select Angle
91
+ </CardTitle>
92
  </CardHeader>
93
  <CardContent className="space-y-4">
94
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
 
112
  <div
113
  key={angle.key}
114
  onClick={() => onSelect?.(angle)}
115
+ className={`p-3 rounded-lg border cursor-pointer transition-all duration-300 ${
116
  selectedAngle?.key === angle.key
117
+ ? "border-blue-500 bg-gradient-to-r from-blue-50 to-cyan-50 shadow-md ring-2 ring-blue-200"
118
+ : "border-gray-200 hover:border-blue-300 hover:bg-gradient-to-r hover:from-gray-50 hover:to-blue-50/30 hover:shadow-sm"
119
  }`}
120
  >
121
  <div className="flex items-start justify-between">
122
  <div className="flex-1">
123
+ <h4 className={`font-semibold transition-colors ${
124
+ selectedAngle?.key === angle.key
125
+ ? "text-blue-700"
126
+ : "text-gray-900"
127
+ }`}>
128
+ {angle.name}
129
+ </h4>
130
+ <p className={`text-sm mt-1 transition-colors ${
131
+ selectedAngle?.key === angle.key
132
+ ? "text-blue-600"
133
+ : "text-gray-600"
134
+ }`}>
135
+ {angle.trigger}
136
+ </p>
137
  <p className="text-xs text-gray-500 mt-1">{category}</p>
138
  </div>
139
  </div>
frontend/components/matrix/ConceptSelector.tsx CHANGED
@@ -146,9 +146,11 @@ export const ConceptSelector: React.FC<ConceptSelectorProps> = ({
146
  }));
147
 
148
  return (
149
- <Card variant="glass">
150
  <CardHeader>
151
- <CardTitle>Select Concept</CardTitle>
 
 
152
  {angleKey && (
153
  <div className="mt-2">
154
  <label className="flex items-center space-x-2">
@@ -185,16 +187,28 @@ export const ConceptSelector: React.FC<ConceptSelectorProps> = ({
185
  <div
186
  key={concept.key}
187
  onClick={() => onSelect?.(concept)}
188
- className={`p-3 rounded-lg border cursor-pointer transition-colors ${
189
  selectedConcept?.key === concept.key
190
- ? "border-blue-500 bg-blue-50"
191
- : "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
192
  }`}
193
  >
194
  <div className="flex items-start justify-between">
195
  <div className="flex-1">
196
- <h4 className="font-semibold text-gray-900">{concept.name}</h4>
197
- <p className="text-sm text-gray-600 mt-1">{concept.structure}</p>
 
 
 
 
 
 
 
 
 
 
 
 
198
  <p className="text-xs text-gray-500 mt-1">{category}</p>
199
  </div>
200
  </div>
 
146
  }));
147
 
148
  return (
149
+ <Card variant="glass" className="border-2 border-transparent hover:border-cyan-200/50 transition-all duration-300">
150
  <CardHeader>
151
+ <CardTitle className="bg-gradient-to-r from-cyan-600 to-pink-600 bg-clip-text text-transparent">
152
+ Select Concept
153
+ </CardTitle>
154
  {angleKey && (
155
  <div className="mt-2">
156
  <label className="flex items-center space-x-2">
 
187
  <div
188
  key={concept.key}
189
  onClick={() => onSelect?.(concept)}
190
+ className={`p-3 rounded-lg border cursor-pointer transition-all duration-300 ${
191
  selectedConcept?.key === concept.key
192
+ ? "border-cyan-500 bg-gradient-to-r from-cyan-50 to-pink-50 shadow-md ring-2 ring-cyan-200"
193
+ : "border-gray-200 hover:border-cyan-300 hover:bg-gradient-to-r hover:from-gray-50 hover:to-cyan-50/30 hover:shadow-sm"
194
  }`}
195
  >
196
  <div className="flex items-start justify-between">
197
  <div className="flex-1">
198
+ <h4 className={`font-semibold transition-colors ${
199
+ selectedConcept?.key === concept.key
200
+ ? "text-cyan-700"
201
+ : "text-gray-900"
202
+ }`}>
203
+ {concept.name}
204
+ </h4>
205
+ <p className={`text-sm mt-1 transition-colors ${
206
+ selectedConcept?.key === concept.key
207
+ ? "text-cyan-600"
208
+ : "text-gray-600"
209
+ }`}>
210
+ {concept.structure}
211
+ </p>
212
  <p className="text-xs text-gray-500 mt-1">{category}</p>
213
  </div>
214
  </div>
frontend/lib/utils/formatters.ts CHANGED
@@ -1,9 +1,48 @@
1
  import { format, formatDistanceToNow } from "date-fns";
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  export const formatDate = (dateString: string | null | undefined): string => {
4
  if (!dateString) return "N/A";
5
  try {
6
- return format(new Date(dateString), "MMM d, yyyy 'at' h:mm a");
 
 
7
  } catch {
8
  return dateString;
9
  }
@@ -12,7 +51,9 @@ export const formatDate = (dateString: string | null | undefined): string => {
12
  export const formatRelativeDate = (dateString: string | null | undefined): string => {
13
  if (!dateString) return "N/A";
14
  try {
15
- return formatDistanceToNow(new Date(dateString), { addSuffix: true });
 
 
16
  } catch {
17
  return dateString;
18
  }
 
1
  import { format, formatDistanceToNow } from "date-fns";
2
 
3
+ /**
4
+ * Parse a date string, ensuring UTC dates are properly converted to local time.
5
+ * Handles both UTC dates (with 'Z' suffix) and dates without timezone info.
6
+ *
7
+ * @param dateString - ISO date string, potentially without timezone info
8
+ * @returns Date object in local timezone
9
+ */
10
+ const parseDate = (dateString: string): Date => {
11
+ if (!dateString || typeof dateString !== 'string') {
12
+ throw new Error('Invalid date string');
13
+ }
14
+
15
+ // If the string already has 'Z' or timezone offset, new Date() will handle it correctly
16
+ const hasTimezone = dateString.includes('Z') ||
17
+ dateString.includes('+') ||
18
+ /[+-]\d{2}:\d{2}$/.test(dateString);
19
+
20
+ if (!hasTimezone && dateString.includes('T')) {
21
+ // Date string without timezone - assume UTC and add 'Z'
22
+ // Format: "2026-01-15T06:55:00" -> "2026-01-15T06:55:00Z"
23
+ // Format: "2026-01-15T06:55:00.123" -> "2026-01-15T06:55:00.123Z"
24
+ const datePart = dateString.split('.')[0]; // Remove milliseconds if present
25
+ if (datePart.length >= 19) { // Ensure we have at least "YYYY-MM-DDTHH:mm:ss"
26
+ dateString = dateString.replace(datePart, datePart + 'Z');
27
+ }
28
+ }
29
+
30
+ const date = new Date(dateString);
31
+
32
+ // Validate the date is valid
33
+ if (isNaN(date.getTime())) {
34
+ throw new Error(`Invalid date: ${dateString}`);
35
+ }
36
+
37
+ return date;
38
+ };
39
+
40
  export const formatDate = (dateString: string | null | undefined): string => {
41
  if (!dateString) return "N/A";
42
  try {
43
+ const date = parseDate(dateString);
44
+ // Format in user's local timezone
45
+ return format(date, "MMM d, yyyy 'at' h:mm a");
46
  } catch {
47
  return dateString;
48
  }
 
51
  export const formatRelativeDate = (dateString: string | null | undefined): string => {
52
  if (!dateString) return "N/A";
53
  try {
54
+ const date = parseDate(dateString);
55
+ // Format relative time in user's local timezone
56
+ return formatDistanceToNow(date, { addSuffix: true });
57
  } catch {
58
  return dateString;
59
  }
frontend/package-lock.json CHANGED
@@ -30,7 +30,7 @@
30
  "@types/react-dom": "^19",
31
  "eslint": "^9",
32
  "eslint-config-next": "16.1.1",
33
- "tailwindcss": "^4",
34
  "typescript": "^5"
35
  }
36
  },
 
30
  "@types/react-dom": "^19",
31
  "eslint": "^9",
32
  "eslint-config-next": "16.1.1",
33
+ "tailwindcss": "^4.1.18",
34
  "typescript": "^5"
35
  }
36
  },
frontend/package.json CHANGED
@@ -31,7 +31,7 @@
31
  "@types/react-dom": "^19",
32
  "eslint": "^9",
33
  "eslint-config-next": "16.1.1",
34
- "tailwindcss": "^4",
35
  "typescript": "^5"
36
  }
37
  }
 
31
  "@types/react-dom": "^19",
32
  "eslint": "^9",
33
  "eslint-config-next": "16.1.1",
34
+ "tailwindcss": "^4.1.18",
35
  "typescript": "^5"
36
  }
37
  }
frontend/store/generationStore.ts CHANGED
@@ -1,4 +1,5 @@
1
  import { create } from "zustand";
 
2
  import type { GenerateResponse, MatrixGenerateResponse, GenerationProgress } from "../types";
3
 
4
  interface GenerationState {
@@ -6,11 +7,13 @@ interface GenerationState {
6
  progress: GenerationProgress;
7
  isGenerating: boolean;
8
  error: string | null;
 
9
 
10
  setCurrentGeneration: (ad: GenerateResponse | MatrixGenerateResponse | null) => void;
11
  setProgress: (progress: GenerationProgress) => void;
12
  setIsGenerating: (isGenerating: boolean) => void;
13
  setError: (error: string | null) => void;
 
14
  reset: () => void;
15
  }
16
 
@@ -22,18 +25,38 @@ const initialState = {
22
  },
23
  isGenerating: false,
24
  error: null as string | null,
 
25
  };
26
 
27
- export const useGenerationStore = create<GenerationState>((set) => ({
28
- ...initialState,
29
-
30
- setCurrentGeneration: (ad) => set({ currentGeneration: ad }),
31
-
32
- setProgress: (progress) => set({ progress }),
33
-
34
- setIsGenerating: (isGenerating) => set({ isGenerating }),
35
-
36
- setError: (error) => set({ error }),
37
-
38
- reset: () => set(initialState),
39
- }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { create } from "zustand";
2
+ import { persist } from "zustand/middleware";
3
  import type { GenerateResponse, MatrixGenerateResponse, GenerationProgress } from "../types";
4
 
5
  interface GenerationState {
 
7
  progress: GenerationProgress;
8
  isGenerating: boolean;
9
  error: string | null;
10
+ generationStartTime: number | null;
11
 
12
  setCurrentGeneration: (ad: GenerateResponse | MatrixGenerateResponse | null) => void;
13
  setProgress: (progress: GenerationProgress) => void;
14
  setIsGenerating: (isGenerating: boolean) => void;
15
  setError: (error: string | null) => void;
16
+ setGenerationStartTime: (time: number | null) => void;
17
  reset: () => void;
18
  }
19
 
 
25
  },
26
  isGenerating: false,
27
  error: null as string | null,
28
+ generationStartTime: null as number | null,
29
  };
30
 
31
+ // Storage key for session persistence
32
+ const STORAGE_KEY = "generation-state";
33
+
34
+ export const useGenerationStore = create<GenerationState>()(
35
+ persist(
36
+ (set) => ({
37
+ ...initialState,
38
+
39
+ setCurrentGeneration: (ad) => set({ currentGeneration: ad }),
40
+
41
+ setProgress: (progress) => set({ progress }),
42
+
43
+ setIsGenerating: (isGenerating) => set({ isGenerating }),
44
+
45
+ setError: (error) => set({ error }),
46
+
47
+ setGenerationStartTime: (time) => set({ generationStartTime: time }),
48
+
49
+ reset: () => set(initialState),
50
+ }),
51
+ {
52
+ name: STORAGE_KEY,
53
+ // Only persist when generating to avoid stale data
54
+ partialize: (state) => ({
55
+ progress: state.progress,
56
+ isGenerating: state.isGenerating,
57
+ generationStartTime: state.generationStartTime,
58
+ currentGeneration: state.isGenerating ? state.currentGeneration : null,
59
+ }),
60
+ }
61
+ )
62
+ );
services/correction.py CHANGED
@@ -199,11 +199,12 @@ Generate a structured JSON response with spelling corrections and visual improve
199
 
200
  if user_instructions:
201
  # User-specified corrections - focus only on what user wants
202
- correction_prompt = f"""The user wants to make a SPECIFIC correction to an existing image using image-to-image generation.
 
203
 
204
  User's correction request: {user_instructions}
205
 
206
- Original image prompt (for reference only): {original_prompt or "Not provided"}
207
 
208
  Create a JSON response with this exact structure:
209
  {{
@@ -228,23 +229,13 @@ CRITICAL INSTRUCTIONS FOR corrected_prompt:
228
  - The corrected_prompt must be MINIMAL and FOCUSED - only mention the specific change
229
  - DO NOT describe the entire image or recreate it
230
  - DO NOT change anything except what the user specified
 
 
 
231
  - For text changes: Use format like "Change text 'OLD' to 'NEW'" or "Replace 'X' with 'Y'"
232
  - For visual changes: Use format like "Make colors brighter" or "Adjust lighting to be softer"
233
- - Keep it under 20 words if possible
234
- - The image-to-image model will preserve everything else automatically
235
-
236
- Examples:
237
- - User: "Change 'Save 50%' to 'Save 60%'"
238
- → corrected_prompt: "Change text 'Save 50%' to 'Save 60%'"
239
-
240
- - User: "Fix spelling: 'insurrance' should be 'insurance'"
241
- → corrected_prompt: "Change text 'insurrance' to 'insurance'"
242
-
243
- - User: "Make the background brighter"
244
- → corrected_prompt: "Make background brighter"
245
-
246
- - User: "Change headline to 'Get Started Today'"
247
- → corrected_prompt: "Change headline text to 'Get Started Today'"
248
 
249
  Respond with valid JSON only, no markdown formatting."""
250
  else:
@@ -382,6 +373,7 @@ Respond with valid JSON only, no markdown formatting."""
382
  logger.info("Using full corrected prompt for auto-analysis")
383
 
384
  logger.info("Calling image service to generate corrected image...")
 
385
  image_bytes, model_used, image_url = await image_service.generate(
386
  prompt=focused_prompt,
387
  model_key="nano-banana", # Always use nano-banana for corrections
 
199
 
200
  if user_instructions:
201
  # User-specified corrections - focus only on what user wants
202
+ correction_prompt = f"""The user wants to make a SPECIFIC, MINIMAL correction to an existing image using image-to-image generation.
203
+ The model will preserve 95% of the original image - only the specific change requested should be mentioned.
204
 
205
  User's correction request: {user_instructions}
206
 
207
+ Original image prompt (for reference only - DO NOT recreate the image): {original_prompt or "Not provided"}
208
 
209
  Create a JSON response with this exact structure:
210
  {{
 
229
  - The corrected_prompt must be MINIMAL and FOCUSED - only mention the specific change
230
  - DO NOT describe the entire image or recreate it
231
  - DO NOT change anything except what the user specified
232
+ - CRITICAL: The model will preserve 95% of the original image - only mention the ONE specific change
233
+ - IMPORTANT: Start with "Remove" or "Delete" for removal requests, "Change" for replacements
234
+ - For text removal: Use format like "Remove the text 'TEXT_TO_REMOVE'" or "Delete 'TEXT_TO_REMOVE'"
235
  - For text changes: Use format like "Change text 'OLD' to 'NEW'" or "Replace 'X' with 'Y'"
236
  - For visual changes: Use format like "Make colors brighter" or "Adjust lighting to be softer"
237
+ - Keep it under 15 words if possible - be extremely concise
238
+ - DO NOT mention any other elements, colors, layout, or composition - they will be preserved automatically
 
 
 
 
 
 
 
 
 
 
 
 
 
239
 
240
  Respond with valid JSON only, no markdown formatting."""
241
  else:
 
373
  logger.info("Using full corrected prompt for auto-analysis")
374
 
375
  logger.info("Calling image service to generate corrected image...")
376
+ logger.info("Using minimal prompt to preserve original image (guidance_scale not supported by nano-banana)")
377
  image_bytes, model_used, image_url = await image_service.generate(
378
  prompt=focused_prompt,
379
  model_key="nano-banana", # Always use nano-banana for corrections
services/database.py CHANGED
@@ -26,6 +26,40 @@ class DatabaseService:
26
  self.mongodb_url = settings.mongodb_url
27
  self.db_name = settings.mongodb_db_name
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  async def connect(self):
30
  """Create connection to MongoDB."""
31
  if not self.mongodb_url:
@@ -161,16 +195,7 @@ class DatabaseService:
161
 
162
  doc = await self.collection.find_one(query)
163
  if doc:
164
- # Convert ObjectId to string for JSON serialization
165
- doc["id"] = str(doc["_id"])
166
- del doc["_id"]
167
- # Convert datetime to ISO format string
168
- if "created_at" in doc and isinstance(doc["created_at"], datetime):
169
- doc["created_at"] = doc["created_at"].isoformat()
170
- # Handle updated_at if it exists
171
- if "updated_at" in doc and isinstance(doc["updated_at"], datetime):
172
- doc["updated_at"] = doc["updated_at"].isoformat()
173
- return doc
174
  return None
175
  except Exception as e:
176
  print(f"Failed to get ad creative: {e}")
@@ -205,17 +230,7 @@ class DatabaseService:
205
  docs = await cursor.to_list(length=limit)
206
 
207
  # Convert documents to dict format
208
- results = []
209
- for doc in docs:
210
- doc["id"] = str(doc["_id"])
211
- del doc["_id"]
212
- # Convert datetime to ISO format string
213
- if "created_at" in doc and isinstance(doc["created_at"], datetime):
214
- doc["created_at"] = doc["created_at"].isoformat()
215
- # Handle updated_at if it exists
216
- if "updated_at" in doc and isinstance(doc["updated_at"], datetime):
217
- doc["updated_at"] = doc["updated_at"].isoformat()
218
- results.append(doc)
219
 
220
  return results, total_count
221
  except Exception as e:
@@ -334,13 +349,7 @@ class DatabaseService:
334
  users_collection = self.db["users"]
335
  user = await users_collection.find_one({"username": username})
336
  if user:
337
- # Convert ObjectId to string
338
- user["id"] = str(user["_id"])
339
- del user["_id"]
340
- # Convert datetime to ISO format
341
- if "created_at" in user and isinstance(user["created_at"], datetime):
342
- user["created_at"] = user["created_at"].isoformat()
343
- return user
344
  return None
345
  except Exception as e:
346
  print(f"Failed to get user: {e}")
@@ -357,13 +366,7 @@ class DatabaseService:
357
  users = await cursor.to_list(length=1000)
358
 
359
  # Convert documents
360
- results = []
361
- for user in users:
362
- user["id"] = str(user["_id"])
363
- del user["_id"]
364
- if "created_at" in user and isinstance(user["created_at"], datetime):
365
- user["created_at"] = user["created_at"].isoformat()
366
- results.append(user)
367
 
368
  return results
369
  except Exception as e:
 
26
  self.mongodb_url = settings.mongodb_url
27
  self.db_name = settings.mongodb_db_name
28
 
29
+ @staticmethod
30
+ def _datetime_to_iso_utc(dt: datetime) -> str:
31
+ """
32
+ Convert a datetime object to ISO format string with UTC timezone indicator.
33
+ Ensures the returned string always has 'Z' suffix to indicate UTC.
34
+ """
35
+ iso_str = dt.isoformat()
36
+ # Add 'Z' suffix if not present to indicate UTC
37
+ if not iso_str.endswith('Z') and '+' not in iso_str and '-' not in iso_str[-6:]:
38
+ return iso_str + "Z"
39
+ return iso_str
40
+
41
+ def _serialize_document(self, doc: Dict[str, Any]) -> Dict[str, Any]:
42
+ """
43
+ Serialize a MongoDB document for JSON response:
44
+ - Convert ObjectId to string 'id'
45
+ - Convert datetime objects to ISO strings with UTC indicator
46
+ """
47
+ if not doc:
48
+ return doc
49
+
50
+ # Convert ObjectId to string
51
+ if "_id" in doc:
52
+ doc["id"] = str(doc["_id"])
53
+ del doc["_id"]
54
+
55
+ # Convert datetime fields to ISO strings with UTC indicator
56
+ datetime_fields = ["created_at", "updated_at"]
57
+ for field in datetime_fields:
58
+ if field in doc and isinstance(doc[field], datetime):
59
+ doc[field] = self._datetime_to_iso_utc(doc[field])
60
+
61
+ return doc
62
+
63
  async def connect(self):
64
  """Create connection to MongoDB."""
65
  if not self.mongodb_url:
 
195
 
196
  doc = await self.collection.find_one(query)
197
  if doc:
198
+ return self._serialize_document(doc)
 
 
 
 
 
 
 
 
 
199
  return None
200
  except Exception as e:
201
  print(f"Failed to get ad creative: {e}")
 
230
  docs = await cursor.to_list(length=limit)
231
 
232
  # Convert documents to dict format
233
+ results = [self._serialize_document(doc) for doc in docs]
 
 
 
 
 
 
 
 
 
 
234
 
235
  return results, total_count
236
  except Exception as e:
 
349
  users_collection = self.db["users"]
350
  user = await users_collection.find_one({"username": username})
351
  if user:
352
+ return self._serialize_document(user)
 
 
 
 
 
 
353
  return None
354
  except Exception as e:
355
  print(f"Failed to get user: {e}")
 
366
  users = await cursor.to_list(length=1000)
367
 
368
  # Convert documents
369
+ results = [self._serialize_document(user) for user in users]
 
 
 
 
 
 
370
 
371
  return results
372
  except Exception as e:
services/image.py CHANGED
@@ -310,10 +310,12 @@ class ImageService:
310
  # Build input parameters
311
  input_data = {"prompt": prompt}
312
 
313
- # Add image URL for image-to-image if provided (for nano-banana-pro corrections)
314
- # Google Nano Banana expects image_input as an array
315
- if image_url and current_model == "nano-banana-pro":
316
  input_data["image_input"] = [image_url]
 
 
317
 
318
  # Add seed if supported
319
  input_data["seed"] = seed
 
310
  # Build input parameters
311
  input_data = {"prompt": prompt}
312
 
313
+ # Add image URL for image-to-image if provided (for nano-banana and nano-banana-pro)
314
+ # Google Nano Banana models expect image_input as an array
315
+ if image_url and current_model in ["nano-banana", "nano-banana-pro"]:
316
  input_data["image_input"] = [image_url]
317
+ # Note: guidance_scale may not be supported by nano-banana on Replicate
318
+ # Relying on minimal prompts to preserve the original image
319
 
320
  # Add seed if supported
321
  input_data["seed"] = seed