返回 presentation-ai
InfographicGenerationPanel.tsx
根目录 / src / components / presentation / edit-panel / sections / InfographicGenerationPanel.tsx
1 "use client";
2
3 import {
4 ChartNoAxesColumnIncreasing,
5 Loader2,
6 Sparkles,
7 WandSparkles,
8 X,
9 } from "lucide-react";
10 import { useSession } from "next-auth/react";
11 import { useMemo, useState } from "react";
12 import { toast } from "sonner";
13
14 import { generateInfographicImageAction } from "@/app/_actions/apps/image-studio/generate-infographic";
15 import { type RootImage } from "@/components/notebook/presentation/utils/parser";
16 import { Button } from "@/components/ui/button";
17 import { Label } from "@/components/ui/label";
18 import { ScrollArea } from "@/components/ui/scroll-area";
19 import {
20 Select,
21 SelectContent,
22 SelectItem,
23 SelectTrigger,
24 SelectValue,
25 } from "@/components/ui/select";
26 import { Textarea } from "@/components/ui/textarea";
27 import {
28 DEFAULT_IMAGE_MODEL,
29 getAvailableImageModels,
30 type ImageModelList,
31 } from "@/constants/image-models";
32 import { useDebouncedSave } from "@/hooks/presentation/useDebouncedSave";
33 import { usePresentationState } from "@/states/presentation-state";
34
35 const LAYOUTS = [
36 "Timeline",
37 "Process",
38 "Comparison",
39 "Hierarchy",
40 "Cycle",
41 "Roadmap",
42 "Matrix",
43 ] as const;
44
45 type InfographicGenerationResponse = {
46 image?: {
47 id: string;
48 prompt: string;
49 url: string;
50 };
51 error?: string;
52 };
53
54 export function InfographicGenerationPanel() {
55 const { data: session } = useSession();
56 const isAdmin = session?.user?.isAdmin === true;
57 const availableImageModels = useMemo(
58 () => getAvailableImageModels(isAdmin),
59 [isAdmin],
60 );
61 const { saveImmediately } = useDebouncedSave();
62 const closeInfographicGenerationEditor = usePresentationState(
63 (s) => s.closeInfographicGenerationEditor,
64 );
65 const currentSlideId = usePresentationState((s) => s.currentSlideId);
66 const slides = usePresentationState((s) => s.slides);
67 const setSlides = usePresentationState((s) => s.setSlides);
68 const imageModel = usePresentationState((s) => s.imageModel);
69 const setImageModel = usePresentationState((s) => s.setImageModel);
70 const boundUpdateElement = usePresentationState((s) => s.boundUpdateElement);
71
72 const currentSlide = slides.find((slide) => slide.id === currentSlideId);
73 const existingPrompt = currentSlide?.rootImage?.query ?? "";
74 const [prompt, setPrompt] = useState(existingPrompt);
75
76 const [layout, setLayout] = useState<(typeof LAYOUTS)[number]>("Timeline");
77 const [isGenerating, setIsGenerating] = useState(false);
78 const [generatedImage, setGeneratedImage] = useState<
79 InfographicGenerationResponse["image"] | null
80 >(null);
81
82 const selectedModel = useMemo(
83 () =>
84 availableImageModels.some((model) => model.value === imageModel)
85 ? imageModel
86 : DEFAULT_IMAGE_MODEL,
87 [availableImageModels, imageModel],
88 );
89
90 const applyGeneratedImage = (url: string, query: string) => {
91 if (boundUpdateElement) {
92 boundUpdateElement({
93 id: "",
94 provider: "infographic",
95 query,
96 url,
97 });
98 void saveImmediately();
99 return;
100 }
101
102 if (!currentSlideId) return;
103
104 setSlides((existingSlides) =>
105 existingSlides.map((slide) =>
106 slide.id === currentSlideId
107 ? {
108 ...slide,
109 rootImage: {
110 ...(slide.rootImage ?? { query: "" }),
111 query,
112 url,
113 embedType: "infographic",
114 imageSource: "generate",
115 chartType: undefined,
116 chartData: undefined,
117 chartOptions: undefined,
118 paletteDropMutable: false,
119 } satisfies RootImage,
120 }
121 : slide,
122 ),
123 );
124 void saveImmediately();
125 };
126
127 const handleGenerate = async () => {
128 const trimmedPrompt = prompt.trim();
129 if (!trimmedPrompt) return;
130
131 setIsGenerating(true);
132 setGeneratedImage(null);
133
134 try {
135 const result = await generateInfographicImageAction({
136 prompt: trimmedPrompt,
137 layout,
138 model: selectedModel,
139 });
140
141 if (!result.success || !result.image) {
142 throw new Error(result.error ?? "Failed to generate infographic");
143 }
144
145 setGeneratedImage(result.image);
146 applyGeneratedImage(result.image.url, trimmedPrompt);
147 toast.success("Infographic added to slide");
148 } catch (error) {
149 toast.error(
150 error instanceof Error
151 ? error.message
152 : "Failed to generate infographic",
153 );
154 } finally {
155 setIsGenerating(false);
156 }
157 };
158
159 return (
160 <div className="flex h-full w-full flex-col border-l bg-background">
161 <div className="flex items-center justify-between border-b px-4 py-2">
162 <div className="flex items-center gap-2">
163 <ChartNoAxesColumnIncreasing className="size-4 text-primary" />
164 <h2 className="text-sm font-semibold">AI Infographics</h2>
165 </div>
166 <Button
167 variant="ghost"
168 size="icon"
169 onClick={closeInfographicGenerationEditor}
170 className="size-8 rounded-full p-0"
171 >
172 <X className="size-5" />
173 </Button>
174 </div>
175
176 <ScrollArea className="flex-1">
177 <div className="space-y-5 p-6">
178 {(() => {
179 const previewUrl =
180 generatedImage?.url ?? currentSlide?.rootImage?.url;
181 return previewUrl ? (
182 <div className="group relative overflow-hidden rounded-md border bg-muted/30 animate-in fade-in duration-300">
183 {/** biome-ignore lint/performance/noImgElement: Generated infographic preview — URL is already persisted externally */}
184 <img
185 src={previewUrl}
186 alt="Infographic preview"
187 className="aspect-video w-full object-cover"
188 loading="lazy"
189 decoding="async"
190 />
191 <div className="absolute inset-0 flex items-end justify-end bg-linear-to-t from-black/40 to-transparent p-3 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
192 <span className="rounded-full bg-background/80 px-2.5 py-1 text-xs font-medium backdrop-blur-sm">
193 Preview
194 </span>
195 </div>
196 </div>
197 ) : (
198 <div className="overflow-hidden rounded-md border bg-muted/30">
199 <div className="flex flex-col items-center justify-center gap-4 px-6 py-9 text-center">
200 {/** biome-ignore lint/performance/noImgElement: Project placeholder SVG is reused as requested */}
201 <img
202 src="/placeholder.svg"
203 alt=""
204 className="h-24 w-32 rounded-md object-cover"
205 />
206 <div className="space-y-1">
207 <h3 className="text-xl font-semibold">
208 Create an infographic
209 </h3>
210 <p className="text-sm text-muted-foreground">
211 Describe the content and layout you want for your
212 infographic
213 </p>
214 </div>
215 </div>
216 </div>
217 );
218 })()}
219
220 <div className="space-y-2">
221 <Label htmlFor="infographic-prompt">Prompt</Label>
222 <div className="rounded-md border border-primary/70 focus-within:ring-2 focus-within:ring-primary/20">
223 <Textarea
224 id="infographic-prompt"
225 value={prompt}
226 onChange={(event) => setPrompt(event.target.value)}
227 placeholder="Describe the process, timeline, or comparison you'd like to visualize (e.g., The 5 stages of business growth)..."
228 className="min-h-26 resize-none border-0 text-base shadow-none focus-visible:ring-0"
229 />
230 <div className="flex justify-end px-3 pb-3">
231 <Button
232 type="button"
233 variant="ghost"
234 size="sm"
235 className="gap-2 text-primary"
236 onClick={() =>
237 setPrompt((currentPrompt) =>
238 currentPrompt.trim()
239 ? `${currentPrompt.trim()}. Include a crisp title, clear stage labels, concise data callouts, directional flow, and presentation-grade visual hierarchy.`
240 : currentPrompt,
241 )
242 }
243 disabled={!prompt.trim() || isGenerating}
244 >
245 Enhance prompt
246 <WandSparkles className="size-4" />
247 </Button>
248 </div>
249 </div>
250 </div>
251
252 <div className="space-y-2">
253 <Label>Layout</Label>
254 <Select
255 value={layout}
256 onValueChange={(value) =>
257 setLayout(value as (typeof LAYOUTS)[number])
258 }
259 >
260 <SelectTrigger className="h-10 rounded-full">
261 <SelectValue />
262 </SelectTrigger>
263 <SelectContent>
264 {LAYOUTS.map((item) => (
265 <SelectItem key={item} value={item}>
266 {item}
267 </SelectItem>
268 ))}
269 </SelectContent>
270 </Select>
271 </div>
272
273 <div className="space-y-2">
274 <Label>Model</Label>
275 <Select
276 value={selectedModel}
277 onValueChange={(value) => setImageModel(value as ImageModelList)}
278 >
279 <SelectTrigger className="h-10">
280 <SelectValue />
281 </SelectTrigger>
282 <SelectContent>
283 {availableImageModels.map((model) => (
284 <SelectItem key={model.value} value={model.value}>
285 {model.label}
286 </SelectItem>
287 ))}
288 </SelectContent>
289 </Select>
290 </div>
291
292 <Button
293 className="h-12 w-full rounded-full text-base font-semibold"
294 onClick={handleGenerate}
295 disabled={
296 !prompt.trim() ||
297 isGenerating ||
298 (!currentSlideId && !boundUpdateElement)
299 }
300 >
301 {isGenerating ? (
302 <>
303 <Loader2 className="mr-2 size-4 animate-spin" />
304 Generating
305 </>
306 ) : (
307 <>
308 <Sparkles className="mr-2 size-4" />
309 Generate
310 </>
311 )}
312 </Button>
313 </div>
314 </ScrollArea>
315 </div>
316 );
317 }
318
318 lines Plain Text