| 1 | "use client"; |
| 2 | |
| 3 | import { Loader2, Sparkles, X } from "lucide-react"; |
| 4 | import { m as motion } from "motion/react"; |
| 5 | import { useSession } from "next-auth/react"; |
| 6 | import { useCallback, useMemo, useState } from "react"; |
| 7 | |
| 8 | import { Button } from "@/components/ui/button"; |
| 9 | import { Label } from "@/components/ui/label"; |
| 10 | import { |
| 11 | Select, |
| 12 | SelectContent, |
| 13 | SelectItem, |
| 14 | SelectTrigger, |
| 15 | SelectValue, |
| 16 | } from "@/components/ui/select"; |
| 17 | import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; |
| 18 | import { Textarea } from "@/components/ui/textarea"; |
| 19 | import { |
| 20 | DEFAULT_IMAGE_MODEL, |
| 21 | getAvailableImageModels, |
| 22 | type ImageModelList, |
| 23 | } from "@/constants/image-models"; |
| 24 | import { useSlideGeneration } from "../context/SlideGenerationContext"; |
| 25 | |
| 26 | interface GenerateSlideUIProps { |
| 27 | slideId: string; |
| 28 | onClose: () => void; |
| 29 | } |
| 30 | |
| 31 | type ContentType = "Slide" | "Infograph"; |
| 32 | type ImageStyle = "3D" | "Sketch" | "Flat"; |
| 33 | type TextDensity = "Minimal" | "Balanced" | "Detailed"; |
| 34 | |
| 35 | const IMAGE_STYLES: ImageStyle[] = ["3D", "Sketch", "Flat"]; |
| 36 | |
| 37 | const TEXT_DENSITIES: TextDensity[] = ["Minimal", "Balanced", "Detailed"]; |
| 38 | |
| 39 | export function GenerateSlideUI({ slideId, onClose }: GenerateSlideUIProps) { |
| 40 | const { data: session } = useSession(); |
| 41 | const imageModels = useMemo( |
| 42 | () => getAvailableImageModels(session?.user?.isAdmin === true), |
| 43 | [session?.user?.isAdmin], |
| 44 | ); |
| 45 | const [prompt, setPrompt] = useState(""); |
| 46 | const [contentType, setContentType] = useState<ContentType>("Slide"); |
| 47 | const [imageModel, setImageModel] = |
| 48 | useState<ImageModelList>(DEFAULT_IMAGE_MODEL); |
| 49 | const [imageStyle, setImageStyle] = useState<ImageStyle>("3D"); |
| 50 | const [textDensity, setTextDensity] = useState<TextDensity>("Balanced"); |
| 51 | const { isGenerating, generatingSlideId, generateSlide, cancelGeneration } = |
| 52 | useSlideGeneration(); |
| 53 | |
| 54 | // Check if we're generating for THIS slide |
| 55 | const isGeneratingThisSlide = isGenerating && generatingSlideId === slideId; |
| 56 | |
| 57 | const handleSubmit = useCallback( |
| 58 | (e: React.FormEvent) => { |
| 59 | e.preventDefault(); |
| 60 | if (!prompt.trim() || isGeneratingThisSlide) return; |
| 61 | |
| 62 | generateSlide(slideId, prompt.trim(), { |
| 63 | slideType: contentType === "Infograph" ? "image" : "standard", |
| 64 | imageStyle, |
| 65 | textDensity, |
| 66 | imageModel: imageModels.some((model) => model.value === imageModel) |
| 67 | ? imageModel |
| 68 | : DEFAULT_IMAGE_MODEL, |
| 69 | }); |
| 70 | }, |
| 71 | [ |
| 72 | prompt, |
| 73 | isGeneratingThisSlide, |
| 74 | generateSlide, |
| 75 | slideId, |
| 76 | contentType, |
| 77 | imageStyle, |
| 78 | textDensity, |
| 79 | imageModel, |
| 80 | imageModels, |
| 81 | ], |
| 82 | ); |
| 83 | |
| 84 | const handleCancel = useCallback(() => { |
| 85 | if (isGeneratingThisSlide) { |
| 86 | cancelGeneration(); |
| 87 | } |
| 88 | onClose(); |
| 89 | }, [isGeneratingThisSlide, cancelGeneration, onClose]); |
| 90 | |
| 91 | const handleKeyDown = useCallback( |
| 92 | (e: React.KeyboardEvent<HTMLTextAreaElement>) => { |
| 93 | if (e.key === "Enter" && !e.shiftKey) { |
| 94 | e.preventDefault(); |
| 95 | handleSubmit(e); |
| 96 | } |
| 97 | }, |
| 98 | [handleSubmit], |
| 99 | ); |
| 100 | |
| 101 | return ( |
| 102 | <div |
| 103 | data-slate-void="true" |
| 104 | contentEditable={false} |
| 105 | className="pointer-events-auto select-none" |
| 106 | > |
| 107 | <motion.div |
| 108 | initial={{ opacity: 0, y: 10 }} |
| 109 | animate={{ opacity: 1, y: 0 }} |
| 110 | exit={{ opacity: 0, y: -10 }} |
| 111 | transition={{ duration: 0.3, ease: "easeOut" }} |
| 112 | className="mt-6 px-6 sm:px-10 lg:px-16" |
| 113 | > |
| 114 | {!isGeneratingThisSlide ? ( |
| 115 | <form onSubmit={handleSubmit}> |
| 116 | <div className="flex flex-col gap-4 rounded-xl border bg-card p-6 text-card-foreground shadow-sm dark:bg-slate-950 dark:border-slate-800"> |
| 117 | <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> |
| 118 | <div className="space-y-1.5"> |
| 119 | <h3 className="font-semibold leading-none tracking-tight flex items-center gap-2"> |
| 120 | <Sparkles className="h-4 w-4 text-primary" /> |
| 121 | Generate Slide |
| 122 | </h3> |
| 123 | <p className="text-sm text-muted-foreground"> |
| 124 | Describe what you want on this slide and AI will generate it |
| 125 | for you. |
| 126 | </p> |
| 127 | </div> |
| 128 | <Tabs |
| 129 | value={contentType} |
| 130 | onValueChange={(value) => |
| 131 | setContentType(value as ContentType) |
| 132 | } |
| 133 | className="w-full sm:w-auto" |
| 134 | > |
| 135 | <TabsList className="grid w-full grid-cols-2"> |
| 136 | <TabsTrigger value="Slide">Standard Slide</TabsTrigger> |
| 137 | <TabsTrigger value="Infograph">Infographic</TabsTrigger> |
| 138 | </TabsList> |
| 139 | </Tabs> |
| 140 | </div> |
| 141 | |
| 142 | {contentType === "Infograph" && ( |
| 143 | <div className="flex flex-wrap items-center gap-4 py-3 border-y border-border/50"> |
| 144 | <div className="flex flex-col space-y-2 flex-1 min-w-35"> |
| 145 | <Label className="text-xs font-medium text-muted-foreground"> |
| 146 | Model |
| 147 | </Label> |
| 148 | <Select |
| 149 | value={imageModel} |
| 150 | onValueChange={(value) => |
| 151 | setImageModel(value as ImageModelList) |
| 152 | } |
| 153 | > |
| 154 | <SelectTrigger className="h-9"> |
| 155 | <SelectValue /> |
| 156 | </SelectTrigger> |
| 157 | <SelectContent> |
| 158 | {imageModels.map((model) => ( |
| 159 | <SelectItem key={model.value} value={model.value}> |
| 160 | {model.label} |
| 161 | </SelectItem> |
| 162 | ))} |
| 163 | </SelectContent> |
| 164 | </Select> |
| 165 | </div> |
| 166 | |
| 167 | <div className="flex flex-col space-y-2 flex-1 min-w-35"> |
| 168 | <Label className="text-xs font-medium text-muted-foreground"> |
| 169 | Style |
| 170 | </Label> |
| 171 | <Select |
| 172 | value={imageStyle} |
| 173 | onValueChange={(value) => |
| 174 | setImageStyle(value as ImageStyle) |
| 175 | } |
| 176 | > |
| 177 | <SelectTrigger className="h-9"> |
| 178 | <SelectValue /> |
| 179 | </SelectTrigger> |
| 180 | <SelectContent> |
| 181 | {IMAGE_STYLES.map((style) => ( |
| 182 | <SelectItem key={style} value={style}> |
| 183 | {style} |
| 184 | </SelectItem> |
| 185 | ))} |
| 186 | </SelectContent> |
| 187 | </Select> |
| 188 | </div> |
| 189 | |
| 190 | <div className="flex flex-col space-y-2 flex-1 min-w-35"> |
| 191 | <Label className="text-xs font-medium text-muted-foreground"> |
| 192 | Text Density |
| 193 | </Label> |
| 194 | <Select |
| 195 | value={textDensity} |
| 196 | onValueChange={(value) => |
| 197 | setTextDensity(value as TextDensity) |
| 198 | } |
| 199 | > |
| 200 | <SelectTrigger className="h-9"> |
| 201 | <SelectValue /> |
| 202 | </SelectTrigger> |
| 203 | <SelectContent> |
| 204 | {TEXT_DENSITIES.map((density) => ( |
| 205 | <SelectItem key={density} value={density}> |
| 206 | {density} |
| 207 | </SelectItem> |
| 208 | ))} |
| 209 | </SelectContent> |
| 210 | </Select> |
| 211 | </div> |
| 212 | </div> |
| 213 | )} |
| 214 | |
| 215 | <Textarea |
| 216 | value={prompt} |
| 217 | onChange={(e) => setPrompt(e.target.value)} |
| 218 | onKeyDown={handleKeyDown} |
| 219 | placeholder="E.g., A comparison between Q1 and Q2 marketing strategies..." |
| 220 | rows={4} |
| 221 | className="resize-none focus-visible:ring-1" |
| 222 | /> |
| 223 | |
| 224 | <div className="flex items-center justify-end gap-3 pt-2"> |
| 225 | <Button type="button" variant="ghost" onClick={handleCancel}> |
| 226 | <X className="mr-2 h-4 w-4" /> |
| 227 | Cancel |
| 228 | </Button> |
| 229 | <Button type="submit" disabled={!prompt.trim()}> |
| 230 | <Sparkles className="mr-2 h-4 w-4" /> |
| 231 | Generate |
| 232 | </Button> |
| 233 | </div> |
| 234 | </div> |
| 235 | </form> |
| 236 | ) : ( |
| 237 | <div className="flex flex-col items-center justify-center py-12 px-4 rounded-xl border border-border/50 bg-muted/20 backdrop-blur-sm"> |
| 238 | <Loader2 className="h-10 w-10 animate-spin text-primary mb-4" /> |
| 239 | <h3 className="font-medium text-lg mb-2"> |
| 240 | Generating your slide... |
| 241 | </h3> |
| 242 | <p className="text-sm text-muted-foreground text-center max-w-sm mb-6"> |
| 243 | AI is currently designing the layout, writing the content, and |
| 244 | sourcing the perfect images. |
| 245 | </p> |
| 246 | <Button variant="outline" onClick={handleCancel}> |
| 247 | <X className="mr-2 h-4 w-4" /> |
| 248 | Cancel Generation |
| 249 | </Button> |
| 250 | </div> |
| 251 | )} |
| 252 | </motion.div> |
| 253 | </div> |
| 254 | ); |
| 255 | } |
| 256 |