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