返回 presentation-ai
SlideTemplateModal.tsx
根目录 / src / components / notebook / presentation / editor / custom-elements / SlideTemplateModal.tsx
1 "use client";
2
3 import { ChevronRight, LayoutGrid, Menu, X } from "lucide-react";
4 import { m as motion } from "motion/react";
5 import { useEffect, useState } from "react";
6
7 import { Button } from "@/components/ui/button";
8 import {
9 Credenza,
10 CredenzaContent,
11 CredenzaHeader,
12 CredenzaTitle,
13 } from "@/components/ui/credenza";
14 import { ScrollArea } from "@/components/ui/scroll-area";
15 import { useMediaQuery } from "@/hooks/globals/useMediaQuery";
16 import { cn } from "@/lib/utils";
17 import { usePresentationState } from "@/states/presentation-state";
18 import { type PlateSlide } from "../../utils/parser";
19 import {
20 TEMPLATE_CATEGORIES,
21 TEMPLATE_DEFINITIONS,
22 type TemplateDefinition,
23 } from "../../utils/templates";
24
25 const getTemplatesByCategory = (categoryId: string) =>
26 TEMPLATE_DEFINITIONS.filter((t) => t.categoryId === categoryId);
27
28 interface TemplateCardProps {
29 template: TemplateDefinition;
30 onClick: () => void;
31 }
32
33 function TemplateCard({ template, onClick }: TemplateCardProps) {
34 return (
35 <motion.button
36 onClick={onClick}
37 className="group flex flex-col gap-2 text-left"
38 whileHover={{ scale: 1.02 }}
39 whileTap={{ scale: 0.98 }}
40 >
41 <div className="aspect-4/3 w-full overflow-hidden rounded-lg border border-border bg-card shadow transition-all group-hover:border-primary/50 group-hover:shadow-md">
42 {template.preview}
43 </div>
44 <span className="text-xs font-medium text-muted-foreground group-hover:text-foreground">
45 {template.name}
46 </span>
47 </motion.button>
48 );
49 }
50
51 interface SlideTemplateModalProps {
52 isOpen: boolean;
53 onClose: () => void;
54 slideId: string;
55 }
56
57 export function SlideTemplateModal({
58 isOpen,
59 onClose,
60 slideId,
61 }: SlideTemplateModalProps) {
62 const isDesktop = useMediaQuery("(min-width: 768px)");
63 const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
64 const [isSidebarVisible, setIsSidebarVisible] = useState(false);
65
66 useEffect(() => {
67 setIsSidebarVisible(isDesktop);
68 }, [isDesktop]);
69
70 const filteredTemplates = selectedCategory
71 ? TEMPLATE_DEFINITIONS.filter((t) => t.categoryId === selectedCategory)
72 : TEMPLATE_DEFINITIONS;
73
74 const handleTemplateSelect = (template: TemplateDefinition) => {
75 const { slides, setSlides } = usePresentationState.getState();
76 const updatedSlides = slides.map((slide) => {
77 if (slide.id !== slideId) return slide;
78 return {
79 ...template.template,
80 id: slideId,
81 } as PlateSlide;
82 });
83 setSlides(updatedSlides);
84 onClose();
85 };
86
87 const renderTemplateGrid = (templates: TemplateDefinition[]) => (
88 <div className="grid grid-cols-2 gap-3 p-1 sm:grid-cols-3 lg:grid-cols-4 lg:gap-4">
89 {templates.map((template) => (
90 <TemplateCard
91 key={template.id}
92 template={template}
93 onClick={() => handleTemplateSelect(template)}
94 />
95 ))}
96 </div>
97 );
98
99 return (
100 <Credenza
101 open={isOpen}
102 onOpenChange={(open) => {
103 if (!open) {
104 onClose();
105 }
106 }}
107 >
108 <CredenzaContent
109 shouldHaveClose={false}
110 className="max-h-[92dvh] max-w-250 gap-0 overflow-hidden p-0"
111 >
112 <CredenzaHeader className="flex flex-row items-center justify-between border-b p-4 sm:px-6">
113 <div className="flex items-center gap-3">
114 {isDesktop && (
115 <Button
116 onClick={() => setIsSidebarVisible(!isSidebarVisible)}
117 variant="ghost"
118 size="icon"
119 className="size-8"
120 >
121 <Menu className="size-4" />
122 </Button>
123 )}
124 <CredenzaTitle>Select a layout</CredenzaTitle>
125 </div>
126 <Button
127 onClick={onClose}
128 variant="ghost"
129 size="icon"
130 className="size-8"
131 >
132 <X className="size-4" />
133 </Button>
134 </CredenzaHeader>
135
136 {isDesktop ? (
137 <div className="flex h-[70vh]">
138 {/* Sidebar */}
139 {isSidebarVisible && (
140 <div className="w-56 border-r bg-muted/30">
141 <ScrollArea className="h-full p-3">
142 <button
143 type="button"
144 onClick={() => setSelectedCategory(null)}
145 className={cn(
146 "mb-1 flex w-full items-center gap-2 rounded-lg p-4 text-sm transition-colors",
147 selectedCategory === null
148 ? "bg-primary text-primary-foreground"
149 : "hover:bg-muted",
150 )}
151 >
152 <LayoutGrid className="size-4" />
153 <span>All Layouts</span>
154 </button>
155 {TEMPLATE_CATEGORIES.map((category) => (
156 <button
157 type="button"
158 key={category.id}
159 onClick={() => setSelectedCategory(category.id)}
160 className={cn(
161 "flex w-full items-center justify-between rounded-lg p-4 text-sm transition-colors",
162 selectedCategory === category.id
163 ? "bg-primary text-primary-foreground"
164 : "hover:bg-muted",
165 )}
166 >
167 <div className="flex items-center gap-2">
168 {category.icon}
169 <span>{category.name}</span>
170 </div>
171 <ChevronRight className="size-3" />
172 </button>
173 ))}
174 </ScrollArea>
175 </div>
176 )}
177
178 {/* Content */}
179 <ScrollArea className="flex-1 p-6">
180 {selectedCategory === null ? (
181 <div className="space-y-8 p-1">
182 {TEMPLATE_CATEGORIES.map((category) => {
183 const categoryTemplates = getTemplatesByCategory(
184 category.id,
185 );
186 if (categoryTemplates.length === 0) return null;
187 return (
188 <section key={category.id}>
189 <div className="mb-3 flex items-center gap-2">
190 {category.icon}
191 <h3 className="text-sm font-medium">
192 {category.name}
193 </h3>
194 </div>
195 {renderTemplateGrid(categoryTemplates)}
196 </section>
197 );
198 })}
199 </div>
200 ) : (
201 renderTemplateGrid(filteredTemplates)
202 )}
203 </ScrollArea>
204 </div>
205 ) : (
206 <div className="flex max-h-[calc(92dvh-4.5rem)] flex-col">
207 <ScrollArea className="border-b">
208 <div className="flex gap-2 px-4 py-3">
209 <button
210 type="button"
211 onClick={() => setSelectedCategory(null)}
212 className={cn(
213 "flex shrink-0 items-center gap-2 rounded-full border px-3 py-2 text-sm transition-colors",
214 selectedCategory === null
215 ? "border-primary bg-primary text-primary-foreground"
216 : "border-border bg-background hover:bg-muted",
217 )}
218 >
219 <LayoutGrid className="size-4" />
220 <span>All Layouts</span>
221 </button>
222 {TEMPLATE_CATEGORIES.map((category) => (
223 <button
224 type="button"
225 key={category.id}
226 onClick={() => setSelectedCategory(category.id)}
227 className={cn(
228 "flex shrink-0 items-center gap-2 rounded-full border px-3 py-2 text-sm transition-colors",
229 selectedCategory === category.id
230 ? "border-primary bg-primary text-primary-foreground"
231 : "border-border bg-background hover:bg-muted",
232 )}
233 >
234 {category.icon}
235 <span>{category.name}</span>
236 </button>
237 ))}
238 </div>
239 </ScrollArea>
240
241 <ScrollArea className="flex-1 p-4">
242 {selectedCategory === null ? (
243 <div className="space-y-6">
244 {TEMPLATE_CATEGORIES.map((category) => {
245 const categoryTemplates = getTemplatesByCategory(
246 category.id,
247 );
248 if (categoryTemplates.length === 0) return null;
249 return (
250 <section key={category.id}>
251 <div className="mb-3 flex items-center gap-2">
252 {category.icon}
253 <h3 className="text-sm font-medium">
254 {category.name}
255 </h3>
256 </div>
257 {renderTemplateGrid(categoryTemplates)}
258 </section>
259 );
260 })}
261 </div>
262 ) : (
263 renderTemplateGrid(filteredTemplates)
264 )}
265 </ScrollArea>
266 </div>
267 )}
268 </CredenzaContent>
269 </Credenza>
270 );
271 }
272
272 lines Plain Text