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