返回 presentation-ai
Header.tsx
1 "use client";
2
3 import {
4 Check,
5 ChevronDown,
6 Languages,
7 Layers,
8 Paperclip,
9 RefreshCw,
10 Search,
11 } from "lucide-react";
12 import { useState } from "react";
13 import { toast } from "sonner";
14
15 import { Badge } from "@/components/ui/badge";
16 import { Button } from "@/components/ui/button";
17 import {
18 Collapsible,
19 CollapsibleContent,
20 CollapsibleTrigger,
21 } from "@/components/ui/collapsible";
22 import {
23 Select,
24 SelectContent,
25 SelectItem,
26 SelectTrigger,
27 SelectValue,
28 } from "@/components/ui/select";
29 import { Switch } from "@/components/ui/switch";
30 import { Textarea } from "@/components/ui/textarea";
31 import { getNotebookAttachmentRagId } from "@/lib/notebook/attachments";
32 import {
33 getPresentationGenerationAspectRatioLabel,
34 PRESENTATION_GENERATION_ASPECT_RATIO_OPTIONS,
35 type PresentationGenerationAspectRatio,
36 } from "@/lib/presentation/aspect-ratio";
37 import { cn } from "@/lib/utils";
38 import { usePresentationState } from "@/states/presentation-state";
39
40 const LANGUAGE_OPTIONS = [
41 { label: "English (US)", shortLabel: "English", value: "en-US" },
42 { label: "Spanish", shortLabel: "Spanish", value: "es" },
43 { label: "French", shortLabel: "French", value: "fr" },
44 { label: "German", shortLabel: "German", value: "de" },
45 { label: "Portuguese", shortLabel: "Portuguese", value: "pt" },
46 { label: "Italian", shortLabel: "Italian", value: "it" },
47 { label: "Japanese", shortLabel: "Japanese", value: "ja" },
48 { label: "Korean", shortLabel: "Korean", value: "ko" },
49 { label: "Chinese", shortLabel: "Chinese", value: "zh" },
50 { label: "Russian", shortLabel: "Russian", value: "ru" },
51 { label: "Hindi", shortLabel: "Hindi", value: "hi" },
52 { label: "Arabic", shortLabel: "Arabic", value: "ar" },
53 ] as const;
54
55 interface HeaderProps {
56 onRegenerate?: () => void;
57 isGeneratingOutlineOverride?: boolean;
58 }
59
60 function getLanguageLabel(language: string): string {
61 return (
62 LANGUAGE_OPTIONS.find((option) => option.value === language)?.shortLabel ??
63 language
64 );
65 }
66
67 export function Header({
68 onRegenerate,
69 isGeneratingOutlineOverride,
70 }: HeaderProps) {
71 const {
72 presentationInput,
73 setPresentationInput,
74 numSlides,
75 setNumSlides,
76 language,
77 setLanguage,
78 webSearchEnabled,
79 setWebSearchEnabled,
80 generationAspectRatio,
81 setGenerationAspectRatio,
82 isGeneratingOutline: isGeneratingOutlineFromState,
83 startOutlineGeneration,
84 attachedFiles,
85 selectedChunks,
86 extractorRagIds,
87 setCurrentExtractorRagId,
88 } = usePresentationState();
89 const isGeneratingOutline =
90 isGeneratingOutlineOverride ?? isGeneratingOutlineFromState;
91 const [isExpanded, setIsExpanded] = useState(false);
92 const prompt = presentationInput.trim();
93 const formatBadgeLabel =
94 getPresentationGenerationAspectRatioLabel(generationAspectRatio);
95 const languageBadgeLabel = getLanguageLabel(language);
96 const hasKnownLanguage = LANGUAGE_OPTIONS.some(
97 (option) => option.value === language,
98 );
99 const languageSelectValue = language.trim()
100 ? language
101 : LANGUAGE_OPTIONS[0].value;
102
103 function handleRegenerate() {
104 if (!prompt) {
105 toast.error("Please enter a presentation topic");
106 return;
107 }
108
109 if (onRegenerate) {
110 onRegenerate();
111 return;
112 }
113
114 startOutlineGeneration();
115 }
116
117 return (
118 <Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
119 <CollapsibleTrigger asChild>
120 <button
121 type="button"
122 className={cn(
123 "flex w-full items-center gap-2 rounded-lg border bg-muted/30 p-3 text-left transition-colors hover:bg-muted/40",
124 isExpanded && "rounded-b-none",
125 )}
126 >
127 <div className="flex min-w-0 flex-1 items-center gap-2">
128 <ChevronDown
129 className={cn(
130 "size-4 shrink-0 text-muted-foreground transition-transform",
131 isExpanded && "rotate-180",
132 )}
133 />
134 <span className="truncate text-sm font-medium">
135 {prompt || "Untitled presentation"}
136 </span>
137 </div>
138
139 <div className="hidden shrink-0 items-center gap-1.5 sm:flex">
140 <Badge variant="secondary" className="font-normal">
141 {numSlides} slides
142 </Badge>
143 <Badge variant="secondary" className="font-normal">
144 {formatBadgeLabel}
145 </Badge>
146 <Badge variant="secondary" className="font-normal">
147 {languageBadgeLabel}
148 </Badge>
149 <Badge variant="secondary" className="font-normal">
150 {webSearchEnabled ? "Search on" : "Search off"}
151 </Badge>
152 {attachedFiles.length > 0 ? (
153 <Badge variant="outline" className="font-normal text-primary">
154 {attachedFiles.length}{" "}
155 {attachedFiles.length === 1 ? "file" : "files"}
156 </Badge>
157 ) : null}
158 </div>
159
160 <Button
161 variant="outline"
162 size="sm"
163 onClick={(event) => {
164 event.stopPropagation();
165 handleRegenerate();
166 }}
167 disabled={isGeneratingOutline || !prompt}
168 className="h-7 shrink-0 gap-1.5 rounded-full px-3 text-xs"
169 >
170 <RefreshCw
171 className={cn(
172 "size-3.5",
173 isGeneratingOutline && "animate-spin",
174 )}
175 />
176 <span className="hidden sm:inline">Regenerate</span>
177 </Button>
178 </button>
179 </CollapsibleTrigger>
180
181 <CollapsibleContent>
182 <div className="rounded-b-lg border border-t-0 bg-muted/30 p-4">
183 <div className="space-y-4">
184 <div className="flex flex-wrap gap-1.5 sm:hidden">
185 <Badge variant="secondary" className="font-normal">
186 {numSlides} slides
187 </Badge>
188 <Badge variant="secondary" className="font-normal">
189 {formatBadgeLabel}
190 </Badge>
191 <Badge variant="secondary" className="font-normal">
192 {languageBadgeLabel}
193 </Badge>
194 <Badge variant="secondary" className="font-normal">
195 {webSearchEnabled ? "Search on" : "Search off"}
196 </Badge>
197 {attachedFiles.length > 0 ? (
198 <Badge variant="outline" className="font-normal text-primary">
199 {attachedFiles.length}{" "}
200 {attachedFiles.length === 1 ? "file" : "files"}
201 </Badge>
202 ) : null}
203 </div>
204
205 <div className="space-y-1.5">
206 <label
207 htmlFor="outline-prompt"
208 className="text-xs font-medium text-muted-foreground"
209 >
210 Prompt
211 </label>
212 <Textarea
213 id="outline-prompt"
214 value={presentationInput}
215 onChange={(event) => setPresentationInput(event.target.value)}
216 disabled={isGeneratingOutline}
217 rows={3}
218 placeholder="Describe the presentation you want to generate..."
219 className="min-h-20 resize-none rounded-xl border-border/50 bg-background px-3.5 py-2.5 text-sm shadow-none focus-visible:ring-2 focus-visible:ring-primary/30"
220 />
221 </div>
222
223 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
224 <div className="space-y-1.5">
225 <label
226 htmlFor="outline-slides"
227 className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"
228 >
229 <Layers className="size-3.5" />
230 Slides
231 </label>
232 <Select
233 value={String(numSlides)}
234 onValueChange={(value) => setNumSlides(Number(value))}
235 >
236 <SelectTrigger
237 id="outline-slides"
238 className="h-9 rounded-lg bg-background"
239 >
240 <SelectValue />
241 </SelectTrigger>
242 <SelectContent>
243 {Array.from({ length: 12 }, (_, index) => index + 1).map(
244 (slideCount) => (
245 <SelectItem
246 key={slideCount}
247 value={String(slideCount)}
248 >
249 {slideCount} {slideCount === 1 ? "slide" : "slides"}
250 </SelectItem>
251 ),
252 )}
253 </SelectContent>
254 </Select>
255 </div>
256
257 <div className="space-y-1.5">
258 <label
259 htmlFor="outline-aspect-ratio"
260 className="text-xs font-medium text-muted-foreground"
261 >
262 Aspect ratio
263 </label>
264 <Select
265 value={generationAspectRatio}
266 onValueChange={(value) =>
267 setGenerationAspectRatio(
268 value as PresentationGenerationAspectRatio,
269 )
270 }
271 >
272 <SelectTrigger
273 id="outline-aspect-ratio"
274 className="h-9 rounded-lg bg-background"
275 >
276 <SelectValue />
277 </SelectTrigger>
278 <SelectContent>
279 {PRESENTATION_GENERATION_ASPECT_RATIO_OPTIONS.map(
280 (option) => (
281 <SelectItem key={option.value} value={option.value}>
282 {option.label}
283 </SelectItem>
284 ),
285 )}
286 </SelectContent>
287 </Select>
288 </div>
289
290 <div className="space-y-1.5">
291 <label
292 htmlFor="outline-language"
293 className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"
294 >
295 <Languages className="size-3.5" />
296 Language
297 </label>
298 <Select value={languageSelectValue} onValueChange={setLanguage}>
299 <SelectTrigger
300 id="outline-language"
301 className="h-9 rounded-lg bg-background"
302 >
303 <SelectValue>{languageBadgeLabel}</SelectValue>
304 </SelectTrigger>
305 <SelectContent>
306 {!hasKnownLanguage && language.trim() ? (
307 <SelectItem value={language}>{language}</SelectItem>
308 ) : null}
309 {LANGUAGE_OPTIONS.map((option) => (
310 <SelectItem key={option.value} value={option.value}>
311 {option.label}
312 </SelectItem>
313 ))}
314 </SelectContent>
315 </Select>
316 </div>
317
318 <div className="space-y-1.5">
319 <label
320 htmlFor="outline-web-search"
321 className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"
322 >
323 <Search className="size-3.5" />
324 Web search
325 </label>
326 <div className="flex h-9 items-center justify-between rounded-lg border bg-background px-3">
327 <span className="text-sm text-muted-foreground">
328 {webSearchEnabled ? "Enabled" : "Disabled"}
329 </span>
330 <Switch
331 id="outline-web-search"
332 checked={webSearchEnabled}
333 onCheckedChange={setWebSearchEnabled}
334 />
335 </div>
336 </div>
337 </div>
338
339 {attachedFiles.length > 0 ? (
340 <div className="space-y-2">
341 <div className="flex items-center justify-between">
342 <span className="text-xs font-medium text-muted-foreground">
343 Attached files
344 </span>
345 {selectedChunks.length > 0 ? (
346 <span className="text-xs text-muted-foreground">
347 {selectedChunks.length}{" "}
348 {selectedChunks.length === 1 ? "selection" : "selections"}
349 </span>
350 ) : null}
351 </div>
352 <div className="flex flex-wrap gap-1.5">
353 {attachedFiles.map((file, index) => {
354 const ragId = getNotebookAttachmentRagId({
355 attachment: file,
356 attachments: attachedFiles,
357 extractorRagIds,
358 index,
359 });
360 const processed = Boolean(ragId);
361 const count = selectedChunks.filter(
362 (chunk) => chunk.ragId === ragId,
363 ).length;
364
365 return (
366 <button
367 key={file.fileAssetId ?? file.url}
368 type="button"
369 onClick={() => {
370 if (!processed || !ragId) {
371 return;
372 }
373
374 setCurrentExtractorRagId(ragId);
375 }}
376 className={cn(
377 "flex max-w-full items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs transition-colors",
378 processed
379 ? "cursor-pointer border-primary/30 bg-primary/5 hover:bg-primary/10"
380 : "border-border bg-muted/40",
381 )}
382 title={processed ? "File processed" : file.name}
383 >
384 {count > 0 ? (
385 <span className="flex items-center gap-0.5 text-primary">
386 <Check className="size-3" />
387 <span className="font-medium">{count}</span>
388 </span>
389 ) : null}
390 <Paperclip className="size-3 shrink-0" />
391 <span className="max-w-48 truncate">{file.name}</span>
392 </button>
393 );
394 })}
395 </div>
396 </div>
397 ) : null}
398 </div>
399 </div>
400 </CollapsibleContent>
401 </Collapsible>
402 );
403 }
404
404 lines Plain Text