返回 presentation-ai
SharedGenerateControls.tsx
根目录 / src / components / presentation / shared / SharedGenerateControls.tsx
1 "use client";
2
3 import { AlertTriangle, Check, Loader2, Sparkles } from "lucide-react";
4 import { useSession } from "next-auth/react";
5 import { useEffect, useMemo, useState } from "react";
6
7 import { type Image as GeneratedImage } from "@/app/_actions/apps/image-studio/fetch";
8 import { generateImageAction } from "@/app/_actions/apps/image-studio/generate";
9 import { Alert, AlertDescription } from "@/components/ui/alert";
10 import { Button } from "@/components/ui/button";
11 import { Label } from "@/components/ui/label";
12 import { ScrollArea } from "@/components/ui/scroll-area";
13 import {
14 Select,
15 SelectContent,
16 SelectItem,
17 SelectTrigger,
18 SelectValue,
19 } from "@/components/ui/select";
20 import { Separator } from "@/components/ui/separator";
21 import { Textarea } from "@/components/ui/textarea";
22 import {
23 DEFAULT_IMAGE_MODEL,
24 getAvailableImageModels,
25 type ImageAspectRatio,
26 type ImageModelList,
27 } from "@/constants/image-models";
28 import {
29 buildPresentationThemedImagePrompt,
30 resolvePresentationImageTheme,
31 } from "@/lib/presentation/image-generation-theme-prompt";
32 import { cn } from "@/lib/utils";
33 import { usePresentationState } from "@/states/presentation-state";
34
35 interface SharedGenerateControlsProps {
36 onImageSelect: (url: string, prompt: string) => void;
37 initialPrompt?: string;
38 className?: string;
39 onImagesGenerated?: (images: GeneratedImage[]) => void;
40 // showGallery prop removed as requested
41 }
42
43 const ART_STYLES = [
44 { id: "none", label: "None", value: "" },
45 {
46 id: "photorealistic",
47 label: "Photorealistic",
48 value: "photorealistic, highly detailed, 8k",
49 },
50 {
51 id: "illustration",
52 label: "Illustration",
53 value: "illustration, vector art, flat style",
54 },
55 {
56 id: "3d-render",
57 label: "3D Render",
58 value: "3d render, unreal engine 5, octane render",
59 },
60 { id: "abstract", label: "Abstract", value: "abstract, artistic, colorful" },
61 {
62 id: "watercolor",
63 label: "Watercolor",
64 value: "watercolor painting, artistic, soft colors",
65 },
66 {
67 id: "cyberpunk",
68 label: "Cyberpunk",
69 value: "cyberpunk, neon lights, futuristic",
70 },
71 { id: "anime", label: "Anime", value: "anime style, studio ghibli, vibrant" },
72 {
73 id: "oil-painting",
74 label: "Oil Painting",
75 value: "oil painting, textured, canvas",
76 },
77 ];
78
79 const ASPECT_RATIOS = [
80 { label: "1:1 (Square)", value: "1:1" },
81 { label: "4:3 (Standard)", value: "4:3" },
82 { label: "3:4 (Portrait)", value: "3:4" },
83 { label: "16:9 (Widescreen)", value: "16:9" },
84 { label: "9:16 (Mobile)", value: "9:16" },
85 ];
86
87 const IMAGE_COUNTS = [1, 2, 3, 4];
88
89 export function SharedGenerateControls({
90 onImageSelect,
91 initialPrompt = "",
92 className,
93 onImagesGenerated,
94 }: SharedGenerateControlsProps) {
95 const { data: session } = useSession();
96 const imageModels = useMemo(
97 () => getAvailableImageModels(session?.user?.isAdmin === true),
98 [session?.user?.isAdmin],
99 );
100 const {
101 imageModel,
102 setImageModel,
103 generatedImageCache,
104 setGeneratedImageCache,
105 theme,
106 customThemeData,
107 } = usePresentationState();
108 const [newPrompt, setNewPrompt] = useState(initialPrompt);
109 const [localError, setLocalError] = useState<string | null>(null);
110 const [isGenerating, setIsGenerating] = useState(false);
111
112 // New state for enhanced controls
113 const [selectedStyle, setSelectedStyle] = useState(ART_STYLES[0]?.id);
114 const [aspectRatio, setAspectRatio] = useState<ImageAspectRatio>("16:9");
115 const [imageCount, setImageCount] = useState(1);
116
117 const buildStyledPrompt = () => {
118 const styleSuffix =
119 ART_STYLES.find((s) => s.id === selectedStyle)?.value || "";
120 return styleSuffix
121 ? `${newPrompt.trim()}, ${styleSuffix}`
122 : newPrompt.trim();
123 };
124
125 const buildFullPrompt = () => {
126 const styledPrompt = buildStyledPrompt();
127 const currentTheme = resolvePresentationImageTheme(theme, customThemeData);
128
129 return buildPresentationThemedImagePrompt(styledPrompt, currentTheme);
130 };
131
132 // Get cached images for current prompt
133 const cacheKey = newPrompt.trim() ? buildFullPrompt() : "";
134 const lastGeneratedImages = cacheKey
135 ? (generatedImageCache[cacheKey] ?? [])
136 : [];
137
138 // Update prompt when initialPrompt changes
139 useEffect(() => {
140 if (initialPrompt) {
141 setNewPrompt(initialPrompt);
142 }
143 }, [initialPrompt]);
144
145 const handleGenerateClick = async () => {
146 if (!newPrompt.trim()) return;
147
148 setLocalError(null);
149 setIsGenerating(true);
150
151 try {
152 const fullPrompt = buildFullPrompt();
153
154 // Generate multiple images sequentially (or parallel if backend supports, here sequential for safety)
155 const promises = Array(imageCount)
156 .fill(null)
157 .map(() =>
158 generateImageAction(
159 fullPrompt,
160 imageModels.some((model) => model.value === imageModel)
161 ? imageModel
162 : DEFAULT_IMAGE_MODEL,
163 aspectRatio,
164 ),
165 );
166
167 const results = await Promise.all(promises);
168
169 const successfulImages: GeneratedImage[] = [];
170 let firstError: string | undefined;
171
172 for (const result of results) {
173 if (result.success && "image" in result && result.image) {
174 successfulImages.push(result.image as unknown as GeneratedImage);
175 } else if (!firstError && "error" in result) {
176 firstError = result.error;
177 }
178 }
179
180 if (successfulImages.length > 0) {
181 setGeneratedImageCache(fullPrompt, successfulImages);
182 onImagesGenerated?.(successfulImages);
183 } else {
184 setLocalError(firstError ?? "Failed to generate images");
185 }
186 } catch (error) {
187 setLocalError(
188 error instanceof Error ? error.message : "Failed to generate image",
189 );
190 } finally {
191 setIsGenerating(false);
192 }
193 };
194
195 return (
196 <div className={cn("flex h-full flex-col space-y-4", className)}>
197 {localError && (
198 <Alert variant="destructive">
199 <AlertTriangle className="h-4 w-4" />
200 <AlertDescription>{localError}</AlertDescription>
201 </Alert>
202 )}
203
204 {/* Prompt Section */}
205 <div className="space-y-3">
206 <Label className="text-sm font-medium">
207 Prompt
208 </Label>
209 <Textarea
210 placeholder="Describe the image you want to create..."
211 className="min-h-20 resize-none text-base"
212 value={newPrompt}
213 onChange={(e) => setNewPrompt(e.target.value)}
214 disabled={isGenerating}
215 />
216
217 {/* Generate Button - Moved up */}
218 <Button
219 variant="default"
220 className="h-10 w-full bg-linear-to-r from-primary to-primary/90 text-base shadow-lg shadow-primary/20 transition-all hover:from-primary/90 hover:to-primary"
221 onClick={handleGenerateClick}
222 disabled={isGenerating || !newPrompt.trim()}
223 >
224 {isGenerating ? (
225 <>
226 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
227 Generating...
228 </>
229 ) : (
230 <>
231 <Sparkles className="mr-2 h-4 w-4" />
232 Generate
233 </>
234 )}
235 </Button>
236 </div>
237
238 {/* Results & Loading State - Only render container when there's content */}
239 {(isGenerating || lastGeneratedImages.length > 0) && (
240 <div className="flex min-h-0 flex-1 flex-col">
241 {isGenerating ? (
242 <div className="flex h-48 animate-in flex-col items-center justify-center rounded-lg border bg-muted/30 duration-300 zoom-in-95 fade-in">
243 <Loader2 className="mb-4 h-8 w-8 animate-spin text-primary" />
244 <p className="animate-pulse text-center text-sm text-muted-foreground">
245 Dreaming up your image...
246 <br />
247 <span className="text-xs opacity-70">
248 This typically takes 5-10 seconds
249 </span>
250 </p>
251 </div>
252 ) : (
253 <ScrollArea className="-mx-2 flex-1 px-2">
254 <div className="grid grid-cols-2 gap-2 pb-4">
255 {lastGeneratedImages.map((img) => (
256 <div
257 key={img.id}
258 className={cn(
259 "group relative animate-in overflow-hidden rounded-lg border-2 border-primary shadow-md duration-300 zoom-in-95 fade-in",
260 aspectRatio === "16:9" && "aspect-video",
261 aspectRatio === "1:1" && "aspect-square",
262 aspectRatio === "4:3" && "aspect-4/3",
263 aspectRatio === "3:4" && "aspect-3/4",
264 aspectRatio === "9:16" && "aspect-9/16",
265 )}
266 >
267 {/** biome-ignore lint/performance/noImgElement: Without this it is not possible to show image links */}
268 <img
269 src={img.url}
270 alt={img.prompt}
271 className="h-full w-full object-cover transition-transform group-hover:scale-105"
272 />
273 <div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/40 opacity-0 backdrop-blur-[1px] transition-opacity group-hover:opacity-100">
274 <Button
275 size="icon"
276 variant="secondary"
277 className="h-8 w-8 rounded-full shadow-lg"
278 onClick={() =>
279 onImageSelect(img.url, buildStyledPrompt())
280 }
281 >
282 <Check className="h-4 w-4" />
283 </Button>
284 </div>
285 </div>
286 ))}
287 </div>
288 </ScrollArea>
289 )}
290 </div>
291 )}
292
293 <Separator />
294
295 {/* Settings Section - Push down */}
296 <div className="space-y-4 pt-1">
297 <h4 className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">
298 Style & Settings
299 </h4>
300
301 <div className="grid grid-cols-2 gap-4">
302 {/* Art Style */}
303 <div className="col-span-2 space-y-2">
304 <Label className="text-xs font-medium text-muted-foreground">
305 Art style
306 </Label>
307 <div className="grid grid-cols-3 gap-2">
308 {ART_STYLES.slice(0, 6).map((style) => (
309 <Button
310 key={style.id}
311 variant={selectedStyle === style.id ? "default" : "outline"}
312 size="sm"
313 className={cn(
314 "h-8 justify-start px-2 text-xs",
315 selectedStyle === style.id &&
316 "bg-primary text-primary-foreground",
317 )}
318 onClick={() => setSelectedStyle(style.id)}
319 >
320 {style.label}
321 </Button>
322 ))}
323 <Select value={selectedStyle} onValueChange={setSelectedStyle}>
324 <SelectTrigger className="h-8 text-xs">
325 <SelectValue placeholder="More" />
326 </SelectTrigger>
327 <SelectContent>
328 {ART_STYLES.map((style) => (
329 <SelectItem key={style.id} value={style.id}>
330 {style.label}
331 </SelectItem>
332 ))}
333 </SelectContent>
334 </Select>
335 </div>
336 </div>
337
338 {/* Aspect Ratio */}
339 <div className="space-y-2">
340 <Label className="text-xs font-medium text-muted-foreground">
341 Aspect ratio
342 </Label>
343 <Select
344 value={aspectRatio}
345 onValueChange={(v) => setAspectRatio(v as ImageAspectRatio)}
346 >
347 <SelectTrigger className="h-8 text-xs">
348 <SelectValue />
349 </SelectTrigger>
350 <SelectContent>
351 {ASPECT_RATIOS.map((ratio) => (
352 <SelectItem key={ratio.value} value={ratio.value}>
353 {ratio.label}
354 </SelectItem>
355 ))}
356 </SelectContent>
357 </Select>
358 </div>
359
360 {/* Image Count */}
361 <div className="space-y-2">
362 <Label className="text-xs font-medium text-muted-foreground">
363 Image count
364 </Label>
365 <Select
366 value={imageCount.toString()}
367 onValueChange={(v) => setImageCount(parseInt(v, 10))}
368 >
369 <SelectTrigger className="h-8 text-xs">
370 <SelectValue />
371 </SelectTrigger>
372 <SelectContent>
373 {IMAGE_COUNTS.map((count) => (
374 <SelectItem key={count} value={count.toString()}>
375 {count}
376 </SelectItem>
377 ))}
378 </SelectContent>
379 </Select>
380 </div>
381
382 {/* Model */}
383 <div className="col-span-2 space-y-2">
384 <Label className="text-xs font-medium text-muted-foreground">
385 Model
386 </Label>
387 <Select
388 value={imageModel}
389 onValueChange={(v) => setImageModel(v as ImageModelList)}
390 >
391 <SelectTrigger className="h-8 text-xs">
392 <SelectValue />
393 </SelectTrigger>
394 <SelectContent>
395 {imageModels.map((model) => (
396 <SelectItem key={model.value} value={model.value}>
397 {model.label}
398 </SelectItem>
399 ))}
400 </SelectContent>
401 </Select>
402 </div>
403 </div>
404 </div>
405 </div>
406 );
407 }
408
408 lines Plain Text