返回 presentation-ai
CreateThemeModal.tsx
根目录 / src / components / notebook / presentation / components / theme / create-theme / CreateThemeModal.tsx
1 "use client";
2
3 import { useQueryClient } from "@tanstack/react-query";
4 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5 import { useForm } from "react-hook-form";
6 import { toast } from "sonner";
7
8 import { updatePresentation } from "@/app/_actions/notebook/presentation/presentationActions";
9 import {
10 createCustomTheme,
11 updateAdminPresentationTheme,
12 updateCustomTheme,
13 } from "@/app/_actions/presentation/theme-actions";
14 import { useThemePanelState } from "@/components/presentation/edit-panel/sections/theme/theme-panel-state";
15 import {
16 Credenza,
17 CredenzaContent,
18 CredenzaTitle,
19 } from "@/components/ui/credenza";
20 import { VisuallyHidden } from "@/components/ui/visually-hidden";
21 import { buildPresentationCustomization } from "@/lib/presentation/customization";
22 import { isBuiltInPresentationTheme } from "@/lib/presentation/theme-resolution";
23 import { themes, type ThemeColorsKeys } from "@/lib/presentation/themes";
24 import { usePresentationState } from "@/states/presentation-state";
25 import { type ThemeFormValues } from "../types";
26 import { colorThemes, type PreviewTab } from "./create-theme-types";
27 import { CreateThemeFooter } from "./CreateThemeFooter";
28 import { CreateThemeHeader } from "./CreateThemeHeader";
29 import { PreviewSection } from "./PreviewSection";
30 import { StepContent } from "./StepContent";
31 import { usePreviewData } from "./usePreviewData";
32 import { useStepNavigation } from "./useStepNavigation";
33 import { useThemeCreationLogic } from "./useThemeCreationLogic";
34
35 const DEFAULT_THEME_VALUES: ThemeFormValues = {
36 isPublic: false,
37 themeBase: "blank",
38 ...themes.mystique,
39 background: undefined,
40 description: "",
41 name: "",
42 };
43
44 interface CreateThemeModalProps {
45 previewMode?: "all" | "test-only";
46 }
47
48 export function CreateThemeModal({
49 previewMode = "all",
50 }: CreateThemeModalProps) {
51 const {
52 setOpenCreateThemeModal,
53 openCreateThemeModal,
54 editingTheme,
55 setEditingTheme,
56 isCustomizing,
57 setIsCustomizing,
58 importedThemeData,
59 setImportedThemeData,
60 } = useThemePanelState();
61
62 const [previewTab, setPreviewTab] = useState<PreviewTab>("current");
63 const previewTabs = useMemo<PreviewTab[]>(
64 () => (previewMode === "test-only" ? ["test"] : ["test", "current"]),
65 [previewMode],
66 );
67 const [isSubmitting, setIsSubmitting] = useState(false);
68 const containerRef = useRef<HTMLDivElement | null>(null);
69 const currentPresentationId = usePresentationState(
70 (s) => s.currentPresentationId,
71 );
72 const baseThemeData = editingTheme?.baseThemeData ?? editingTheme?.themeData;
73
74 // Prepare default values based on imported theme, editing theme, or default
75 const defaultValues = useMemo(() => {
76 // Imported theme data from PPTX file
77 if (importedThemeData) {
78 return {
79 isPublic: false,
80 themeBase: "blank" as const,
81 ...importedThemeData,
82 description: importedThemeData.description || "",
83 };
84 }
85
86 if (editingTheme) {
87 // Get the base name, falling back to themeData name or "Custom Theme"
88 const baseName =
89 editingTheme.name || editingTheme.themeData?.name || "Custom Theme";
90
91 if (isCustomizing) {
92 // When customizing, use the theme data and prefill name with "Copy of {theme name}"
93 // Only prepend "Copy of" if it's not already there
94 const name = baseName.startsWith("Copy of ")
95 ? baseName
96 : `Copy of ${baseName}`;
97
98 return {
99 isPublic: false,
100 themeBase: "blank" as const,
101 ...editingTheme.themeData,
102 description: "",
103 name,
104 };
105 }
106 // Normal editing
107 return {
108 isPublic: editingTheme.isPublic,
109 themeBase: "blank" as const,
110 ...editingTheme.themeData,
111 description: editingTheme.description || "",
112 name: baseName,
113 };
114 }
115 return DEFAULT_THEME_VALUES;
116 }, [editingTheme, isCustomizing, importedThemeData]);
117
118 // Form
119 const form = useForm<ThemeFormValues>({
120 defaultValues,
121 values: defaultValues, // Ensure form updates when editingTheme changes
122 });
123 const { control, handleSubmit, setValue, reset } = form;
124 const {
125 selectedColorTheme,
126 applyThemePreset,
127 linkedColorChange,
128 setSelectedColorTheme,
129 setShowAdvancedColors,
130 } = useThemeCreationLogic({
131 setValue,
132 initialTheme: baseThemeData,
133 });
134
135 const handleClose = () => {
136 setOpenCreateThemeModal(false);
137 setEditingTheme(null);
138 setIsCustomizing(false);
139 setImportedThemeData(null);
140 };
141
142 const queryClient = useQueryClient();
143
144 // Submit handler for creating new themes (used when navigating to save step and submitting)
145 const onSubmit = async (data: ThemeFormValues) => {
146 try {
147 setIsSubmitting(true);
148 const {
149 name,
150 description,
151 isPublic: _isPublic,
152 themeBase: _themeBase,
153 ...themeStyleData
154 } = data;
155
156 // Validate custom font URLs
157 const fonts = themeStyleData.fonts as ThemeFormValues["fonts"];
158
159 if (fonts.headingUrl && !fonts.headingUrl.match(/^https?:\/\/.+/)) {
160 toast.error("Heading font URL is invalid", {
161 description: "Please check the font URL or remove it.",
162 });
163 setIsSubmitting(false);
164 return;
165 }
166
167 if (fonts.bodyUrl && !fonts.bodyUrl.match(/^https?:\/\/.+/)) {
168 toast.error("Body font URL is invalid", {
169 description: "Please check the font URL or remove it.",
170 });
171 setIsSubmitting(false);
172 return;
173 }
174
175 let result;
176 // When editing (and NOT customizing), update the existing theme
177 if (editingTheme && !isCustomizing) {
178 const updateTheme = editingTheme.isAdmin
179 ? updateAdminPresentationTheme
180 : updateCustomTheme;
181
182 result = await updateTheme(editingTheme.id, {
183 name,
184 description,
185 isPublic: false,
186 themeData: themeStyleData,
187 });
188 } else {
189 if (isCustomizing) {
190 if (!currentPresentationId) {
191 toast.error("No presentation selected");
192 setIsSubmitting(false);
193 return;
194 }
195
196 // Get original theme name (from built-in themes)
197 const currentThemeId = usePresentationState.getState().theme;
198 const builtInTheme = themes[currentThemeId as keyof typeof themes];
199 const originalThemeName =
200 builtInTheme?.name ||
201 editingTheme?.themeData?.name ||
202 String(currentThemeId) ||
203 "Custom Theme";
204
205 // Use the user's input if provided, otherwise use "Copy of {original}"
206 const themeName = name || `Copy of ${originalThemeName}`;
207
208 // Creating new theme from customization
209 const createResult = await createCustomTheme({
210 name: themeName,
211 description: description || "",
212 isPublic: false,
213 themeData: themeStyleData,
214 });
215
216 if (createResult.success && createResult.themeId) {
217 const createdThemeData = {
218 ...themeStyleData,
219 name: themeName,
220 description: description || "",
221 };
222
223 // Apply the new theme to the presentation
224 usePresentationState
225 .getState()
226 .setTheme(createResult.themeId, createdThemeData);
227
228 // Update the presentation to use the new theme
229 result = await updatePresentation({
230 id: currentPresentationId,
231 theme: createResult.themeId,
232 });
233 } else {
234 result = createResult;
235 }
236 } else {
237 // Creating new theme (from scratch)
238 result = await createCustomTheme({
239 name,
240 description,
241 isPublic: false,
242 themeData: themeStyleData,
243 });
244 }
245 }
246
247 if (result.success) {
248 if (editingTheme && !isCustomizing) {
249 const currentThemeId = usePresentationState.getState().theme;
250 if (currentThemeId === editingTheme.id) {
251 usePresentationState.getState().setTheme(editingTheme.id, {
252 ...themeStyleData,
253 name,
254 description: description ?? "",
255 });
256 }
257 }
258
259 toast.success(
260 editingTheme && !isCustomizing
261 ? editingTheme.isAdmin
262 ? "System theme updated successfully!"
263 : "Theme updated successfully!"
264 : isCustomizing
265 ? "Customization saved successfully!"
266 : "Theme created successfully!",
267 );
268
269 // Invalidate queries to refresh the list
270 queryClient.invalidateQueries({
271 queryKey: ["presentation", "themes", "user"],
272 });
273 queryClient.invalidateQueries({
274 queryKey: ["presentation", "themes", "public"],
275 });
276 queryClient.invalidateQueries({
277 queryKey: ["presentation", "themes", "favorites"],
278 });
279 queryClient.invalidateQueries({
280 queryKey: ["presentation", "themes", "system"],
281 });
282
283 handleClose();
284 } else {
285 toast.error(result.message || "Failed to save theme");
286 }
287 } catch {
288 toast.error("An unexpected error occurred while saving the theme");
289 } finally {
290 setIsSubmitting(false);
291 }
292 };
293
294 const { currentStep, handleContinue, handleBack, setCurrentStep } =
295 useStepNavigation({
296 onClose: handleClose,
297 handleSubmit,
298 onSubmit,
299 });
300
301 // Handler for "Save" - save customization directly to the current presentation
302 const handleSaveCustomization = useCallback(async () => {
303 if (!currentPresentationId) {
304 toast.error("No presentation selected to customize");
305 return;
306 }
307
308 try {
309 setIsSubmitting(true);
310 const data = form.getValues();
311 // Strip form-only fields that aren't part of ThemeProperties
312 const {
313 description,
314 isPublic: _isPublic,
315 themeBase: _themeBase,
316 name: _name,
317 ...themeStyleData
318 } = data;
319
320 // Validate custom font URLs
321 const fonts = themeStyleData.fonts as ThemeFormValues["fonts"];
322
323 if (fonts.headingUrl && !fonts.headingUrl.match(/^https?:\/\/.+/)) {
324 toast.error("Heading font URL is invalid", {
325 description: "Please check the font URL or remove it.",
326 });
327 setIsSubmitting(false);
328 return;
329 }
330
331 if (fonts.bodyUrl && !fonts.bodyUrl.match(/^https?:\/\/.+/)) {
332 toast.error("Body font URL is invalid", {
333 description: "Please check the font URL or remove it.",
334 });
335 setIsSubmitting(false);
336 return;
337 }
338
339 // Get original theme name (from built-in themes, not the "Copy of..." form value)
340 const currentThemeId = usePresentationState.getState().theme;
341 const builtInTheme = themes[currentThemeId as keyof typeof themes];
342 const originalThemeName =
343 builtInTheme?.name || editingTheme?.themeData?.name || "Custom Theme";
344
345 const customThemeData = {
346 ...themeStyleData,
347 name: originalThemeName,
348 description: description || originalThemeName,
349 };
350
351 const currentTheme = usePresentationState.getState().theme as string;
352
353 const currentThemeDataByTheme =
354 usePresentationState.getState().themeDataByTheme;
355 const nextThemeDataByTheme = {
356 ...currentThemeDataByTheme,
357 [currentTheme]: customThemeData,
358 };
359 usePresentationState.getState().setThemeDataByTheme(nextThemeDataByTheme);
360
361 // Update the presentation state with new theme data
362 usePresentationState.getState().setTheme(currentTheme, customThemeData);
363
364 // Build customization object
365 const state = usePresentationState.getState();
366 const customization = buildPresentationCustomization({
367 customThemeData,
368 themeDataByTheme: nextThemeDataByTheme,
369 generatedThemeData: state.generatedThemeData,
370 theme: state.theme,
371 pageStyle: state.pageStyle,
372 presentationStyle: state.presentationStyle,
373 generationAspectRatio: state.generationAspectRatio,
374 textContent: state.textContent,
375 tone: state.tone,
376 audience: state.audience,
377 scenario: state.scenario,
378 pageBackground: state.pageBackground,
379 selectedSlideTemplates: state.selectedSlideTemplates,
380 outlineItemIds: state.outlineItemIds,
381 outlineTemplateOverrides: state.outlineTemplateOverrides,
382 });
383
384 // Save to the presentation
385 const result = await updatePresentation({
386 id: currentPresentationId,
387 theme: state.theme as string,
388 customization,
389 });
390
391 if (result.success) {
392 toast.success("Customization saved successfully!");
393 handleClose();
394 } else {
395 toast.error(result.message || "Failed to save customization");
396 }
397 } catch {
398 toast.error("An unexpected error occurred while saving");
399 } finally {
400 setIsSubmitting(false);
401 }
402 }, [currentPresentationId, form, handleClose]);
403
404 const handleSaveCurrentStep = useCallback(() => {
405 if (editingTheme && !isCustomizing) {
406 void handleSubmit(onSubmit)();
407 return;
408 }
409
410 void handleSaveCustomization();
411 }, [
412 editingTheme,
413 handleSaveCustomization,
414 handleSubmit,
415 isCustomizing,
416 onSubmit,
417 ]);
418
419 const handleResetCustomization = useCallback(async () => {
420 if (!currentPresentationId) {
421 toast.error("No presentation selected to reset");
422 return;
423 }
424
425 const state = usePresentationState.getState();
426 const currentTheme = state.theme as string;
427 const resetThemeData = baseThemeData ?? null;
428 const nextThemeDataByTheme = { ...state.themeDataByTheme };
429 delete nextThemeDataByTheme[currentTheme];
430
431 try {
432 setIsSubmitting(true);
433 usePresentationState.getState().setThemeDataByTheme(nextThemeDataByTheme);
434 usePresentationState
435 .getState()
436 .setTheme(
437 currentTheme,
438 isBuiltInPresentationTheme(currentTheme) ? null : resetThemeData,
439 );
440 if (resetThemeData) {
441 reset({
442 isPublic: false,
443 themeBase: "blank",
444 ...resetThemeData,
445 description: resetThemeData.description || "",
446 name: resetThemeData.name || editingTheme?.name || "Custom Theme",
447 });
448 }
449
450 const customization = buildPresentationCustomization({
451 customThemeData: isBuiltInPresentationTheme(currentTheme)
452 ? null
453 : resetThemeData,
454 themeDataByTheme: nextThemeDataByTheme,
455 generatedThemeData: state.generatedThemeData,
456 theme: currentTheme,
457 pageStyle: state.pageStyle,
458 presentationStyle: state.presentationStyle,
459 generationAspectRatio: state.generationAspectRatio,
460 textContent: state.textContent,
461 tone: state.tone,
462 audience: state.audience,
463 scenario: state.scenario,
464 pageBackground: state.pageBackground,
465 selectedSlideTemplates: state.selectedSlideTemplates,
466 outlineItemIds: state.outlineItemIds,
467 outlineTemplateOverrides: state.outlineTemplateOverrides,
468 });
469
470 const result = await updatePresentation({
471 id: currentPresentationId,
472 theme: currentTheme,
473 customization,
474 });
475
476 if (result.success) {
477 toast.success("Customization reset successfully!");
478 handleClose();
479 } else {
480 toast.error(result.message || "Failed to reset customization");
481 }
482 } catch {
483 toast.error("An unexpected error occurred while resetting");
484 } finally {
485 setIsSubmitting(false);
486 }
487 }, [
488 baseThemeData,
489 currentPresentationId,
490 editingTheme?.name,
491 handleClose,
492 reset,
493 ]);
494
495 // Handler for "Save & Create New" - create a new theme copy and apply it
496 const handleSaveAndCreateNew = useCallback(async () => {
497 if (!currentPresentationId) {
498 toast.error("No presentation selected");
499 return;
500 }
501
502 try {
503 setIsSubmitting(true);
504 const data = form.getValues();
505 const {
506 description,
507 isPublic: _isPublic,
508 themeBase: _themeBase,
509 name,
510 ...themeStyleData
511 } = data;
512
513 // Validate custom font URLs
514 const fonts = themeStyleData.fonts as ThemeFormValues["fonts"];
515
516 if (fonts.headingUrl && !fonts.headingUrl.match(/^https?:\/\/.+/)) {
517 toast.error("Heading font URL is invalid", {
518 description: "Please check the font URL or remove it.",
519 });
520 setIsSubmitting(false);
521 return;
522 }
523
524 if (fonts.bodyUrl && !fonts.bodyUrl.match(/^https?:\/\/.+/)) {
525 toast.error("Body font URL is invalid", {
526 description: "Please check the font URL or remove it.",
527 });
528 setIsSubmitting(false);
529 return;
530 }
531
532 // Get original theme name (from built-in themes)
533 const currentThemeId = usePresentationState.getState().theme;
534 const builtInTheme = themes[currentThemeId as keyof typeof themes];
535 const originalThemeName =
536 builtInTheme?.name ||
537 editingTheme?.themeData?.name ||
538 String(currentThemeId) ||
539 "Custom Theme";
540
541 // Use the user's input if provided, otherwise use "Copy of {original}"
542 const themeName = name || `Copy of ${originalThemeName}`;
543
544 // Create the new theme
545 const createResult = await createCustomTheme({
546 name: themeName,
547 description: description || "",
548 isPublic: false, // Always private for customization copies
549 themeData: themeStyleData,
550 });
551
552 if (createResult.success && createResult.themeId) {
553 const createdThemeData = {
554 ...themeStyleData,
555 name: themeName,
556 description: description || "",
557 };
558
559 // Apply the new theme to the presentation
560 usePresentationState
561 .getState()
562 .setTheme(createResult.themeId, createdThemeData);
563
564 // Update the presentation to use the new theme
565 const result = await updatePresentation({
566 id: currentPresentationId,
567 theme: createResult.themeId,
568 });
569
570 if (result.success) {
571 toast.success("New theme created and applied!");
572
573 // Invalidate queries to refresh the theme list
574 queryClient.invalidateQueries({
575 queryKey: ["presentation", "themes", "user"],
576 });
577 queryClient.invalidateQueries({
578 queryKey: ["presentation", "themes", "public"],
579 });
580 queryClient.invalidateQueries({
581 queryKey: ["presentation", "themes", "favorites"],
582 });
583
584 handleClose();
585 } else {
586 toast.error(result.message || "Failed to apply new theme");
587 }
588 } else {
589 toast.error(createResult.message || "Failed to create new theme");
590 }
591 } catch {
592 toast.error("An unexpected error occurred");
593 } finally {
594 setIsSubmitting(false);
595 }
596 }, [
597 currentPresentationId,
598 editingTheme?.name,
599 form,
600 handleClose,
601 queryClient,
602 ]);
603
604 // Reset form when modal opens/closes or editingTheme/isCustomizing/importedThemeData changes
605 useEffect(() => {
606 if (openCreateThemeModal) {
607 reset(defaultValues);
608 setPreviewTab(previewMode === "test-only" ? "test" : "current");
609 setCurrentStep("colors");
610 setSelectedColorTheme(
611 editingTheme || importedThemeData
612 ? "custom-theme"
613 : (colorThemes[0]?.id ?? "custom-theme"),
614 );
615 setShowAdvancedColors(false);
616 }
617 }, [
618 openCreateThemeModal,
619 editingTheme,
620 isCustomizing,
621 importedThemeData,
622 previewMode,
623 reset,
624 defaultValues,
625 setCurrentStep,
626 setSelectedColorTheme,
627 setShowAdvancedColors,
628 ]);
629
630 const currentSlides = usePresentationState((s) => s.slides);
631 const { previewThemeData, slidesToDisplay } = usePreviewData({
632 control,
633 previewTab,
634 currentSlides,
635 });
636
637 // Handlers for StepContent
638 const handleColorChange = (key: ThemeColorsKeys, value: string) => {
639 linkedColorChange(key, value);
640 };
641
642 const handleThemeSelect = (themeId: string) => {
643 applyThemePreset(themeId);
644 };
645
646 return (
647 <Credenza
648 open={openCreateThemeModal}
649 onOpenChange={setOpenCreateThemeModal}
650 >
651 <CredenzaContent
652 shouldHaveClose={false}
653 overlayClassName="z-40"
654 className="z-50 h-dvh max-h-none w-dvw max-w-none border-none p-0"
655 >
656 <VisuallyHidden>
657 <CredenzaTitle>Create Theme</CredenzaTitle>
658 </VisuallyHidden>
659 <div className="flex h-full flex-col lg:flex-row">
660 {/* Left Panel - Editor */}
661 <div className="flex w-full flex-col border-b border-border lg:w-1/2 lg:border-r lg:border-b-0">
662 <CreateThemeHeader
663 currentStep={currentStep}
664 onBack={handleBack}
665 onClose={handleClose}
666 />
667 <div className="h-[calc(100vh-2*70px)] overflow-y-auto">
668 <StepContent
669 step={currentStep}
670 control={control}
671 selectedColorTheme={selectedColorTheme}
672 onColorChange={handleColorChange}
673 onSelectColorTheme={handleThemeSelect}
674 setValue={setValue}
675 isCustomizing={isCustomizing}
676 defaultColors={baseThemeData?.colors}
677 />
678 </div>
679 <CreateThemeFooter
680 currentStep={currentStep}
681 isSubmitting={isSubmitting}
682 onStepClick={setCurrentStep}
683 onContinue={handleContinue}
684 onSave={handleSaveCurrentStep}
685 onSaveAndCreateNew={handleSaveAndCreateNew}
686 onResetCustomization={
687 isCustomizing ? handleResetCustomization : undefined
688 }
689 isEditing={!!editingTheme}
690 isCustomizing={isCustomizing}
691 />
692 </div>
693
694 {/* Right Panel - Preview */}
695 <PreviewSection
696 containerRef={containerRef}
697 previewTab={previewTab}
698 previewTabs={previewTabs}
699 previewThemeData={previewThemeData}
700 slidesToDisplay={slidesToDisplay}
701 onTabChange={setPreviewTab}
702 />
703 </div>
704 </CredenzaContent>
705 </Credenza>
706 );
707 }
708
708 lines Plain Text