返回 presentation-ai
OutlineTemplateModal.tsx
根目录 / src / components / notebook / presentation / components / outline / OutlineTemplateModal.tsx
1 "use client";
2
3 import { Check, ChevronRight, LayoutGrid, Menu, X } from "lucide-react";
4 import { motion } from "motion/react";
5 import { useCallback, 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 {
19 isTemplateSelected,
20 removeTemplateSelection,
21 TEMPLATE_CATEGORIES,
22 TEMPLATE_DEFINITIONS,
23 type TemplateDefinition,
24 } from "../../utils/templates";
25 import { persistOutlineLayoutSelection } from "./persistOutlineLayoutSelection";
26
27 interface SelectableTemplateCardProps {
28 template: TemplateDefinition;
29 isSelected: boolean;
30 onToggle: () => void;
31 disabled?: boolean;
32 }
33
34 function SelectableTemplateCard({
35 template,
36 isSelected,
37 onToggle,
38 disabled,
39 }: SelectableTemplateCardProps) {
40 return (
41 <motion.button
42 onClick={onToggle}
43 disabled={disabled}
44 className={cn(
45 "group relative flex flex-col gap-2 text-left",
46 disabled && !isSelected && "cursor-not-allowed opacity-50",
47 )}
48 whileHover={!disabled ? { scale: 1.02 } : undefined}
49 whileTap={!disabled ? { scale: 0.98 } : undefined}
50 >
51 <div
52 className={cn(
53 "aspect-4/3 w-full overflow-hidden rounded-lg border bg-card shadow transition-all",
54 isSelected
55 ? "border-primary ring-2 ring-primary/20"
56 : "border-border group-hover:border-primary/50 group-hover:shadow-md",
57 )}
58 >
59 {template.preview}
60 {/* Selection indicator */}
61 {isSelected && (
62 <div className="absolute top-2 right-2 flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
63 <Check className="size-3" />
64 </div>
65 )}
66 </div>
67 <div className="flex items-center gap-2">
68 <span
69 className={cn(
70 "text-xs font-medium",
71 isSelected
72 ? "text-foreground"
73 : "text-muted-foreground group-hover:text-foreground",
74 )}
75 >
76 {template.name}
77 </span>
78 </div>
79 </motion.button>
80 );
81 }
82
83 interface OutlineTemplateModalProps {
84 isOpen: boolean;
85 onClose: () => void;
86 mode: "global" | "single";
87 outlineId?: string; // Only for "single" mode
88 }
89
90 export function OutlineTemplateModal({
91 isOpen,
92 onClose,
93 mode,
94 outlineId,
95 }: OutlineTemplateModalProps) {
96 const isDesktop = useMediaQuery("(min-width: 768px)");
97 const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
98 const [isSidebarVisible, setIsSidebarVisible] = useState(false);
99
100 const {
101 selectedSlideTemplates,
102 setSelectedSlideTemplates,
103 numSlides,
104 outlineTemplateOverrides,
105 setOutlineTemplateOverride,
106 } = usePresentationState();
107
108 const singleTemplateId =
109 mode === "single" && outlineId ? outlineTemplateOverrides[outlineId] : null;
110
111 useEffect(() => {
112 if (isOpen) {
113 setIsSidebarVisible(isDesktop);
114 }
115 }, [isDesktop, isOpen]);
116
117 const effectiveSelection =
118 mode === "global"
119 ? selectedSlideTemplates
120 : singleTemplateId
121 ? [singleTemplateId]
122 : [];
123
124 const filteredTemplates = selectedCategory
125 ? TEMPLATE_DEFINITIONS.filter((t) => t.categoryId === selectedCategory)
126 : TEMPLATE_DEFINITIONS;
127
128 const handleTemplateToggle = useCallback(
129 (templateId: string) => {
130 const template = TEMPLATE_DEFINITIONS.find(
131 (item) => item.id === templateId,
132 );
133
134 if (!template) {
135 return;
136 }
137
138 if (mode === "global") {
139 const isSelected = isTemplateSelected(selectedSlideTemplates, template);
140 if (isSelected) {
141 setSelectedSlideTemplates(
142 removeTemplateSelection(selectedSlideTemplates, template),
143 );
144 } else if (selectedSlideTemplates.length < numSlides) {
145 setSelectedSlideTemplates([...selectedSlideTemplates, templateId]);
146 }
147
148 void persistOutlineLayoutSelection();
149 } else {
150 // Single mode - one explicit layout per outline item.
151 const isSelected = singleTemplateId
152 ? isTemplateSelected([singleTemplateId], template)
153 : false;
154 const nextTemplateId = isSelected ? null : templateId;
155
156 if (outlineId) {
157 setOutlineTemplateOverride(outlineId, nextTemplateId);
158 void persistOutlineLayoutSelection();
159 }
160 }
161 },
162 [
163 mode,
164 selectedSlideTemplates,
165 setSelectedSlideTemplates,
166 numSlides,
167 singleTemplateId,
168 outlineId,
169 setOutlineTemplateOverride,
170 ],
171 );
172
173 const handleClearAll = () => {
174 if (mode === "global") {
175 setSelectedSlideTemplates([]);
176 } else if (outlineId) {
177 setOutlineTemplateOverride(outlineId, null);
178 }
179
180 void persistOutlineLayoutSelection();
181 };
182
183 const handleApply = () => {
184 onClose();
185 };
186
187 const getTemplatesByCategory = (categoryId: string) =>
188 TEMPLATE_DEFINITIONS.filter((t) => t.categoryId === categoryId);
189
190 const selectionLimit = mode === "global" ? numSlides : 1;
191 const isAtLimit =
192 mode === "global" && effectiveSelection.length >= selectionLimit;
193
194 const renderTemplateGrid = (templates: TemplateDefinition[]) => (
195 <div className="grid grid-cols-2 gap-3 p-1 sm:grid-cols-3 lg:grid-cols-4 lg:gap-4">
196 {templates.map((template) => (
197 <SelectableTemplateCard
198 key={template.id}
199 template={template}
200 isSelected={isTemplateSelected(effectiveSelection, template)}
201 onToggle={() => handleTemplateToggle(template.id)}
202 disabled={
203 isAtLimit && !isTemplateSelected(effectiveSelection, template)
204 }
205 />
206 ))}
207 </div>
208 );
209
210 return (
211 <Credenza
212 open={isOpen}
213 onOpenChange={(open) => {
214 if (!open) {
215 onClose();
216 }
217 }}
218 >
219 <CredenzaContent
220 shouldHaveClose={false}
221 className="h-[92dvh] max-h-[92dvh] max-w-250 gap-0 overflow-hidden p-0 md:h-auto"
222 >
223 <CredenzaHeader className="border-b px-4 py-4 sm:px-6">
224 <div className="flex items-start justify-between gap-3">
225 <div className="flex min-w-0 items-center gap-3">
226 {isDesktop && (
227 <Button
228 onClick={() => setIsSidebarVisible(!isSidebarVisible)}
229 variant="ghost"
230 size="icon"
231 className="size-8"
232 >
233 <Menu className="size-4" />
234 </Button>
235 )}
236 <div className="min-w-0">
237 <CredenzaTitle>
238 {mode === "global" ? "Select Layouts" : "Select Layout"}
239 </CredenzaTitle>
240 <p className="mt-1 text-sm text-muted-foreground">
241 {effectiveSelection.length}/{selectionLimit} selected
242 </p>
243 </div>
244 </div>
245 {isDesktop && (
246 <div className="flex items-center gap-2">
247 {effectiveSelection.length > 0 && (
248 <Button
249 onClick={handleClearAll}
250 variant="outline"
251 size="sm"
252 className="h-8"
253 >
254 Clear All
255 </Button>
256 )}
257 <Button onClick={handleApply} size="sm" className="h-8">
258 {mode === "global" ? "Done" : "Apply"}
259 </Button>
260 <Button
261 onClick={onClose}
262 variant="ghost"
263 size="icon"
264 className="size-8"
265 >
266 <X className="size-4" />
267 </Button>
268 </div>
269 )}
270 {!isDesktop && (
271 <Button
272 onClick={onClose}
273 variant="ghost"
274 size="icon"
275 className="size-8"
276 >
277 <X className="size-4" />
278 </Button>
279 )}
280 </div>
281 </CredenzaHeader>
282
283 {isDesktop ? (
284 <div className="flex h-[70vh]">
285 {/* Sidebar */}
286 {isSidebarVisible && (
287 <div className="w-60 border-r bg-muted/30">
288 <ScrollArea className="h-full space-y-2 p-3">
289 <button
290 type="button"
291 onClick={() => setSelectedCategory(null)}
292 className={cn(
293 "mb-1 flex w-full items-center gap-2 rounded-lg p-4 text-sm transition-colors",
294 selectedCategory === null
295 ? "bg-primary text-primary-foreground"
296 : "hover:bg-muted",
297 )}
298 >
299 <LayoutGrid className="size-4" />
300 <span>All Templates</span>
301 </button>
302 {TEMPLATE_CATEGORIES.map((category) => (
303 <button
304 type="button"
305 key={category.id}
306 onClick={() => setSelectedCategory(category.id)}
307 className={cn(
308 "flex w-full items-center justify-between rounded-lg p-4 text-sm transition-colors",
309 selectedCategory === category.id
310 ? "bg-primary text-primary-foreground"
311 : "hover:bg-muted",
312 )}
313 >
314 <div className="flex items-center gap-2">
315 {category.icon}
316 <span>{category.name}</span>
317 </div>
318 <ChevronRight className="size-4" />
319 </button>
320 ))}
321 </ScrollArea>
322 </div>
323 )}
324
325 {/* Content */}
326 <ScrollArea className="flex-1 overflow-x-visible p-5">
327 {selectedCategory === null ? (
328 <div className="space-y-8 px-1">
329 {TEMPLATE_CATEGORIES.map((category) => {
330 const categoryTemplates = getTemplatesByCategory(
331 category.id,
332 );
333 if (categoryTemplates.length === 0) return null;
334 return (
335 <section key={category.id}>
336 <div className="mb-3 flex items-center gap-2">
337 {category.icon}
338 <h3 className="text-sm font-medium">
339 {category.name}
340 </h3>
341 </div>
342 {renderTemplateGrid(categoryTemplates)}
343 </section>
344 );
345 })}
346 </div>
347 ) : (
348 renderTemplateGrid(filteredTemplates)
349 )}
350 </ScrollArea>
351 </div>
352 ) : (
353 <div className="flex min-h-0 flex-1 flex-col">
354 <div className="overflow-x-auto border-b">
355 <div className="flex w-max gap-2 px-4 py-3">
356 <button
357 type="button"
358 onClick={() => setSelectedCategory(null)}
359 className={cn(
360 "flex shrink-0 items-center gap-2 rounded-full border px-3 py-2 text-sm transition-colors",
361 selectedCategory === null
362 ? "border-primary bg-primary text-primary-foreground"
363 : "border-border bg-background hover:bg-muted",
364 )}
365 >
366 <LayoutGrid className="size-4" />
367 <span>All Layouts</span>
368 </button>
369 {TEMPLATE_CATEGORIES.map((category) => (
370 <button
371 type="button"
372 key={category.id}
373 onClick={() => setSelectedCategory(category.id)}
374 className={cn(
375 "flex shrink-0 items-center gap-2 rounded-full border px-3 py-2 text-sm transition-colors",
376 selectedCategory === category.id
377 ? "border-primary bg-primary text-primary-foreground"
378 : "border-border bg-background hover:bg-muted",
379 )}
380 >
381 {category.icon}
382 <span>{category.name}</span>
383 </button>
384 ))}
385 </div>
386 </div>
387
388 <ScrollArea className="min-h-0 flex-1 px-4 py-4">
389 {selectedCategory === null ? (
390 <div className="space-y-6">
391 {TEMPLATE_CATEGORIES.map((category) => {
392 const categoryTemplates = getTemplatesByCategory(
393 category.id,
394 );
395 if (categoryTemplates.length === 0) return null;
396 return (
397 <section key={category.id}>
398 <div className="mb-3 flex items-center gap-2">
399 {category.icon}
400 <h3 className="text-sm font-medium">
401 {category.name}
402 </h3>
403 </div>
404 {renderTemplateGrid(categoryTemplates)}
405 </section>
406 );
407 })}
408 </div>
409 ) : (
410 renderTemplateGrid(filteredTemplates)
411 )}
412 </ScrollArea>
413
414 <div className="flex flex-col gap-2 border-t px-4 py-3">
415 {effectiveSelection.length > 0 && (
416 <Button onClick={handleClearAll} variant="outline">
417 Clear All
418 </Button>
419 )}
420 <Button onClick={handleApply}>
421 {mode === "global" ? "Done" : "Apply"}
422 </Button>
423 </div>
424 </div>
425 )}
426 </CredenzaContent>
427 </Credenza>
428 );
429 }
430
430 lines Plain Text