返回 presentation-ai
GenerateSlideUI.tsx
根目录 / src / components / notebook / presentation / editor / custom-elements / GenerateSlideUI.tsx
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
256 lines Plain Text