返回 presentation-ai
PresentationDashboard.tsx
根目录 / src / components / notebook / presentation / components / PresentationDashboard.tsx
1 "use client";
2
3 import {
4 fetchPresentations,
5 type PresentationDocumentTypeFilter,
6 } from "@/app/_actions/notebook/presentation/fetchPresentations";
7 import { togglePresentationFavorite } from "@/app/_actions/notebook/presentation/presentationFavoriteActions";
8 import {
9 createEmptyPresentation,
10 deletePresentation,
11 duplicatePresentation,
12 updatePresentationTitle,
13 } from "@/app/_actions/notebook/presentation/presentationActions";
14 import { ModelPicker } from "@/components/notebook/presentation/components/ModelPicker";
15 import { useBlankPresentationCreator } from "@/hooks/presentation/useBlankPresentationCreator";
16 import {
17 getPresentationGenerationAspectRatioLabel,
18 type PresentationGenerationAspectRatio,
19 } from "@/lib/presentation/aspect-ratio";
20 import { buildPresentationCustomization } from "@/lib/presentation/customization";
21 import { cn } from "@/lib/utils";
22 import { usePresentationState } from "@/states/presentation-state";
23 import {
24 type InfiniteData,
25 useInfiniteQuery,
26 useMutation,
27 useQueryClient,
28 } from "@tanstack/react-query";
29 import { formatDistanceToNow } from "date-fns";
30 import {
31 Archive,
32 ArrowRight,
33 Check,
34 Clock3,
35 Copy,
36 Folder,
37 Globe,
38 Grid2X2,
39 Languages,
40 LayoutTemplate,
41 List,
42 Loader2,
43 MoreVertical,
44 PanelsTopLeft,
45 Pencil,
46 Plus,
47 Search,
48 SlidersHorizontal,
49 Star,
50 Trash2,
51 WandSparkles,
52 X,
53 type LucideIcon,
54 } from "lucide-react";
55 import Image from "next/image";
56 import { useTheme } from "next-themes";
57 import { useRouter } from "next/navigation";
58 import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
59 import { useInView } from "react-intersection-observer";
60 import { toast } from "sonner";
61
62 import { Button } from "@/components/ui/button";
63 import {
64 Credenza,
65 CredenzaContent,
66 CredenzaDescription,
67 CredenzaFooter,
68 CredenzaHeader,
69 CredenzaTitle,
70 } from "@/components/ui/credenza";
71 import {
72 DropdownMenu,
73 DropdownMenuCheckboxItem,
74 DropdownMenuContent,
75 DropdownMenuLabel,
76 DropdownMenuItem,
77 DropdownMenuRadioGroup,
78 DropdownMenuRadioItem,
79 DropdownMenuSeparator,
80 DropdownMenuTrigger,
81 } from "@/components/ui/dropdown-menu";
82 import { Input } from "@/components/ui/input";
83 import {
84 AlertDialog,
85 AlertDialogAction,
86 AlertDialogCancel,
87 AlertDialogContent,
88 AlertDialogDescription,
89 AlertDialogFooter,
90 AlertDialogHeader,
91 AlertDialogTitle,
92 } from "@/components/ui/alert-dialog";
93
94 const PRESENTATIONS_QUERY_KEY = ["presentations"] as const;
95 const ALL_PRESENTATION_DOCUMENT_TYPES = "ALL";
96 type PresentationDocumentTypeFilterValue =
97 | typeof ALL_PRESENTATION_DOCUMENT_TYPES
98 | PresentationDocumentTypeFilter;
99
100 type PresentationFileItem = {
101 id: string;
102 name: string;
103 thumbnailUrl: string | null;
104 modified: string;
105 modifiedAt: Date;
106 isFavorited: boolean;
107 isFavoritePending: boolean;
108 isRenamePending: boolean;
109 isDeletePending: boolean;
110 isDuplicatePending: boolean;
111 onClick: () => void;
112 onToggleFavorite: () => void;
113 onRename: (nextName: string) => Promise<boolean>;
114 onDelete: () => Promise<boolean>;
115 onDuplicate: () => void;
116 };
117
118 type PresentationPage = Awaited<ReturnType<typeof fetchPresentations>>;
119 type PresentationsInfiniteData = InfiniteData<PresentationPage, number>;
120 type FavoriteMutationVariables = {
121 documentId: string;
122 isFavorited: boolean;
123 };
124
125 type LibraryTab = "all" | "recent" | "favorites";
126 type ViewMode = "grid" | "list";
127 type SortBy = "date-desc" | "date-asc" | "name-asc" | "name-desc";
128
129 const LANGUAGE_OPTIONS = [
130 { label: "English", value: "en-US" },
131 { label: "Portuguese", value: "pt" },
132 { label: "Spanish", value: "es" },
133 { label: "French", value: "fr" },
134 { label: "German", value: "de" },
135 { label: "Italian", value: "it" },
136 { label: "Japanese", value: "ja" },
137 { label: "Korean", value: "ko" },
138 { label: "Chinese", value: "zh" },
139 { label: "Russian", value: "ru" },
140 { label: "Hindi", value: "hi" },
141 { label: "Arabic", value: "ar" },
142 ] as const;
143
144 const SLIDE_OPTIONS = Array.from({ length: 12 }, (_, index) => ({
145 label: `${index + 1} slide${index === 0 ? "" : "s"}`,
146 value: String(index + 1),
147 }));
148
149 function getPresentationRoute(item: {
150 hasContent: boolean;
151 hasSlides: boolean;
152 id: string;
153 }) {
154 return item.hasSlides || item.hasContent
155 ? `/presentation/${item.id}`
156 : `/presentation/generate/${item.id}`;
157 }
158
159 function NotebookPageLayout({ children }: { children: ReactNode }) {
160 return (
161 <div
162 className="notebook-section relative h-full w-full max-w-[100vw] min-w-0 overflow-x-hidden overflow-y-auto"
163 style={{ scrollbarGutter: "stable" }}
164 >
165 <main className="mx-auto mt-4 w-full max-w-[min(100vw,72rem)] min-w-0 px-3 pb-8 sm:mt-6 sm:px-6 lg:px-8">
166 {children}
167 </main>
168 </div>
169 );
170 }
171
172 function GreetingSection() {
173 return (
174 <section className="mb-5 flex flex-col items-center text-center sm:mb-6">
175 <h1 className="max-w-4xl text-2xl font-semibold tracking-normal text-foreground sm:text-3xl">
176 What presentation would you like to create today?
177 </h1>
178 </section>
179 );
180 }
181
182 function SettingPill({
183 icon: Icon,
184 label,
185 children,
186 }: {
187 icon: LucideIcon;
188 label: string;
189 children: ReactNode;
190 }) {
191 return (
192 <DropdownMenu>
193 <DropdownMenuTrigger asChild>
194 <button
195 type="button"
196 className="inline-flex h-8 max-w-full items-center gap-2 rounded-full border border-border bg-background px-3 text-[13px] font-medium text-foreground transition-colors hover:bg-accent sm:h-9 sm:px-3.5 sm:text-sm"
197 >
198 <Icon className="size-3.5 shrink-0 sm:size-4" />
199 <span className="truncate">{label}</span>
200 </button>
201 </DropdownMenuTrigger>
202 <DropdownMenuContent
203 align="start"
204 className="w-64 max-w-[calc(100vw-1rem)] p-2"
205 >
206 {children}
207 </DropdownMenuContent>
208 </DropdownMenu>
209 );
210 }
211
212 function NotebookInputBox({
213 placeholder,
214 value,
215 onChange,
216 onSubmit,
217 submitDisabled,
218 isSubmitting,
219 children,
220 topRightContent,
221 }: {
222 placeholder: string;
223 value: string;
224 onChange: (value: string) => void;
225 onSubmit: () => void;
226 submitDisabled: boolean;
227 isSubmitting: boolean;
228 children: ReactNode;
229 topRightContent?: ReactNode;
230 }) {
231 return (
232 <div className="relative mb-4 min-w-0 rounded-xl border border-border bg-background p-2.5 shadow sm:p-4">
233 {topRightContent ? (
234 <div className="absolute top-2.5 right-2.5 z-10 sm:top-4 sm:right-4">
235 {topRightContent}
236 </div>
237 ) : null}
238 <textarea
239 aria-label="Presentation prompt"
240 value={value}
241 onChange={(event) => onChange(event.target.value)}
242 placeholder={placeholder}
243 onKeyDown={(event) => {
244 if (event.key === "Enter" && event.ctrlKey && !submitDisabled) {
245 event.preventDefault();
246 onSubmit();
247 }
248 }}
249 className={cn(
250 "mb-3 h-24 w-full min-w-0 resize-none bg-transparent text-sm text-foreground placeholder-muted-foreground outline-none sm:h-30",
251 topRightContent ? "pr-34 sm:pr-44" : undefined,
252 )}
253 />
254 <div className="flex min-w-0 items-center justify-between gap-2">
255 <div className="min-w-0 flex-1">{children}</div>
256 <button
257 type="button"
258 onClick={onSubmit}
259 disabled={submitDisabled}
260 aria-busy={isSubmitting}
261 className={cn(
262 "flex size-8 shrink-0 items-center justify-center rounded-full bg-foreground transition-colors sm:h-9 sm:w-9",
263 isSubmitting
264 ? "cursor-wait opacity-100"
265 : "hover:bg-foreground/90 disabled:opacity-50",
266 )}
267 >
268 {isSubmitting ? (
269 <Loader2 className="size-4 animate-spin text-background" />
270 ) : (
271 <ArrowRight className="size-4 text-background" />
272 )}
273 </button>
274 </div>
275 </div>
276 );
277 }
278
279 function PresentationFavoriteButton({
280 file,
281 className,
282 }: {
283 file: PresentationFileItem;
284 className?: string;
285 }) {
286 return (
287 <button
288 type="button"
289 aria-label={
290 file.isFavorited
291 ? `Remove ${file.name} from favorites`
292 : `Add ${file.name} to favorites`
293 }
294 aria-pressed={file.isFavorited}
295 title={file.isFavorited ? "Remove from favorites" : "Add to favorites"}
296 disabled={file.isFavoritePending}
297 onClick={(event) => {
298 event.stopPropagation();
299 file.onToggleFavorite();
300 }}
301 className={cn(
302 "flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-background hover:text-foreground disabled:cursor-wait disabled:opacity-70",
303 file.isFavorited && "text-yellow-500",
304 className,
305 )}
306 >
307 {file.isFavoritePending ? (
308 <Loader2 className="size-4 animate-spin" />
309 ) : (
310 <Star
311 className={cn(
312 "size-4",
313 file.isFavorited && "fill-yellow-400 text-yellow-500",
314 )}
315 />
316 )}
317 </button>
318 );
319 }
320
321 function PresentationFileActionsMenu({
322 file,
323 onRenameRequest,
324 onDeleteRequest,
325 }: {
326 file: PresentationFileItem;
327 onRenameRequest: (file: PresentationFileItem) => void;
328 onDeleteRequest: (file: PresentationFileItem) => void;
329 }) {
330 return (
331 <div
332 onClick={(event) => event.stopPropagation()}
333 onKeyDown={(event) => event.stopPropagation()}
334 >
335 <DropdownMenu>
336 <DropdownMenuTrigger asChild>
337 <Button
338 type="button"
339 variant="ghost"
340 size="icon"
341 className="size-8 rounded-full bg-background/90 text-muted-foreground shadow-sm backdrop-blur hover:bg-background hover:text-foreground"
342 >
343 <MoreVertical className="size-4" />
344 <span className="sr-only">Open presentation actions</span>
345 </Button>
346 </DropdownMenuTrigger>
347 <DropdownMenuContent align="end" className="w-52">
348 <DropdownMenuItem
349 disabled={file.isRenamePending}
350 onClick={() => onRenameRequest(file)}
351 >
352 {file.isRenamePending ? (
353 <Loader2 className="mr-2 size-4 animate-spin" />
354 ) : (
355 <Pencil className="mr-2 size-4" />
356 )}
357 Rename
358 </DropdownMenuItem>
359 <DropdownMenuItem
360 disabled={file.isDuplicatePending}
361 onClick={file.onDuplicate}
362 >
363 {file.isDuplicatePending ? (
364 <Loader2 className="mr-2 size-4 animate-spin" />
365 ) : (
366 <Copy className="mr-2 size-4" />
367 )}
368 Duplicate
369 </DropdownMenuItem>
370 <DropdownMenuItem
371 disabled={file.isFavoritePending}
372 onClick={file.onToggleFavorite}
373 >
374 {file.isFavoritePending ? (
375 <Loader2 className="mr-2 size-4 animate-spin" />
376 ) : (
377 <Star
378 className={cn(
379 "mr-2 size-4",
380 file.isFavorited && "fill-yellow-400 text-yellow-400",
381 )}
382 />
383 )}
384 {file.isFavorited ? "Remove from favorites" : "Add to favorites"}
385 </DropdownMenuItem>
386 <DropdownMenuSeparator />
387 <DropdownMenuItem
388 disabled={file.isDeletePending}
389 className="text-destructive focus:text-destructive"
390 onClick={() => onDeleteRequest(file)}
391 >
392 {file.isDeletePending ? (
393 <Loader2 className="mr-2 size-4 animate-spin" />
394 ) : (
395 <Trash2 className="mr-2 size-4" />
396 )}
397 Delete
398 </DropdownMenuItem>
399 </DropdownMenuContent>
400 </DropdownMenu>
401 </div>
402 );
403 }
404
405 function PresentationProjectFilesSection({
406 files,
407 isLoading,
408 onCreateNew,
409 filterOptions,
410 activeFilterId,
411 onFilterChange,
412 activeTab,
413 onActiveTabChange,
414 showFavoritesOnly,
415 onShowFavoritesOnlyChange,
416 }: {
417 files: PresentationFileItem[];
418 isLoading?: boolean;
419 onCreateNew: () => void;
420 filterOptions: { id: string; label: string }[];
421 activeFilterId: string;
422 onFilterChange: (filterId: string) => void;
423 activeTab: LibraryTab;
424 onActiveTabChange: (tab: LibraryTab) => void;
425 showFavoritesOnly: boolean;
426 onShowFavoritesOnlyChange: (showFavoritesOnly: boolean) => void;
427 }) {
428 const [searchQuery, setSearchQuery] = useState("");
429 const [viewMode, setViewMode] = useState<ViewMode>("grid");
430 const [sortBy, setSortBy] = useState<SortBy>("date-desc");
431 const [isSearchOpen, setIsSearchOpen] = useState(false);
432 const [renameTarget, setRenameTarget] =
433 useState<PresentationFileItem | null>(null);
434 const [renameValue, setRenameValue] = useState("");
435 const [deleteTarget, setDeleteTarget] =
436 useState<PresentationFileItem | null>(null);
437 const searchInputRef = useRef<HTMLInputElement>(null);
438
439 const tabs: { id: LibraryTab; label: string; icon: LucideIcon }[] = [
440 { id: "all", label: "All", icon: Archive },
441 { id: "recent", label: "Recently viewed", icon: Clock3 },
442 { id: "favorites", label: "Favorites", icon: Star },
443 ];
444 const sortOptions: { id: SortBy; label: string; icon: LucideIcon }[] = [
445 { id: "date-desc", label: "Newest first", icon: Clock3 },
446 { id: "date-asc", label: "Oldest first", icon: Clock3 },
447 { id: "name-asc", label: "Name A-Z", icon: Grid2X2 },
448 { id: "name-desc", label: "Name Z-A", icon: Grid2X2 },
449 ];
450 const shouldShowSearchInput = Boolean(searchQuery) || isSearchOpen;
451 const activeFiltersCount =
452 (showFavoritesOnly ? 1 : 0) +
453 (activeFilterId !== filterOptions[0]?.id ? 1 : 0);
454 const isRenamePending = renameTarget
455 ? (files.find((file) => file.id === renameTarget.id)?.isRenamePending ??
456 renameTarget.isRenamePending)
457 : false;
458 const isDeletePending = deleteTarget
459 ? (files.find((file) => file.id === deleteTarget.id)?.isDeletePending ??
460 deleteTarget.isDeletePending)
461 : false;
462
463 const openRenameDialog = (file: PresentationFileItem) => {
464 setRenameTarget(file);
465 setRenameValue(file.name);
466 };
467
468 const closeRenameDialog = () => {
469 if (!isRenamePending) {
470 setRenameTarget(null);
471 setRenameValue("");
472 }
473 };
474
475 const closeDeleteDialog = () => {
476 if (!isDeletePending) {
477 setDeleteTarget(null);
478 }
479 };
480
481 useEffect(() => {
482 if (!shouldShowSearchInput) return;
483
484 const frame = requestAnimationFrame(() => {
485 searchInputRef.current?.focus();
486 });
487
488 return () => cancelAnimationFrame(frame);
489 }, [shouldShowSearchInput]);
490
491 const visibleFiles = useMemo(() => {
492 const query = searchQuery.trim().toLowerCase();
493 let nextFiles = files.filter((file) => {
494 if (activeTab === "favorites" || showFavoritesOnly) {
495 if (!file.isFavorited) return false;
496 }
497 return query ? file.name.toLowerCase().includes(query) : true;
498 });
499
500 nextFiles = [...nextFiles].sort((a, b) => {
501 if (sortBy === "date-desc") {
502 return b.modifiedAt.getTime() - a.modifiedAt.getTime();
503 }
504 if (sortBy === "date-asc") {
505 return a.modifiedAt.getTime() - b.modifiedAt.getTime();
506 }
507 if (sortBy === "name-desc") {
508 return b.name.localeCompare(a.name);
509 }
510 return a.name.localeCompare(b.name);
511 });
512
513 return nextFiles;
514 }, [activeTab, files, searchQuery, showFavoritesOnly, sortBy]);
515
516 return (
517 <>
518 <div className="max-w-full min-w-0 overflow-x-hidden">
519 <div className="mb-4 flex min-w-0 flex-col gap-3 lg:flex-row lg:items-center lg:justify-between lg:gap-4">
520 <div className="min-w-0">
521 <div className="flex min-w-0 items-center gap-2 overflow-x-auto">
522 {tabs.map((tab) => {
523 const Icon = tab.icon;
524 const isActive = activeTab === tab.id;
525
526 return (
527 <button
528 key={tab.id}
529 type="button"
530 onClick={() => onActiveTabChange(tab.id)}
531 className={cn(
532 "inline-flex h-9 shrink-0 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors",
533 isActive
534 ? "bg-primary/15 text-primary"
535 : "text-muted-foreground hover:bg-accent hover:text-foreground",
536 )}
537 >
538 <Icon className="size-4" />
539 <span>{tab.label}</span>
540 </button>
541 );
542 })}
543 </div>
544 </div>
545
546 <div className="min-w-0 flex-1">
547 <div className="flex w-full min-w-0 items-center justify-between gap-2 sm:justify-end">
548 <div className="order-2 shrink-0 sm:order-1">
549 <div
550 className={cn(
551 "relative h-8.5 shrink-0 overflow-hidden transition-[width] duration-300 ease-out",
552 shouldShowSearchInput ? "w-36 sm:w-56 lg:w-64" : "w-8.5",
553 )}
554 >
555 <Button
556 type="button"
557 variant="outline"
558 size="sm"
559 onClick={() => setIsSearchOpen(true)}
560 className={cn(
561 "absolute inset-0 size-8.5 p-0 transition-all duration-200 ease-out",
562 shouldShowSearchInput &&
563 "pointer-events-none scale-95 opacity-0",
564 )}
565 >
566 <Search className="size-4" />
567 <span className="sr-only">Search files</span>
568 </Button>
569 <div
570 className={cn(
571 "absolute inset-0 transition-all duration-300 ease-out",
572 shouldShowSearchInput
573 ? "translate-x-0 opacity-100"
574 : "pointer-events-none translate-x-2 opacity-0",
575 )}
576 >
577 <Search className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
578 <input
579 ref={searchInputRef}
580 type="text"
581 aria-label="Search files"
582 placeholder="Search"
583 value={searchQuery}
584 onChange={(event) => setSearchQuery(event.target.value)}
585 className="h-8.5 w-full min-w-0 rounded-lg border border-border bg-background py-1.5 pr-8 pl-9 text-sm text-foreground outline-none focus:border-primary"
586 />
587 <button
588 type="button"
589 aria-label="Close search"
590 onClick={() => {
591 setSearchQuery("");
592 setIsSearchOpen(false);
593 }}
594 className="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:text-foreground"
595 >
596 <X className="size-3.5" />
597 </button>
598 </div>
599 </div>
600 </div>
601 <div className="order-1 flex min-w-0 flex-row-reverse items-center gap-2 sm:order-2 sm:flex-row">
602 <Button
603 type="button"
604 variant="outline"
605 size="sm"
606 onClick={onCreateNew}
607 className="h-8.5 gap-1.5 rounded-lg px-3"
608 >
609 <Plus className="size-4" />
610 <span>Create new</span>
611 </Button>
612 <DropdownMenu>
613 <DropdownMenuTrigger asChild>
614 <Button
615 type="button"
616 variant="outline"
617 size="sm"
618 className="relative size-8.5 p-0"
619 >
620 <SlidersHorizontal className="size-4" />
621 <span className="sr-only">Sort and filter files</span>
622 {activeFiltersCount > 0 ? (
623 <span className="absolute -top-1 -right-1 flex h-4.5 min-w-4.5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground sm:static sm:h-5 sm:min-w-5 sm:rounded-full">
624 {activeFiltersCount}
625 </span>
626 ) : null}
627 </Button>
628 </DropdownMenuTrigger>
629 <DropdownMenuContent align="end" className="w-52">
630 <DropdownMenuLabel>Sort by</DropdownMenuLabel>
631 {sortOptions.map((option) => (
632 <DropdownMenuItem
633 key={option.id}
634 onClick={() => setSortBy(option.id)}
635 className="flex items-center justify-between"
636 >
637 <span className="flex items-center gap-2">
638 <option.icon className="size-4" />
639 {option.label}
640 </span>
641 {sortBy === option.id ? (
642 <Check className="size-4" />
643 ) : null}
644 </DropdownMenuItem>
645 ))}
646 <DropdownMenuSeparator />
647 <DropdownMenuLabel>Filter</DropdownMenuLabel>
648 <DropdownMenuItem
649 onClick={() =>
650 onShowFavoritesOnlyChange(!showFavoritesOnly)
651 }
652 className="flex items-center justify-between"
653 >
654 <span className="flex items-center gap-2">
655 <Star
656 className={cn(
657 "size-4",
658 showFavoritesOnly &&
659 "fill-yellow-400 text-yellow-400",
660 )}
661 />
662 Favorites only
663 </span>
664 {showFavoritesOnly ? <Check className="size-4" /> : null}
665 </DropdownMenuItem>
666 <DropdownMenuSeparator />
667 <DropdownMenuLabel>Type</DropdownMenuLabel>
668 {filterOptions.map((option) => (
669 <DropdownMenuItem
670 key={option.id}
671 onClick={() => onFilterChange(option.id)}
672 className="flex items-center justify-between"
673 >
674 <span>{option.label}</span>
675 {activeFilterId === option.id ? (
676 <Check className="size-4" />
677 ) : null}
678 </DropdownMenuItem>
679 ))}
680 </DropdownMenuContent>
681 </DropdownMenu>
682 <div className="inline-flex rounded-lg border border-border bg-background p-0.5">
683 <button
684 type="button"
685 onClick={() => setViewMode("grid")}
686 className={cn(
687 "inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors",
688 viewMode === "grid"
689 ? "bg-accent text-foreground"
690 : "text-muted-foreground hover:text-foreground",
691 )}
692 >
693 <Grid2X2 className="size-3.5" />
694 <span className="hidden sm:inline">Grid</span>
695 </button>
696 <button
697 type="button"
698 onClick={() => setViewMode("list")}
699 className={cn(
700 "inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors",
701 viewMode === "list"
702 ? "bg-accent text-foreground"
703 : "text-muted-foreground hover:text-foreground",
704 )}
705 >
706 <List className="size-3.5" />
707 <span className="hidden sm:inline">List</span>
708 </button>
709 </div>
710 </div>
711 </div>
712 </div>
713 </div>
714
715 <div className="max-w-full overflow-x-auto overflow-y-hidden rounded-lg border border-border bg-background">
716 {isLoading ? (
717 viewMode === "grid" ? (
718 <div className="grid gap-3 p-3 sm:grid-cols-2 lg:grid-cols-3">
719 {Array.from({ length: 6 }).map((_, index) => (
720 <div
721 key={`grid-skeleton-${index}`}
722 className="rounded-xl border border-border bg-background p-4"
723 >
724 <div className="mb-4 flex items-center justify-between gap-2">
725 <div className="size-10 animate-pulse rounded-lg bg-muted" />
726 <div className="size-8 animate-pulse rounded-md bg-muted" />
727 </div>
728 <div className="mb-2 h-4 w-2/3 animate-pulse rounded bg-muted" />
729 <div className="mb-3 h-3 w-1/2 animate-pulse rounded bg-muted" />
730 <div className="h-6 w-24 animate-pulse rounded-md bg-muted" />
731 </div>
732 ))}
733 </div>
734 ) : (
735 <div>
736 {Array.from({ length: 4 }).map((_, index) => (
737 <div
738 key={`list-skeleton-${index}`}
739 className="flex items-center justify-between gap-3 border-t border-border px-4 py-3 first:border-t-0"
740 >
741 <div className="flex min-w-0 flex-1 items-center gap-3">
742 <div className="size-9 animate-pulse rounded-lg bg-muted" />
743 <div className="min-w-0 flex-1">
744 <div className="mb-2 h-4 w-2/3 animate-pulse rounded bg-muted" />
745 <div className="h-3 w-1/2 animate-pulse rounded bg-muted" />
746 </div>
747 </div>
748 <div className="size-8 animate-pulse rounded-md bg-muted" />
749 </div>
750 ))}
751 </div>
752 )
753 ) : visibleFiles.length === 0 ? (
754 <div className="flex flex-col items-center justify-center px-4 py-16 text-center">
755 <div className="mb-4 rounded-full bg-muted p-4">
756 <Folder className="size-8 text-muted-foreground" />
757 </div>
758 <p className="mb-2 text-sm font-medium text-foreground">
759 No presentations yet
760 </p>
761 <p className="mb-6 text-sm text-muted-foreground">
762 Create your first presentation to get started
763 </p>
764 </div>
765 ) : viewMode === "grid" ? (
766 <div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,10.5rem),1fr))] gap-3 p-3 sm:grid-cols-2 sm:gap-4 sm:p-4 lg:grid-cols-3 xl:grid-cols-4">
767 {visibleFiles.map((file) => (
768 <div
769 key={file.id}
770 className="group relative flex flex-col overflow-hidden rounded-xl border border-border bg-card text-left transition-all duration-200 hover:border-primary/50 hover:shadow-md focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"
771 >
772 <button
773 type="button"
774 aria-label={`Open ${file.name}`}
775 onClick={file.onClick}
776 className="absolute inset-0 z-10 cursor-pointer rounded-xl focus-visible:outline-none"
777 />
778 <div className="pointer-events-none relative z-20 aspect-video w-full overflow-hidden bg-muted/30">
779 {file.thumbnailUrl ? (
780 <Image
781 unoptimized
782 width={400}
783 height={300}
784 src={file.thumbnailUrl}
785 alt={file.name}
786 className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
787 />
788 ) : (
789 <div className="flex size-full items-center justify-center bg-accent/10">
790 <Folder className="size-12 text-muted-foreground/40" />
791 </div>
792 )}
793 <div className="absolute inset-0 bg-linear-to-t from-black/5 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
794 </div>
795 <div
796 className={cn(
797 "absolute top-2 right-2 z-30 flex items-center gap-1 transition-opacity",
798 file.isFavorited
799 ? "opacity-100"
800 : "sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100",
801 )}
802 >
803 <PresentationFavoriteButton
804 file={file}
805 className="border border-border/70 bg-background/90 shadow-sm backdrop-blur"
806 />
807 <PresentationFileActionsMenu
808 file={file}
809 onRenameRequest={openRenameDialog}
810 onDeleteRequest={setDeleteTarget}
811 />
812 </div>
813 <div className="pointer-events-none relative z-20 flex flex-1 flex-col p-3">
814 <div className="flex items-start justify-between gap-2">
815 <div className="min-w-0 flex-1">
816 <h4
817 className="truncate font-medium text-card-foreground"
818 title={file.name}
819 >
820 {file.name}
821 </h4>
822 <p className="mt-1 text-xs text-muted-foreground">
823 {file.modified}
824 </p>
825 </div>
826 </div>
827 </div>
828 </div>
829 ))}
830 </div>
831 ) : (
832 <div className="divide-y divide-border">
833 {visibleFiles.map((file) => (
834 <div
835 key={file.id}
836 className="group relative flex min-w-0 items-center gap-3 px-4 py-3 hover:bg-accent/30 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 sm:gap-4 sm:py-2.5"
837 >
838 <button
839 type="button"
840 aria-label={`Open ${file.name}`}
841 onClick={file.onClick}
842 className="absolute inset-0 z-10 cursor-pointer focus-visible:outline-none"
843 />
844 <div className="pointer-events-none relative z-20 flex min-w-0 flex-1 items-center gap-3 sm:gap-4">
845 {file.thumbnailUrl ? (
846 <Image
847 unoptimized
848 width={400}
849 height={300}
850 src={file.thumbnailUrl}
851 alt={file.name}
852 className="h-12 w-20 shrink-0 rounded-md border border-border object-cover"
853 />
854 ) : (
855 <div className="flex h-12 w-20 shrink-0 items-center justify-center rounded-md border border-border bg-primary/10 text-primary">
856 <Folder className="size-4" />
857 </div>
858 )}
859 <div className="min-w-0 flex-1">
860 <div className="flex min-w-0 items-start gap-2">
861 <p className="truncate text-sm font-medium text-foreground group-hover:text-primary">
862 {file.name}
863 </p>
864 </div>
865 <div className="mt-1 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
866 <span className="truncate">{file.modified}</span>
867 </div>
868 </div>
869 </div>
870 <div className="relative z-20 flex shrink-0 items-center gap-1">
871 <PresentationFavoriteButton file={file} />
872 <PresentationFileActionsMenu
873 file={file}
874 onRenameRequest={openRenameDialog}
875 onDeleteRequest={setDeleteTarget}
876 />
877 </div>
878 </div>
879 ))}
880 </div>
881 )}
882 </div>
883 </div>
884
885 <Credenza
886 open={Boolean(renameTarget)}
887 onOpenChange={(open) => {
888 if (!open) {
889 closeRenameDialog();
890 }
891 }}
892 >
893 <CredenzaContent className="sm:max-w-md">
894 <CredenzaHeader>
895 <CredenzaTitle>Rename presentation</CredenzaTitle>
896 <CredenzaDescription>
897 Choose a new name for this presentation.
898 </CredenzaDescription>
899 </CredenzaHeader>
900 <Input
901 value={renameValue}
902 onChange={(event) => setRenameValue(event.target.value)}
903 placeholder="Enter a new presentation name"
904 autoFocus
905 />
906 <CredenzaFooter>
907 <Button
908 type="button"
909 variant="outline"
910 onClick={closeRenameDialog}
911 disabled={isRenamePending}
912 >
913 Cancel
914 </Button>
915 <Button
916 type="button"
917 disabled={
918 !renameTarget ||
919 !renameValue.trim() ||
920 renameValue.trim() === renameTarget.name ||
921 isRenamePending
922 }
923 onClick={async () => {
924 if (!renameTarget) {
925 closeRenameDialog();
926 return;
927 }
928
929 const nextName = renameValue.trim();
930 if (!nextName || nextName === renameTarget.name) {
931 closeRenameDialog();
932 return;
933 }
934
935 const renamed = await renameTarget.onRename(nextName);
936 if (renamed) {
937 closeRenameDialog();
938 }
939 }}
940 >
941 {isRenamePending ? (
942 <Loader2 className="mr-2 size-4 animate-spin" />
943 ) : null}
944 Rename
945 </Button>
946 </CredenzaFooter>
947 </CredenzaContent>
948 </Credenza>
949
950 <AlertDialog
951 open={Boolean(deleteTarget)}
952 onOpenChange={(open) => {
953 if (!open) {
954 closeDeleteDialog();
955 }
956 }}
957 >
958 <AlertDialogContent>
959 <AlertDialogHeader>
960 <AlertDialogTitle>Delete presentation</AlertDialogTitle>
961 <AlertDialogDescription>
962 This will permanently delete "{deleteTarget?.name}". This action
963 cannot be undone.
964 </AlertDialogDescription>
965 </AlertDialogHeader>
966 <AlertDialogFooter>
967 <AlertDialogCancel disabled={isDeletePending}>
968 Cancel
969 </AlertDialogCancel>
970 <AlertDialogAction
971 className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
972 disabled={!deleteTarget || isDeletePending}
973 onClick={async (event) => {
974 event.preventDefault();
975 if (!deleteTarget) {
976 closeDeleteDialog();
977 return;
978 }
979
980 const deleted = await deleteTarget.onDelete();
981 if (deleted) {
982 closeDeleteDialog();
983 }
984 }}
985 >
986 {isDeletePending ? (
987 <Loader2 className="mr-2 size-4 animate-spin" />
988 ) : null}
989 Delete
990 </AlertDialogAction>
991 </AlertDialogFooter>
992 </AlertDialogContent>
993 </AlertDialog>
994 </>
995 );
996 }
997
998 export function PresentationDashboard() {
999 const router = useRouter();
1000 const queryClient = useQueryClient();
1001 const { resolvedTheme } = useTheme();
1002 const [documentTypeFilter, setDocumentTypeFilter] =
1003 useState<PresentationDocumentTypeFilterValue>(
1004 ALL_PRESENTATION_DOCUMENT_TYPES,
1005 );
1006 const [libraryTab, setLibraryTab] = useState<LibraryTab>("recent");
1007 const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
1008 const { createBlank: handleCreateBlank, isCreating: isCreatingBlank } =
1009 useBlankPresentationCreator();
1010 const {
1011 presentationInput,
1012 setPresentationInput,
1013 isGeneratingOutline,
1014 language,
1015 setLanguage,
1016 numSlides,
1017 setNumSlides,
1018 generationAspectRatio,
1019 setGenerationAspectRatio,
1020 webSearchEnabled,
1021 setWebSearchEnabled,
1022 autoThemeEnabled,
1023 setAutoThemeEnabled,
1024 setOutputFormat,
1025 setCurrentPresentation,
1026 setIsGeneratingOutline,
1027 setTheme,
1028 startOutlineGeneration,
1029 customThemeData,
1030 themeDataByTheme,
1031 generatedThemeData,
1032 pageStyle,
1033 presentationStyle,
1034 textContent,
1035 tone,
1036 audience,
1037 scenario,
1038 pageBackground,
1039 selectedSlideTemplates,
1040 outlineItemIds,
1041 outlineTemplateOverrides,
1042 resetPresentationState,
1043 } = usePresentationState();
1044
1045 useEffect(() => {
1046 setOutputFormat("flow");
1047 }, [setOutputFormat]);
1048
1049 useEffect(() => {
1050 resetPresentationState();
1051 }, [resetPresentationState]);
1052
1053 const typeFilter =
1054 documentTypeFilter === ALL_PRESENTATION_DOCUMENT_TYPES
1055 ? undefined
1056 : documentTypeFilter;
1057 const favoritesOnly = libraryTab === "favorites" || showFavoritesOnly;
1058
1059 const updateCachedPresentationFavorite = (
1060 documentId: string,
1061 isFavorite: boolean,
1062 ) => {
1063 queryClient.setQueriesData<PresentationsInfiniteData>(
1064 { queryKey: PRESENTATIONS_QUERY_KEY },
1065 (cachedData) => {
1066 if (!cachedData) {
1067 return cachedData;
1068 }
1069
1070 return {
1071 ...cachedData,
1072 pages: cachedData.pages.map((page) => ({
1073 ...page,
1074 items: page.items.map((item) =>
1075 item.id === documentId
1076 ? {
1077 ...item,
1078 favorites: isFavorite
1079 ? item.favorites.length > 0
1080 ? item.favorites
1081 : [{ id: "optimistic-favorite" }]
1082 : [],
1083 }
1084 : item,
1085 ),
1086 })),
1087 };
1088 },
1089 );
1090 };
1091
1092 const updateCachedPresentationTitle = (
1093 documentId: string,
1094 title: string,
1095 ) => {
1096 queryClient.setQueriesData<PresentationsInfiniteData>(
1097 { queryKey: PRESENTATIONS_QUERY_KEY },
1098 (cachedData) => {
1099 if (!cachedData) {
1100 return cachedData;
1101 }
1102
1103 return {
1104 ...cachedData,
1105 pages: cachedData.pages.map((page) => ({
1106 ...page,
1107 items: page.items.map((item) =>
1108 item.id === documentId ? { ...item, title } : item,
1109 ),
1110 })),
1111 };
1112 },
1113 );
1114 };
1115
1116 const removeCachedPresentation = (documentId: string) => {
1117 queryClient.setQueriesData<PresentationsInfiniteData>(
1118 { queryKey: PRESENTATIONS_QUERY_KEY },
1119 (cachedData) => {
1120 if (!cachedData) {
1121 return cachedData;
1122 }
1123
1124 return {
1125 ...cachedData,
1126 pages: cachedData.pages.map((page) => ({
1127 ...page,
1128 items: page.items.filter((item) => item.id !== documentId),
1129 })),
1130 };
1131 },
1132 );
1133 };
1134
1135 const favoriteMutation = useMutation({
1136 mutationFn: async ({ documentId }: FavoriteMutationVariables) => {
1137 const result = await togglePresentationFavorite(documentId);
1138
1139 if (!result.success) {
1140 throw new Error(result.message || "Failed to update favorite");
1141 }
1142
1143 return result;
1144 },
1145 onMutate: async ({
1146 documentId,
1147 isFavorited,
1148 }: FavoriteMutationVariables) => {
1149 await queryClient.cancelQueries({ queryKey: PRESENTATIONS_QUERY_KEY });
1150
1151 const previousQueries =
1152 queryClient.getQueriesData<PresentationsInfiniteData>({
1153 queryKey: PRESENTATIONS_QUERY_KEY,
1154 });
1155
1156 updateCachedPresentationFavorite(documentId, !isFavorited);
1157
1158 return { previousQueries };
1159 },
1160 onError: (error, _variables, context) => {
1161 context?.previousQueries.forEach(([queryKey, previousData]) => {
1162 queryClient.setQueryData(queryKey, previousData);
1163 });
1164
1165 toast.error(
1166 error instanceof Error
1167 ? error.message
1168 : "Failed to update favorite",
1169 );
1170 },
1171 onSuccess: (result, variables) => {
1172 if (typeof result.isFavorite === "boolean") {
1173 updateCachedPresentationFavorite(
1174 variables.documentId,
1175 result.isFavorite,
1176 );
1177 }
1178 },
1179 onSettled: () => {
1180 queryClient.invalidateQueries({ queryKey: PRESENTATIONS_QUERY_KEY });
1181 },
1182 });
1183
1184 const renameMutation = useMutation({
1185 mutationFn: async ({
1186 documentId,
1187 title,
1188 }: {
1189 documentId: string;
1190 title: string;
1191 }) => {
1192 const result = await updatePresentationTitle(documentId, title);
1193
1194 if (!result.success) {
1195 throw new Error(result.message || "Failed to rename presentation");
1196 }
1197
1198 return result;
1199 },
1200 onMutate: async ({ documentId, title }) => {
1201 await queryClient.cancelQueries({ queryKey: PRESENTATIONS_QUERY_KEY });
1202
1203 const previousQueries =
1204 queryClient.getQueriesData<PresentationsInfiniteData>({
1205 queryKey: PRESENTATIONS_QUERY_KEY,
1206 });
1207
1208 updateCachedPresentationTitle(documentId, title);
1209
1210 return { previousQueries };
1211 },
1212 onError: (error, _variables, context) => {
1213 context?.previousQueries.forEach(([queryKey, previousData]) => {
1214 queryClient.setQueryData(queryKey, previousData);
1215 });
1216
1217 toast.error(
1218 error instanceof Error
1219 ? error.message
1220 : "Failed to rename presentation",
1221 );
1222 },
1223 onSuccess: () => {
1224 toast.success("Presentation renamed");
1225 },
1226 onSettled: () => {
1227 queryClient.invalidateQueries({ queryKey: PRESENTATIONS_QUERY_KEY });
1228 },
1229 });
1230
1231 const deleteMutation = useMutation({
1232 mutationFn: async ({ documentId }: { documentId: string }) => {
1233 const result = await deletePresentation(documentId);
1234
1235 if (!result.success) {
1236 throw new Error(result.message || "Failed to delete presentation");
1237 }
1238
1239 return result;
1240 },
1241 onMutate: async ({ documentId }) => {
1242 await queryClient.cancelQueries({ queryKey: PRESENTATIONS_QUERY_KEY });
1243
1244 const previousQueries =
1245 queryClient.getQueriesData<PresentationsInfiniteData>({
1246 queryKey: PRESENTATIONS_QUERY_KEY,
1247 });
1248
1249 removeCachedPresentation(documentId);
1250
1251 return { previousQueries };
1252 },
1253 onError: (error, _variables, context) => {
1254 context?.previousQueries.forEach(([queryKey, previousData]) => {
1255 queryClient.setQueryData(queryKey, previousData);
1256 });
1257
1258 toast.error(
1259 error instanceof Error
1260 ? error.message
1261 : "Failed to delete presentation",
1262 );
1263 },
1264 onSuccess: () => {
1265 toast.success("Presentation deleted");
1266 },
1267 onSettled: () => {
1268 queryClient.invalidateQueries({ queryKey: PRESENTATIONS_QUERY_KEY });
1269 },
1270 });
1271
1272 const duplicateMutation = useMutation({
1273 mutationFn: async ({ documentId }: { documentId: string }) => {
1274 const result = await duplicatePresentation(documentId);
1275
1276 if (!result.success || !result.presentation) {
1277 throw new Error(result.message || "Failed to duplicate presentation");
1278 }
1279
1280 return result.presentation;
1281 },
1282 onSuccess: (presentation) => {
1283 queryClient.invalidateQueries({ queryKey: PRESENTATIONS_QUERY_KEY });
1284 toast.success("Presentation duplicated");
1285 router.push(`/presentation/${presentation.id}`);
1286 },
1287 onError: (error) => {
1288 toast.error(
1289 error instanceof Error
1290 ? error.message
1291 : "Failed to duplicate presentation",
1292 );
1293 },
1294 });
1295
1296 const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
1297 useInfiniteQuery({
1298 queryKey: [...PRESENTATIONS_QUERY_KEY, documentTypeFilter, favoritesOnly],
1299 queryFn: async ({ pageParam = 0 }) =>
1300 fetchPresentations(pageParam, typeFilter, { favoritesOnly }),
1301 getNextPageParam: (lastPage, allPages) =>
1302 lastPage.hasMore ? allPages.length : undefined,
1303 initialPageParam: 0,
1304 });
1305 const { ref: loadMoreRef } = useInView({
1306 onChange: (inView) => {
1307 if (inView && hasNextPage && !isFetchingNextPage) {
1308 void fetchNextPage();
1309 }
1310 },
1311 });
1312
1313 const allPresentations = data?.pages.flatMap((page) => page.items) ?? [];
1314 const fileItems: PresentationFileItem[] = allPresentations.map((item) => ({
1315 id: item.id,
1316 name: item.title || "Untitled",
1317 thumbnailUrl: item.thumbnailUrl,
1318 modified: formatDistanceToNow(new Date(item.updatedAt), {
1319 addSuffix: true,
1320 }),
1321 modifiedAt: new Date(item.updatedAt),
1322 isFavorited: item.favorites.length > 0,
1323 isFavoritePending:
1324 favoriteMutation.isPending &&
1325 favoriteMutation.variables?.documentId === item.id,
1326 isRenamePending:
1327 renameMutation.isPending && renameMutation.variables?.documentId === item.id,
1328 isDeletePending:
1329 deleteMutation.isPending && deleteMutation.variables?.documentId === item.id,
1330 isDuplicatePending:
1331 duplicateMutation.isPending &&
1332 duplicateMutation.variables?.documentId === item.id,
1333 onClick: () => router.push(getPresentationRoute(item)),
1334 onToggleFavorite: () =>
1335 favoriteMutation.mutate({
1336 documentId: item.id,
1337 isFavorited: item.favorites.length > 0,
1338 }),
1339 onRename: async (nextName) => {
1340 try {
1341 await renameMutation.mutateAsync({
1342 documentId: item.id,
1343 title: nextName,
1344 });
1345 return true;
1346 } catch {
1347 return false;
1348 }
1349 },
1350 onDelete: async () => {
1351 try {
1352 await deleteMutation.mutateAsync({ documentId: item.id });
1353 return true;
1354 } catch {
1355 return false;
1356 }
1357 },
1358 onDuplicate: () => duplicateMutation.mutate({ documentId: item.id }),
1359 }));
1360
1361 const selectedLanguageLabel =
1362 LANGUAGE_OPTIONS.find((option) => option.value === language)?.label ??
1363 "English";
1364 const slidesLabel =
1365 SLIDE_OPTIONS.find((option) => option.value === String(numSlides))?.label ??
1366 `${numSlides} slides`;
1367 const outputFormatLabel =
1368 getPresentationGenerationAspectRatioLabel(generationAspectRatio);
1369 const filterOptions = useMemo(
1370 () => [
1371 { id: ALL_PRESENTATION_DOCUMENT_TYPES, label: "All" },
1372 { id: "PRESENTATION", label: "Presentations" },
1373 ],
1374 [],
1375 );
1376
1377 const handleGenerate = async () => {
1378 const prompt = presentationInput.trim();
1379
1380 if (!prompt) {
1381 return;
1382 }
1383
1384 const initialTheme = resolvedTheme === "dark" ? "ebony" : "mystique";
1385 const title = prompt.substring(0, 50) || "Untitled Presentation";
1386
1387 setOutputFormat("flow");
1388 setIsGeneratingOutline(true);
1389 setTheme(initialTheme);
1390
1391 const customization = buildPresentationCustomization({
1392 customThemeData,
1393 themeDataByTheme,
1394 generatedThemeData,
1395 theme: initialTheme,
1396 pageStyle,
1397 presentationStyle,
1398 generationAspectRatio,
1399 textContent,
1400 tone,
1401 audience,
1402 scenario,
1403 pageBackground,
1404 selectedSlideTemplates,
1405 outlineItemIds,
1406 outlineTemplateOverrides,
1407 });
1408
1409 try {
1410 const result = await createEmptyPresentation({
1411 title,
1412 theme: initialTheme,
1413 language,
1414 customization,
1415 });
1416
1417 if (!result.success || !result.presentation) {
1418 setIsGeneratingOutline(false);
1419 toast.error(result.message || "Failed to create presentation");
1420 return;
1421 }
1422
1423 setCurrentPresentation(result.presentation.id, result.presentation.title);
1424 startOutlineGeneration();
1425 router.push(`/presentation/generate/${result.presentation.id}`);
1426 } catch (error) {
1427 setIsGeneratingOutline(false);
1428 console.error("Error creating presentation:", error);
1429 toast.error("Failed to create presentation");
1430 }
1431 };
1432
1433 return (
1434 <NotebookPageLayout>
1435 <GreetingSection />
1436
1437 <NotebookInputBox
1438 placeholder="Describe your topic or paste your content here. Our AI will structure it into a compelling presentation."
1439 value={presentationInput}
1440 onChange={setPresentationInput}
1441 onSubmit={handleGenerate}
1442 submitDisabled={!presentationInput.trim() || isGeneratingOutline}
1443 isSubmitting={isGeneratingOutline}
1444 >
1445 <div className="flex min-w-0 flex-wrap items-center gap-2">
1446 <SettingPill icon={PanelsTopLeft} label={slidesLabel}>
1447 <DropdownMenuLabel>Slides</DropdownMenuLabel>
1448 <DropdownMenuRadioGroup
1449 value={String(numSlides)}
1450 onValueChange={(value) => setNumSlides(Number(value))}
1451 >
1452 {SLIDE_OPTIONS.map((option) => (
1453 <DropdownMenuRadioItem key={option.value} value={option.value}>
1454 {option.label}
1455 </DropdownMenuRadioItem>
1456 ))}
1457 </DropdownMenuRadioGroup>
1458 </SettingPill>
1459
1460 <SettingPill icon={LayoutTemplate} label={outputFormatLabel}>
1461 <DropdownMenuLabel>Format</DropdownMenuLabel>
1462 <DropdownMenuRadioGroup
1463 value={generationAspectRatio}
1464 onValueChange={(value) =>
1465 setGenerationAspectRatio(
1466 value as PresentationGenerationAspectRatio,
1467 )
1468 }
1469 >
1470 <DropdownMenuRadioItem value="dynamic">
1471 Dynamic
1472 </DropdownMenuRadioItem>
1473 <DropdownMenuRadioItem value="16:9">16:9</DropdownMenuRadioItem>
1474 </DropdownMenuRadioGroup>
1475 </SettingPill>
1476
1477 <SettingPill icon={Languages} label={selectedLanguageLabel}>
1478 <DropdownMenuLabel>Language</DropdownMenuLabel>
1479 <DropdownMenuRadioGroup value={language} onValueChange={setLanguage}>
1480 {LANGUAGE_OPTIONS.map((option) => (
1481 <DropdownMenuRadioItem key={option.value} value={option.value}>
1482 {option.label}
1483 </DropdownMenuRadioItem>
1484 ))}
1485 </DropdownMenuRadioGroup>
1486 </SettingPill>
1487
1488 <DropdownMenu>
1489 <DropdownMenuTrigger asChild>
1490 <button
1491 type="button"
1492 className="inline-flex h-8 items-center gap-2 rounded-full border border-border bg-background px-3 text-[13px] font-medium text-foreground transition-colors hover:bg-accent sm:h-9 sm:px-3.5 sm:text-sm"
1493 >
1494 <WandSparkles className="size-3.5 sm:size-4" />
1495 More
1496 </button>
1497 </DropdownMenuTrigger>
1498 <DropdownMenuContent align="start" className="w-56">
1499 <DropdownMenuCheckboxItem
1500 checked={webSearchEnabled}
1501 onCheckedChange={setWebSearchEnabled}
1502 >
1503 <Globe className="size-4" />
1504 Web Search
1505 </DropdownMenuCheckboxItem>
1506 <DropdownMenuCheckboxItem
1507 checked={autoThemeEnabled}
1508 onCheckedChange={setAutoThemeEnabled}
1509 >
1510 <WandSparkles className="size-4" />
1511 Auto Theme
1512 </DropdownMenuCheckboxItem>
1513 </DropdownMenuContent>
1514 </DropdownMenu>
1515
1516 <ModelPicker shouldShowLabel={false} />
1517
1518 </div>
1519 </NotebookInputBox>
1520
1521 <PresentationProjectFilesSection
1522 files={fileItems}
1523 isLoading={isLoading}
1524 onCreateNew={() => {
1525 if (!isCreatingBlank) {
1526 void handleCreateBlank();
1527 }
1528 }}
1529 filterOptions={filterOptions}
1530 activeFilterId={documentTypeFilter}
1531 onFilterChange={(filterId) =>
1532 setDocumentTypeFilter(filterId as PresentationDocumentTypeFilterValue)
1533 }
1534 activeTab={libraryTab}
1535 onActiveTabChange={setLibraryTab}
1536 showFavoritesOnly={showFavoritesOnly}
1537 onShowFavoritesOnlyChange={setShowFavoritesOnly}
1538 />
1539
1540 {hasNextPage ? (
1541 <div ref={loadMoreRef} className="flex justify-center py-4">
1542 {isFetchingNextPage ? (
1543 <div className="size-5 animate-spin rounded-full border-2 border-foreground border-t-transparent" />
1544 ) : null}
1545 </div>
1546 ) : null}
1547 </NotebookPageLayout>
1548 );
1549 }
1550
1550 lines Plain Text