Merge pull request #503 from presenton/refactor/custom_template

refactor/custom template
This commit is contained in:
Shiva Raj Badu 2026-04-09 11:28:15 +05:45 committed by GitHub
commit 9903df99d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 4533 additions and 2157 deletions

View file

@ -22,7 +22,7 @@ import {
export const CustomTemplateCard = React.memo(function CustomTemplateCard({ template }: { template: CustomTemplates }) {
const router = useRouter();
const { previewLayouts, loading, totalLayouts } = useCustomTemplatePreview(`${template.id}`);
const { previewLayouts, loading } = useCustomTemplatePreview(`${template.id}`);
const handleOpen = useCallback(() => {
trackEvent(MixpanelEvent.Templates_Custom_Opened, { template_id: template.id, template_name: template.name });
if (template.id.startsWith('custom-')) {
@ -38,7 +38,7 @@ export const CustomTemplateCard = React.memo(function CustomTemplateCard({ templ
onClick={handleOpen}
>
<TemplatePreviewStage>
<LayoutsBadge count={totalLayouts} />
<LayoutsBadge count={template.layoutCount} />
<CustomTemplatePreview
previewLayouts={previewLayouts}
loading={loading}

View file

@ -0,0 +1,273 @@
"use client";
import React, { useEffect, useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import { useFileUpload } from "./hooks/useFileUpload";
import { useTemplateCreation } from "./hooks/useTemplateCreation";
import { useLayoutSaving } from "./hooks/useLayoutSaving";
import { ProcessedSlide } from "./types";
import { TAILWIND_CDN_URL } from "./constants";
import { TemplateStudioHeader } from "./components/TemplateStudioHeader";
import { TemplateCreationProgress } from "./components/TemplateCreationProgress";
import { Step2FontManagement } from "./components/steps/Step2FontManagement";
import { Step3SlidePreview } from "./components/steps/Step3SlidePreview";
import { Step4TemplateCreation } from "./components/steps/Step4TemplateCreation";
import { SaveLayoutButton } from "./components/SaveLayoutButton";
import { SaveLayoutModal } from "./components/SaveLayoutModal";
import { FileUploadSection } from "./components/FileUploadSection";
import { useFontLoader } from "../hooks/useFontLoad";
import Header from "@/app/(presentation-generator)/(dashboard)/dashboard/components/Header";
const CustomTemplatePage = () => {
const router = useRouter();
const [schemaEditorSlideIndex, setSchemaEditorSlideIndex] = useState<number | null>(null);
const [schemaPreviewData, setSchemaPreviewData] = useState<Record<number, Record<string, any>>>({});
const { selectedFile, handleFileSelect, removeFile } = useFileUpload();
const {
state,
uploadedFonts,
slides,
setSlides,
completedSlides,
checkFonts,
uploadFont,
removeFont,
fontUploadAndPreview,
initTemplateCreation,
retrySlide,
} = useTemplateCreation();
// Layout saving hook
const {
isSavingLayout,
isModalOpen,
openSaveModal,
closeSaveModal,
saveLayout,
} = useLayoutSaving(slides);
useEffect(() => {
const existingScript = document.querySelector('script[src*="tailwindcss.com"]');
if (!existingScript) {
const script = document.createElement("script");
script.src = TAILWIND_CDN_URL;
script.async = true;
document.head.appendChild(script);
}
}, []);
/**
* Step 1: Check fonts in uploaded PPTX
*/
const handleCheckFonts = useCallback(async () => {
if (selectedFile) {
await checkFonts(selectedFile);
}
}, [selectedFile, checkFonts]);
/**
* Step 2: Upload fonts and generate preview
*/
const handleFontUploadAndPreview = useCallback(async () => {
if (selectedFile) {
const data = await fontUploadAndPreview(selectedFile);
if (data) {
useFontLoader(data.fonts);
}
}
}, [selectedFile, fontUploadAndPreview]);
/**
* Step 5: Save template with metadata
*/
const handleSaveTemplate = useCallback(async (
layoutName: string,
description: string,
template_info_id: string
): Promise<string | null> => {
const id = await saveLayout(layoutName, description, template_info_id);
if (id) {
router.push(`/template-preview?slug=custom-${id}`);
}
return id;
}, [saveLayout, router]);
/**
* Update a specific slide's data
*/
const handleSlideUpdate = useCallback((index: number, updatedSlideData: Partial<ProcessedSlide>) => {
setSlides((prevSlides) =>
prevSlides.map((s, i) =>
i === index
? { ...s, ...updatedSlideData, modified: true }
: s
)
);
}, [setSlides]);
/**
* Open schema editor for a specific slide
*/
const handleOpenSchemaEditor = useCallback((index: number | null) => {
setSchemaEditorSlideIndex(index);
}, []);
/**
* Close schema editor
*/
const handleCloseSchemaEditor = useCallback(() => {
setSchemaEditorSlideIndex(null);
}, []);
/**
* Save changes from schema editor
*/
const handleSchemaEditorSave = useCallback((updatedReact: string) => {
if (schemaEditorSlideIndex !== null) {
setSlides(prev => prev.map((s, i) =>
i === schemaEditorSlideIndex ? { ...s, react: updatedReact } : s
));
}
setSchemaEditorSlideIndex(null);
}, [schemaEditorSlideIndex, setSlides]);
/**
* Update schema preview content (for AI fill)
*/
const handleSchemaPreviewContent = useCallback((content: Record<string, any>) => {
if (schemaEditorSlideIndex !== null) {
setSchemaPreviewData(prev => ({
...prev,
[schemaEditorSlideIndex]: content
}));
}
}, [schemaEditorSlideIndex]);
/**
* Clear schema preview data for a specific slide
*/
const handleClearSchemaPreview = useCallback((slideIndex: number) => {
setSchemaPreviewData(prev => {
const newData = { ...prev };
delete newData[slideIndex];
return newData;
});
}, []);
const showFileUpload = state.step === 'file-upload';
const showFontManager = state.step === 'font-check' || state.step === 'font-upload';
const showPreview = state.step === 'slides-preview';
const showSlides = state.step === 'template-creation' || state.step === 'completed';
const isProcessingCompleted = state.step === 'completed';
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<Header />
<TemplateStudioHeader />
{showFileUpload ? (
<div className="pb-24">
<FileUploadSection
selectedFile={selectedFile}
handleFileSelect={handleFileSelect}
removeFile={removeFile}
CheckFonts={handleCheckFonts}
isProcessingPptx={state.isLoading}
slides={[]}
completedSlides={0}
/>
</div>
) : (
<div className="mx-auto min-h-[600px] px-6 pb-24">
<TemplateCreationProgress
currentStep={state.step}
totalSlides={state.totalSlides}
processedSlides={completedSlides}
/>
{/* Step 2: Font Management */}
{showFontManager && (
<Step2FontManagement
fontsData={state.fontsData}
uploadedFonts={uploadedFonts}
uploadFont={uploadFont}
removeFont={removeFont}
onContinue={handleFontUploadAndPreview}
isUploading={state.isLoading}
/>
)}
{/* Step 3: Slide Preview */}
{showPreview && (
<Step3SlidePreview
previewData={state.previewData}
onInitTemplate={initTemplateCreation}
isLoading={state.isLoading}
/>
)}
{/* Step 4: Template Creation & Editing */}
{showSlides && slides.length > 0 && (
<Step4TemplateCreation
slides={slides}
setSlides={setSlides}
retrySlide={retrySlide}
onSlideUpdate={handleSlideUpdate}
schemaEditorSlideIndex={schemaEditorSlideIndex}
onOpenSchemaEditor={handleOpenSchemaEditor}
onCloseSchemaEditor={handleCloseSchemaEditor}
onSchemaEditorSave={handleSchemaEditorSave}
schemaPreviewData={schemaPreviewData}
onSchemaPreviewContent={handleSchemaPreviewContent}
onClearSchemaPreview={handleClearSchemaPreview}
/>
)}
{/* Floating Save Template Button */}
{isProcessingCompleted && slides.some((s) => s.processed) && (
<SaveLayoutButton
onSave={openSaveModal}
isSaving={isSavingLayout}
isProcessing={slides.some((s) => s.processing)}
/>
)}
{/* Save Template Modal */}
<SaveLayoutModal
isOpen={isModalOpen}
onClose={closeSaveModal}
onSave={handleSaveTemplate}
isSaving={isSavingLayout}
template_info_id={state.templateId || ''}
/>
</div>
)}
</div>
);
};
export default CustomTemplatePage;

View file

@ -1,21 +0,0 @@
import React from "react";
import Header from "@/app/(presentation-generator)/(dashboard)/dashboard/components/Header";
export const APIKeyWarning: React.FC = () => {
return (
<div className="min-h-screen font-roboto bg-gradient-to-br from-slate-50 to-slate-100">
<Header />
<div className="flex items-center justify-center aspect-video mx-auto px-6">
<div className="text-center space-y-2 my-6 bg-white p-10 rounded-lg shadow-lg">
<h1 className="text-xl font-bold text-gray-900">
Please add "GOOGLE_API_KEY" to enable template creation via AI.
</h1>
<h1 className="text-xl font-bold text-gray-900">Please add your OpenAI API Key to process the layout</h1>
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
This feature requires an OpenAI model GPT-5. Configure your key in settings or via environment variables.
</p>
</div>
</div>
</div>
);
};

View file

@ -1,167 +0,0 @@
'use client'
import React from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Pencil, Eraser, RotateCcw, SendHorizontal, X } from "lucide-react";
import { EditControlsProps } from "../../types";
export const EditControls: React.FC<EditControlsProps> = ({
isEditMode,
prompt,
isUpdating,
strokeWidth,
strokeColor,
eraserMode,
onPromptChange,
onSave,
onCancel,
onStrokeWidthChange,
onStrokeColorChange,
onEraserModeChange,
onClearCanvas,
}) => {
const colors = [
"#000000",
"#FF0000",
"#00FF00",
"#0000FF",
"#FFFF00",
"#FF00FF",
"#00FFFF",
"#FFA500",
];
const strokeWidths = [1, 3, 5, 8, 12];
if (!isEditMode) return null;
return (
<div className="border-2 max-w-[1280px] mx-auto border-blue-200 rounded-lg p-4 bg-blue-50 space-y-4">
{/* Drawing Tools */}
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4 flex-wrap">
{/* Drawing Tools */}
<div className="flex items-center gap-2">
<Button
variant={!eraserMode ? "default" : "outline"}
size="sm"
onClick={() => onEraserModeChange(false)}
className="flex items-center gap-1"
>
<Pencil size={14} />
Draw
</Button>
<Button
variant={eraserMode ? "default" : "outline"}
size="sm"
onClick={() => onEraserModeChange(true)}
className="flex items-center gap-1"
>
<Eraser size={14} />
Erase
</Button>
</div>
{/* Color Picker */}
{!eraserMode && (
<div className="flex items-center gap-1">
{colors.map((color) => (
<button
key={color}
className={`w-5 h-5 rounded-full border-2 ${
strokeColor === color
? "border-gray-800"
: "border-gray-300"
}`}
style={{ backgroundColor: color }}
onClick={() => onStrokeColorChange(color)}
/>
))}
</div>
)}
{/* Stroke Width */}
<div className="flex items-center gap-1">
{strokeWidths.map((width) => (
<button
key={width}
className={`w-7 h-7 rounded border flex items-center justify-center ${
strokeWidth === width
? "bg-blue-100 border-blue-500"
: "border-gray-300"
}`}
onClick={() => onStrokeWidthChange(width)}
>
<div
className="rounded-full bg-gray-800"
style={{
width: `${width + 1}px`,
height: `${width + 1}px`,
}}
/>
</button>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={onClearCanvas}
className="flex items-center gap-1"
>
<RotateCcw size={14} />
Clear
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={onCancel}
className="flex items-center gap-1"
>
<X size={14} />
Cancel
</Button>
</div>
{/* Prompt Section */}
<div className="space-y-2 mt-2">
<label
htmlFor="edit-prompt"
className="text-sm font-medium font-inter text-gray-700"
>
Describe the changes you want to make:
</label>
<div className="flex gap-2">
<Textarea
id="edit-prompt"
placeholder="Enter your prompt here... (e.g., 'Change the title color to blue', 'Add a border to the image', etc.)"
value={prompt}
onChange={(e) => onPromptChange(e.target.value)}
className="flex-1 font-inter duration-300 h-[70px] border-blue-200 border-2 rounded-lg outline-none focus:border-blue-500 focus:ring-0 max-h-[70px] resize-none"
disabled={isUpdating}
/>
<div>
<Button
onClick={onSave}
disabled={isUpdating || !prompt.trim()}
className="flex flex-col w-28 font-inter font-semibold items-center gap-1 h-full bg-green-600 hover:bg-green-700 px-4"
>
{isUpdating ? (
"Updating..."
) : (
<>
<SendHorizontal size={14} />
Update
</>
)}
</Button>
</div>
</div>
</div>
</div>
);
};

View file

@ -1,97 +0,0 @@
'use client'
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Save, X, Code } from "lucide-react";
import { ProcessedSlide } from "../../types";
import Editor from 'react-simple-code-editor';
import { highlight, languages } from 'prismjs';
import 'prismjs/components/prism-clike';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-markup';
import 'prismjs/components/prism-jsx';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
interface HtmlEditorProps {
slide: ProcessedSlide;
isHtmlEditMode: boolean;
onSave: (html: string) => void;
onCancel: () => void;
}
export const HtmlEditor: React.FC<HtmlEditorProps> = ({
slide,
isHtmlEditMode,
onSave,
onCancel,
}) => {
const [htmlContent, setHtmlContent] = useState(slide.html || "");
useEffect(() => {
setHtmlContent(slide.html || "");
}, [slide.html]);
if (!isHtmlEditMode) return null;
const handleSave = () => {
onSave(htmlContent);
};
const handleCancel = () => {
setHtmlContent(slide.html || "");
onCancel();
};
return (
<Sheet open={isHtmlEditMode} onOpenChange={(open) => { if (!open) handleCancel(); }}>
<SheetContent side="right" className="w-full sm:max-w-[860px] p-0">
<SheetHeader className="px-6 py-4 border-b">
<SheetTitle className="flex items-center justify-between w-full">
<span className="flex items-center gap-2 text-purple-800">
<Code className="w-5 h-5 text-purple-600" />
HTML Editor
</span>
</SheetTitle>
</SheetHeader>
<div className="space-y-4 px-2 overflow-y-auto h-[85%]">
<div className="container__content_area">
<Editor
value={htmlContent}
onValueChange={html => setHtmlContent(html)}
highlight={code => highlight(code, languages.jsx!, 'jsx')}
padding={10}
id="html-editor"
name="html-editor"
className="container__editor"
/>
</div>
</div>
<SheetFooter className="px-6 py-4 border-b">
<SheetTitle className="flex items-center justify-between w-full">
<div></div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCancel}
className="flex items-center gap-1"
>
<X size={14} />
Cancel
</Button>
<Button
onClick={handleSave}
className="flex items-center gap-1 bg-purple-600 hover:bg-purple-700"
size="sm"
>
<Save size={14} />
Save HTML
</Button>
</div>
</SheetTitle>
</SheetFooter>
</SheetContent>
</Sheet>
);
};

View file

@ -1,16 +1,32 @@
'use client'
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useDrawingCanvas } from "../../hooks/useDrawingCanvas";
import React, { useRef, useState, useMemo, useEffect } from "react";
import { useCompiledLayout } from "../../hooks/useCompiledLayout";
import { useSlideUndoRedo } from "../../hooks/useSlideUndoRedo";
import { EachSlideProps } from "../../types";
import { SlideContentDisplay } from "./SlideContentDisplay";
import { useHtmlEdit } from "../../hooks/useHtmlEdit";
import { SlideActions } from "./SlideActions";
import { HtmlEditor } from "./HtmlEditor";
import { EditControls } from "./EditControls";
import { useSlideEdit } from "../../hooks/useSlideEdit";
import {
Trash2,
X,
Check,
Loader2,
RotateCcw,
Sparkles,
Edit,
Code,
MousePointer2,
Undo,
Redo
} from "lucide-react";
import Timer from "../Timer";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import ToolTip from "@/components/ToolTip";
// import { CodeEditor } from "./CodeEditor";
// import SlideSelectionEditor from "./SlideSelectionEditor";
import SchemaElementHighlighter from "../SchemaElementHighlighter";
const EachSlide: React.FC<EachSlideProps> = ({
slide,
@ -19,145 +35,451 @@ const EachSlide: React.FC<EachSlideProps> = ({
setSlides,
onSlideUpdate,
isProcessing,
onOpenSchemaEditor,
isSchemaEditorOpen = false,
schemaPreviewData,
onClearSchemaPreview,
}) => {
// Custom hooks
const [localPreviewData, setLocalPreviewData] = useState<Record<string, any> | null>(null);
// Use schema preview data from parent if available, otherwise use local
const previewData = schemaPreviewData ?? localPreviewData;
const setPreviewData = setLocalPreviewData;
const [isEditPromptOpen, setIsEditPromptOpen] = useState(false);
const slideDisplayRef = useRef<HTMLDivElement>(null);
const [showCodeEditor, setShowCodeEditor] = useState(false);
const [isSelectionEditMode, setIsSelectionEditMode] = useState(false);
// Compile layout once and share with child components
const compiledLayout = useCompiledLayout(slide.react);
// Auto-retry once if compilation fails
const hasAutoRetriedCompile = useRef(false);
useEffect(() => {
// Reset the flag when compilation succeeds
if (compiledLayout) {
hasAutoRetriedCompile.current = false;
}
}, [compiledLayout]);
useEffect(() => {
if (
slide.react &&
slide.processed &&
!slide.processing &&
!compiledLayout &&
!hasAutoRetriedCompile.current
) {
hasAutoRetriedCompile.current = true;
console.log(`Auto-retrying slide ${index + 1} after compile failure...`);
retrySlide(index);
}
}, [slide.react, slide.processed, slide.processing, compiledLayout, index, retrySlide]);
// Get sample data for schema-element highlighting
const sampleData = useMemo(() => {
if (previewData) return previewData;
if (compiledLayout?.sampleData && Object.keys(compiledLayout.sampleData).length > 0) {
return compiledLayout.sampleData;
}
try {
return compiledLayout?.schema?.parse({}) ?? null;
} catch {
return null;
}
}, [compiledLayout, previewData]);
// Undo/Redo functionality for this slide
const {
canvasRef,
slideDisplayRef,
strokeWidth,
strokeColor,
eraserMode,
isDrawing,
canvasDimensions,
setCanvasDimensions,
didYourDraw,
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
handleClearCanvas,
handleEraserModeChange,
handleStrokeColorChange,
handleStrokeWidthChange,
} = useDrawingCanvas();
undo,
redo,
canUndo,
canRedo,
} = useSlideUndoRedo(slide, setSlides, index);
const {
isEditMode,
isUpdating,
prompt,
slideContentRef,
setPrompt,
handleSave,
handleEditClick,
handleCancelEdit,
} = useSlideEdit(slide, index, onSlideUpdate, setSlides);
const {
isHtmlEditMode,
handleHtmlEditClick,
handleHtmlEditCancel,
handleHtmlSave,
} = useHtmlEdit(slide, index, onSlideUpdate, setSlides);
// Set canvas dimensions when entering edit mode
React.useEffect(() => {
if (isEditMode && slideContentRef.current && slide.html) {
const rect = slideContentRef.current.getBoundingClientRect();
setCanvasDimensions({
width: Math.max(rect.width, 800),
height: Math.max(rect.height, 600),
});
}
}, [isEditMode, slide.html, slideContentRef, setCanvasDimensions]);
// Handle save with drawing data
const handleSaveWithDrawing = () => {
handleSave(slideDisplayRef!, didYourDraw);
};
// Handle delete slide
const handleDeleteSlide = () => {
setSlides((prevSlides) => prevSlides.filter((_, i) => i !== index));
};
// Handle retry slide
const handleRetrySlide = () => {
retrySlide(index);
};
const closeEditPrompt = () => {
setIsEditPromptOpen(false);
handleCancelEdit();
};
const submitEditPrompt = async () => {
if (isUpdating) return;
await handleSave();
setIsEditPromptOpen(false);
setPrompt("");
};
// Clear preview data - clears both local and parent state
const handleClearPreview = () => {
setPreviewData(null);
onClearSchemaPreview?.();
};
// Handle delete slide
const handleDeleteSlide = () => {
// warmin
const confirmed = window.confirm(
`Are you sure you want to delete slide ${index + 1}? This action cannot be undone.`
);
if (!confirmed) return;
setSlides(prev => prev.filter((_, i) => i !== index));
};
// Handle selection edit update
const handleSelectionUpdate = (updatedHtml: string) => {
// Update the slide's html content via parent callback or directly
setSlides(prev => prev.map((s, i) => i === index ? { ...s, react: updatedHtml } : s));
};
const isSlideReady = slide.processed && !slide.processing;
const isSlideProcessing = slide.processing;
const hasError = !!slide.error;
return (
<Card
key={slide.slide_number}
className="border-2 font-instrument_sans w-full relative"
>
<CardHeader className="max-w-[1280px] mx-auto px-0 py-6">
<CardTitle className="text-xl">
<SlideActions
<div className="group max-w-[1440px] mx-auto relative bg-white rounded-2xl border border-[#E5E7EB] overflow-hidden transition-all duration-300 hover:shadow-lg hover:border-[#D1D5DB]">
{/* Slide Header */}
<div className="px-5 py-4 border-b border-[#F3F4F6] bg-gradient-to-r from-[#FAFAFA] to-white">
<div className="flex items-center justify-between">
{/* Left: Slide Info */}
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-[#EBE9FE] text-[#7A5AF8] font-semibold text-sm">
{index + 1}
</div>
<div>
<h3 className="text-base font-semibold text-[#111827] tracking-tight">
{compiledLayout?.layoutId || `Slide ${index + 1}`}
</h3>
{compiledLayout?.layoutDescription && (
<p className="text-sm text-[#6B7280] mt-0.5 line-clamp-1 max-w-[300px]">
{compiledLayout.layoutDescription}
</p>
)}
</div>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-1.5">
{/* Primary Actions Group */}
<div className="flex items-center bg-gray-50/80 rounded-lg p-1 gap-0.5">
{/* AI Edit Button */}
<Popover
open={isEditPromptOpen}
onOpenChange={(open) => {
setIsEditPromptOpen(open);
if (open) handleEditClick();
else handleCancelEdit();
}}
>
<PopoverTrigger asChild>
<button
disabled={!isSlideReady}
className={`
inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium
rounded-md transition-all duration-150
${!isSlideReady
? "opacity-40 cursor-not-allowed text-gray-400"
: "text-gray-600 hover:bg-white hover:text-violet-600 hover:shadow-sm"
}
`}
>
<Sparkles className="w-3.5 h-3.5" />
<span>AI Edit</span>
</button>
</PopoverTrigger>
<PopoverContent
align="end"
side="bottom"
sideOffset={8}
className="w-[380px] p-0 rounded-xl border border-gray-200 shadow-2xl bg-white"
>
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center shadow-sm">
<Sparkles className="w-3.5 h-3.5 text-white" />
</div>
<div>
<span className="text-sm font-semibold text-gray-800">AI Edit</span>
<p className="text-[10px] text-gray-400">Apply AI edits & tweaks</p>
</div>
</div>
<button
type="button"
onClick={closeEditPrompt}
disabled={isUpdating}
className="p-1 rounded-md hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors disabled:opacity-50"
>
<X className="w-4 h-4" />
</button>
</div>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={3}
autoFocus
placeholder="What changes would you like? e.g., 'Make the title larger' or 'Change colors to blue theme'"
disabled={isUpdating}
className="w-full px-3 py-2.5 rounded-lg border border-gray-200 bg-gray-50 text-sm text-gray-800 placeholder:text-gray-400 resize-none focus:outline-none focus:ring-2 focus:ring-violet-500/20 focus:border-violet-400 focus:bg-white transition-all"
/>
<div className="flex justify-end mt-3">
<button
type="button"
onClick={submitEditPrompt}
disabled={isUpdating || !prompt.trim()}
className={`
inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg transition-all
${isUpdating || !prompt.trim()
? "bg-gray-100 text-gray-400 cursor-not-allowed"
: "bg-gradient-to-r from-violet-500 to-purple-600 text-white hover:from-violet-600 hover:to-purple-700 shadow-sm hover:shadow-md"
}
`}
>
{isUpdating ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Applying...
</>
) : (
<>
<Check className="w-3.5 h-3.5" />
Apply
</>
)}
</button>
</div>
</div>
</PopoverContent>
</Popover>
{/* Schema Button */}
<ToolTip content="Edit content schema">
<button
onClick={() => {
if (isSchemaEditorOpen) {
onOpenSchemaEditor?.(null);
} else {
onOpenSchemaEditor?.(index);
}
}}
disabled={!isSlideReady}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed ${isSchemaEditorOpen
? "bg-emerald-100 text-emerald-700"
: "text-gray-600 hover:bg-white hover:text-emerald-600 hover:shadow-sm"
}`}
>
<Edit className="w-3.5 h-3.5" />
<span>Schema</span>
</button>
</ToolTip>
{/* Code Button */}
{/* <ToolTip content="Edit source code">
<button
onClick={() => setShowCodeEditor(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md text-gray-600 hover:bg-white hover:text-blue-600 hover:shadow-sm transition-all duration-150"
>
<Code className="w-3.5 h-3.5" />
<span>Code</span>
</button>
</ToolTip> */}
{/* Select Edit Button */}
{/* <ToolTip content={isSelectionEditMode ? "Exit selection mode" : "Click elements to edit"}>
<button
onClick={() => setIsSelectionEditMode(!isSelectionEditMode)}
disabled={!isSlideReady}
className={`
inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium
rounded-md transition-all duration-150
${isSelectionEditMode
? "bg-indigo-100 text-indigo-700"
: "text-gray-600 hover:bg-white hover:text-indigo-600 hover:shadow-sm"
}
disabled:opacity-40 disabled:cursor-not-allowed
`}
>
<MousePointer2 className="w-3.5 h-3.5" />
<span>{isSelectionEditMode ? "Exit" : "Select"}</span>
</button>
</ToolTip> */}
</div>
{/* Separator */}
<div className="w-px h-6 bg-gray-200 mx-1" />
{/* Undo/Redo Group */}
<div className="flex items-center bg-gray-50/80 rounded-lg p-1 gap-0.5">
<ToolTip content={canUndo ? "Undo (Ctrl+Z)" : "Nothing to undo"}>
<button
onClick={undo}
disabled={!canUndo || !isSlideReady}
className={`
inline-flex items-center justify-center w-8 h-8
rounded-md transition-all duration-150
${!canUndo || !isSlideReady
? "opacity-40 cursor-not-allowed text-gray-400"
: "text-gray-600 hover:bg-white hover:text-amber-600 hover:shadow-sm"
}
`}
>
<Undo className="w-4 h-4" />
</button>
</ToolTip>
<ToolTip content={canRedo ? "Redo (Ctrl+Shift+Z)" : "Nothing to redo"}>
<button
onClick={redo}
disabled={!canRedo || !isSlideReady}
className={`
inline-flex items-center justify-center w-8 h-8
rounded-md transition-all duration-150
${!canRedo || !isSlideReady
? "opacity-40 cursor-not-allowed text-gray-400"
: "text-gray-600 hover:bg-white hover:text-amber-600 hover:shadow-sm"
}
`}
>
<Redo className="w-4 h-4" />
</button>
</ToolTip>
</div>
{/* Separator */}
<div className="w-px h-6 bg-gray-200 mx-1" />
{/* Re-Construct Button */}
<ToolTip content="Re-Design this slide">
<button
onClick={handleRetrySlide}
disabled={!isSlideReady}
className={`
inline-flex items-center gap-2 px-4 py-2 text-sm font-medium
rounded-full transition-all duration-200
${!isSlideReady
? "opacity-40 cursor-not-allowed bg-gradient-to-r from-[#F3F4F6] to-[#E5E7EB] text-[#9CA3AF]"
: "text-[#111827] shadow-sm hover:shadow-md"
}
`}
style={isSlideReady ? {
background: 'linear-gradient(135deg, #D5CAFC 0%, #E3D2EB 35%, #F4DCD3 70%, #FDE4C2 100%)',
} : undefined}
>
<RotateCcw className="w-3.5 h-3.5" />
Re-Construct
</button>
</ToolTip>
{/* Delete Button */}
<ToolTip content="Delete slide">
<button
onClick={handleDeleteSlide}
disabled={!isSlideReady}
className={`
p-1.5 rounded-lg border transition-all duration-150
${!isSlideReady
? "opacity-40 cursor-not-allowed bg-gray-50 border-gray-200 text-gray-400"
: "bg-white border-gray-200 text-gray-400 hover:bg-red-50 hover:border-red-200 hover:text-red-500"
}
`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</ToolTip>
</div>
</div>
{/* Processing Timer - Only show here, not in SlideContentDisplay */}
{isSlideProcessing && (
<div className="mt-4">
<div className="flex items-center gap-2 mb-2">
<Loader2 className="w-4 h-4 animate-spin text-[#7A5AF8]" />
<span className="text-sm font-medium text-[#7A5AF8]">Generating slide layout...</span>
</div>
<Timer duration={120} />
</div>
)}
</div>
{/* Slide Content */}
<div className="p-4">
{/* Selection Edit Mode Banner */}
{isSelectionEditMode && slide.processed && !slide.processing && (
<div className="mb-4 flex items-center justify-between bg-indigo-50 border border-indigo-200 rounded-xl px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-indigo-500 flex items-center justify-center">
<MousePointer2 className="w-3.5 h-3.5 text-white" />
</div>
<span className="text-sm font-medium text-indigo-700">
Selection Edit Mode Click on any element to edit with AI
</span>
</div>
<button
onClick={() => setIsSelectionEditMode(false)}
className="h-8 px-3 text-sm font-medium text-indigo-600 hover:text-indigo-800 hover:bg-indigo-100 rounded-md transition-colors"
>
Exit
</button>
</div>
)}
<div className="relative">
<SlideContentDisplay
slide={slide}
index={index}
isProcessing={isProcessing}
isEditMode={isEditMode}
isHtmlEditMode={isHtmlEditMode}
onEditClick={handleEditClick}
onHtmlEditClick={handleHtmlEditClick}
onRetry={handleRetrySlide}
onDelete={handleDeleteSlide}
compiledLayout={compiledLayout}
previewData={previewData}
retrySlide={handleRetrySlide}
onClearPreview={handleClearPreview}
slideDisplayRef={slideDisplayRef}
/>
</CardTitle>
</CardHeader>
{/* Schema-Element Highlighting Overlay - active when schema editor is open */}
{isSchemaEditorOpen && slide.processed && !slide.processing && (
<SchemaElementHighlighter
containerRef={slideDisplayRef}
sampleData={sampleData}
isActive={isSchemaEditorOpen}
/>
)}
{/* Selection Editor Overlay */}
{/* {isSelectionEditMode && slide.processed && !slide.processing && (
<SlideSelectionEditor
containerRef={slideDisplayRef}
slide={slide}
onSlideUpdate={handleSelectionUpdate}
/>
)} */}
</div>
</div>
<CardContent className="space-y-4">
{/* HTML Editor */}
<HtmlEditor
slide={slide}
isHtmlEditMode={isHtmlEditMode}
onSave={handleHtmlSave}
onCancel={handleHtmlEditCancel}
/>
{/* Edit Controls */}
<EditControls
isEditMode={isEditMode}
prompt={prompt}
isUpdating={isUpdating}
strokeWidth={strokeWidth}
strokeColor={strokeColor}
eraserMode={eraserMode}
onPromptChange={setPrompt}
onSave={handleSaveWithDrawing}
onCancel={handleCancelEdit}
onStrokeWidthChange={handleStrokeWidthChange}
onStrokeColorChange={handleStrokeColorChange}
onEraserModeChange={handleEraserModeChange}
onClearCanvas={handleClearCanvas}
/>
{/* Slide Content Display */}
<SlideContentDisplay
slide={slide}
isEditMode={isEditMode}
isHtmlEditMode={isHtmlEditMode}
slideContentRef={slideContentRef}
slideDisplayRef={slideDisplayRef}
canvasRef={canvasRef}
canvasDimensions={canvasDimensions}
strokeWidth={strokeWidth}
strokeColor={strokeColor}
eraserMode={eraserMode}
isDrawing={isDrawing}
didYourDraw={didYourDraw}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
retrySlide={handleRetrySlide}
onTouchEnd={handleTouchEnd}
/>
</CardContent>
</Card>
{/* Status Indicator */}
{hasError && (
<div className="absolute top-3 right-3">
<div className="w-3 h-3 rounded-full bg-[#EF4444] animate-pulse" />
</div>
)}
</div>
);
};
export default EachSlide;
export default EachSlide;

View file

@ -1,103 +0,0 @@
'use client'
import React from "react";
import { AlertCircle, CheckCircle, Edit, Loader2, Repeat2, Trash, Code } from "lucide-react";
import ToolTip from "@/components/ToolTip";
import { SlideActionsProps } from "../../types";
export const SlideActions: React.FC<SlideActionsProps> = ({
slide,
index,
isProcessing,
isEditMode,
isHtmlEditMode,
onEditClick,
onHtmlEditClick,
onRetry,
onDelete,
}) => {
return (
<div className="flex items-center w-full justify-between gap-2">
<div>
{slide.processing ? (
<Loader2 className="w-6 h-6 text-blue-600 animate-spin" />
) : slide.processed ? (
<CheckCircle className="w-6 h-6 text-green-600" />
) : slide.error ? (
<AlertCircle className="w-6 h-6 text-red-600" />
) : (
<div className="w-6 h-6 border-2 border-gray-300 rounded-full" />
)}
</div>
{slide.processed && (
<div className="flex gap-6">
{slide.processed && slide.html && !isEditMode && !isHtmlEditMode && (
<>
<div>
<ToolTip content="Edit slide with AI">
<button
onClick={onEditClick}
disabled={isProcessing || !slide.processed}
className={`px-6 py-2 flex gap-2 text-sm items-center group-hover:scale-105 rounded-lg bg-[#5141e5] hover:shadow-md transition-all duration-300 cursor-pointer shadow-md ${
isProcessing || !slide.processed
? "opacity-50 cursor-not-allowed"
: ""
}`}
>
<Edit className="w-4 sm:w-5 h-4 sm:h-5 text-white" />
<span className="text-white">Edit Slide</span>
</button>
</ToolTip>
</div>
<div>
<ToolTip content="Edit HTML directly">
<button
onClick={onHtmlEditClick}
disabled={isProcessing || !slide.processed}
className={`px-6 py-2 flex gap-2 text-sm items-center group-hover:scale-105 rounded-lg bg-purple-600 hover:bg-purple-700 hover:shadow-md transition-all duration-300 cursor-pointer shadow-md ${
isProcessing || !slide.processed
? "opacity-50 cursor-not-allowed"
: ""
}`}
>
<Code className="w-4 sm:w-5 h-4 sm:h-5 text-white" />
<span className="text-white">Edit HTML</span>
</button>
</ToolTip>
</div>
</>
)}
<div>
<ToolTip content="Re-Design this slide">
<button
onClick={onRetry}
disabled={isProcessing || !slide.processed}
className={`px-6 py-2 flex gap-2 text-sm items-center group-hover:scale-105 rounded-lg bg-[#5141e5] hover:shadow-md transition-all duration-300 cursor-pointer shadow-md ${
isProcessing || !slide.processed
? "opacity-50 cursor-not-allowed"
: ""
}`}
>
<Repeat2 className="w-4 sm:w-5 h-4 sm:h-5 text-white" />
<span className="text-white">Re-Construct</span>
</button>
</ToolTip>
</div>
<div>
<ToolTip content="Delete Slide">
<button
disabled={isProcessing}
onClick={onDelete}
className={`px-4 py-2 flex gap-2 text-sm items-center group-hover:scale-105 rounded-lg hover:shadow-md transition-all duration-300 cursor-pointer shadow-md ${
isProcessing ? "opacity-50 cursor-not-allowed" : ""
}`}
>
<Trash className="w-4 sm:w-5 h-4 sm:h-5 text-red-500" />
</button>
</ToolTip>
</div>
</div>
)}
</div>
);
};

View file

@ -1,127 +1,122 @@
'use client'
import React from "react";
import SlideContent from "../SlideContent";
import { SlideContentDisplayProps } from "../../types";
import { Repeat2 } from "lucide-react";
import Timer from "../Timer";
import { ProcessedSlide } from "../../types";
import { RotateCcw, X, AlertCircle, ImageOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CompiledLayout } from "@/app/hooks/compileLayout";
export interface SlideContentDisplayProps {
slide: ProcessedSlide;
compiledLayout: CompiledLayout | null;
previewData?: Record<string, any> | null;
retrySlide: (slideNumber: number) => void;
onClearPreview?: () => void;
slideDisplayRef?: React.RefObject<HTMLDivElement>;
}
export const SlideContentDisplay: React.FC<SlideContentDisplayProps> = ({
slide,
isEditMode,
isHtmlEditMode,
slideContentRef,
slideDisplayRef,
canvasRef,
canvasDimensions,
eraserMode,
strokeWidth,
strokeColor,
isDrawing,
didYourDraw,
onMouseDown,
onMouseMove,
onMouseUp,
onTouchStart,
onTouchMove,
onTouchEnd,
compiledLayout,
previewData,
retrySlide,
onClearPreview,
slideDisplayRef,
}) => {
// Don't show slide content when in HTML edit mode
if (isHtmlEditMode) {
return null;
}
if (slide.processing) {
// Successfully processed slide
if (slide.processed && slide.react && !slide.processing) {
return (
<div className="space-y-4">
<p className="text-base text-blue-600 font-medium">🔄 Converting to HTML...</p>
<div className="space-y-3">
<Timer duration={160} />
</div>
<div className="animate-pulse space-y-3">
<div className="h-6 bg-gray-200 rounded w-2/3"></div>
<div className="h-6 bg-gray-200 rounded w-1/2"></div>
<div className="h-64 bg-gray-200 rounded"></div>
</div>
</div>
);
}
if (slide.processed && slide.html) {
return (
<div className="relative">
{slide.convertingToReact && (
<div className="mb-4">
<p className="text-sm text-purple-700 font-medium mb-1"> Converting HTML to React...</p>
<Timer duration={90} />
<div className="relative flex-1">
{/* Preview Mode Banner */}
{previewData && (
<div className="mb-4 flex items-center justify-between bg-[#EDE9FE] border border-[#C4B5FD] rounded-xl px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-[#7A5AF8] flex items-center justify-center">
<span className="text-white text-xs"></span>
</div>
<span className="text-sm font-medium text-[#5B21B6]">
Showing AI-generated preview
</span>
</div>
{onClearPreview && (
<Button
variant="ghost"
size="sm"
onClick={onClearPreview}
className="h-8 text-[#7A5AF8] hover:text-[#5B21B6] hover:bg-[#DDD6FE]"
>
<X className="w-4 h-4 mr-1.5" />
Clear
</Button>
)}
</div>
)}
<div ref={slideDisplayRef} className="relative mx-auto w-full">
<div ref={slideContentRef}>
<SlideContent slide={slide} />
</div>
{isEditMode && (
<canvas
ref={canvasRef}
width={canvasDimensions.width}
height={canvasDimensions.height}
style={{
position: "absolute",
top: 0,
left: 0,
zIndex: 30,
cursor: eraserMode ? "grab" : "crosshair",
pointerEvents: "auto",
touchAction: "none",
}}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onContextMenu={(e) => e.preventDefault()}
{/* Slide Content */}
<div className="relative rounded-xl overflow-hidden border border-[#E5E7EB] bg-white shadow-sm">
<div ref={slideDisplayRef}>
<SlideContent
slide={slide}
compiledLayout={compiledLayout}
data={previewData}
retrySlide={retrySlide}
/>
)}
</div>
</div>
</div>
);
}
// Error state
if (slide.error) {
const isImageTooLarge = slide.error.includes("image exceeds 5 MB maximum");
return (
<div className="space-y-4">
<p className="text-base text-red-600 font-medium"> Conversion failed</p>
<div className="text-sm text-gray-700 p-4 bg-red-50 rounded border border-red-200">
{slide.error.includes("image exceeds 5 MB maximum") ? (
<div>
<p className="font-medium text-red-700 mb-2">Image too large for processing</p>
<p>This slide's image exceeds the 5MB limit. Try using a smaller resolution PPTX file.</p>
</div>
) : (
slide.error
)}
</div>
<div className="flex justify-center">
<button className="bg-red-50 flex gap-2 items-center rounded border border-red-200 px-4 py-2 " onClick={() => retrySlide(slide.slide_number)}>
<Repeat2 className="w-4 h-4" />Retry
</button>
<div className="rounded-xl border border-[#FECACA] bg-[#FEF2F2] p-6">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-full bg-[#FEE2E2] flex items-center justify-center flex-shrink-0">
{isImageTooLarge ? (
<ImageOff className="w-5 h-5 text-[#DC2626]" />
) : (
<AlertCircle className="w-5 h-5 text-[#DC2626]" />
)}
</div>
<div className="flex-1">
<h4 className="text-base font-semibold text-[#991B1B] mb-1">
{isImageTooLarge ? "Image Too Large" : "Conversion Failed"}
</h4>
<p className="text-sm text-[#B91C1C] mb-4">
{isImageTooLarge
? "This slide's image exceeds the 5MB limit. Try using a smaller resolution PPTX file or compressing the images."
: slide.error
}
</p>
<button
onClick={() => retrySlide(slide.slide_number)}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-full bg-white border border-[#FECACA] text-[#DC2626] hover:bg-[#FEE2E2] transition-all"
>
<RotateCcw className="w-4 h-4" />
Retry
</button>
</div>
</div>
</div>
);
}
// Loading/Processing state - Timer is now shown in parent component (NewEachSlide)
// This just shows a skeleton placeholder
return (
<div className="space-y-4">
<p className="text-base text-gray-500"> Waiting in queue to process...</p>
<div className="animate-pulse space-y-3">
<div className="h-6 bg-gray-200 rounded w-2/3"></div>
<div className="h-6 bg-gray-200 rounded w-1/2"></div>
<div className="h-64 bg-gray-200 rounded"></div>
<div className="rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-6 mx-auto max-w-[1280px] w-full aspect-video h-[720px]">
<div className="animate-pulse space-y-4 w-full h-full">
{/* Content skeleton */}
<div className="aspect-video bg-[#E5E7EB] rounded-xl mt-4 w-full h-full" />
</div>
</div>
);
};
};

View file

@ -1,117 +1,244 @@
import React from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Upload, FileText, X, Loader2 } from "lucide-react";
import React, { useState, useRef, useEffect } from "react";
import { UploadIcon, ChevronRight, Plus, FileText, X, Coins, Edit3, Info } from "lucide-react";
import { ProcessedSlide } from "../types";
import Timer from "./Timer";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface FileUploadSectionProps {
selectedFile: File | null;
handleFileSelect: (event: React.ChangeEvent<HTMLInputElement>) => void;
removeFile: () => void;
processFile: () => void;
CheckFonts: () => void;
isProcessingPptx: boolean;
slides: ProcessedSlide[];
completedSlides: number;
}
// Credit costs constants
const COST_PER_SLIDE = 3;
const COST_EDIT = 1;
export const FileUploadSection: React.FC<FileUploadSectionProps> = ({
selectedFile,
handleFileSelect,
removeFile,
processFile,
CheckFonts,
isProcessingPptx,
slides,
completedSlides,
}) => {
const isProcessing = isProcessingPptx || slides.some((s) => s.processing);
const [showCreditsDetails, setShowCreditsDetails] = useState(false);
const creditsButtonRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
// Calculate estimated credits
const totalSlides = slides.length;
const billedSlides = Math.max(totalSlides, 1);
const minRequired = billedSlides * COST_PER_SLIDE;
const handleCheckFonts = () => {
CheckFonts();
}
// Close popover when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (
creditsButtonRef.current && !creditsButtonRef.current.contains(target) &&
popoverRef.current && !popoverRef.current.contains(target)
) {
setShowCreditsDetails(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="w-5 h-5" />
Upload PDF or PPTX File
</CardTitle>
<CardDescription>
Select a PDF or PowerPoint file (.pdf or .pptx) to process. Maximum file size: 100MB
</CardDescription>
{slides.length > 0 && (
<div className="flex items-center justify-end gap-2">
{slides.some((s) => s.processing) && (
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
)}
{completedSlides}/{slides.length} slides completed
<div className="md:h-[calc(100vh-310px)] h-[calc(100vh-450px)] relative overflow-hidden">
<div className=" max-w-[650px] w-full mx-auto px-2 md:px-0 ">
<div
className='absolute z-0 md:-bottom-[36%] -bottom-[40%] left-0 w-full h-full'
style={{
height: "341px",
borderRadius: '1440px',
background: 'radial-gradient(5.92% 104.69% at 50% 100%, rgba(122, 90, 248, 0.00) 0%, rgba(255, 255, 255, 0.00) 100%), radial-gradient(50% 50% at 50% 50%, rgba(122, 90, 248, 0.80) 0%, rgba(122, 90, 248, 0.00) 100%)',
}}
/>
<div className=' w-max ml-9 rounded-tl-[28px] rounded-tr-[28px] flex items-center bg-[#FAFAFF] px-2.5 pt-2.5 pb-1'
style={{
boxShadow: '0 0 16px 0 rgba(80, 71, 230, 0.12)',
}}
>
<div className={`flex justify-center gap-1 py-2.5 pl-2 pr-3 cursor-pointer bg-white rounded-[80px] `}
style={{
boxShadow: '0 0 4px 0 rgba(0, 0, 0, 0.06)',
}}
>
<UploadIcon className={`w-4 h-4 text-black`} />
<p className='text-xs font-medium text-black'>Upload PPTX File</p>
</div>
)}
</CardHeader>
<CardContent className="space-y-4">
{!selectedFile ? (
<div className="border-2 relative border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors">
<Upload className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<Label htmlFor="file-upload" className="cursor-pointer">
<span className="text-lg font-medium text-gray-700">
Click to upload a PDF or PPTX file
</span>
<input
id="file-upload"
type="file"
accept=".pdf,.pptx"
onChange={handleFileSelect}
className="opacity-0 w-full h-full cursor-pointer absolute top-0 left-0 z-10"
/>
</Label>
<p className="text-sm text-gray-500 mt-2">
Drag and drop your file here or click to browse
</p>
</div>
) : (
<div className="flex items-center justify-between p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center gap-3">
<FileText className="w-8 h-8 text-blue-600" />
<div>
<p className="font-medium text-gray-900">
{selectedFile.name}
</p>
<p className="text-sm text-gray-500">
{(selectedFile.size / (1024 * 1024)).toFixed(2)} MB
</p>
</div>
<div className=" w-full bg-[#FAFAFF] rounded-[28px] p-2.5 "
style={{
boxShadow: '0 0 16px 0 rgba(80, 71, 230, 0.12)',
clipPath: 'inset(0px -28px -28px -28px)',
}}
>
<div className="bg-[#FEFEFF] rounded-[18px] p-2 border border-[#EDEEEF] ">
<div className="h-[120px] w-full bg-[#F6F6F9] rounded-[12px] p-1.5">
<div className="border border-[#B8B8C1] border-dashed rounded-[12px ] p-1.5 h-full relative">
{!selectedFile ? <>
<input
id="file-upload"
type="file"
accept=".pptx"
onChange={handleFileSelect}
className="opacity-0 w-full h-full cursor-pointer absolute top-0 left-0 z-10"
/>
<div className='absolute inset-0 flex flex-col items-center justify-center'>
<div className='w-[42px] h-[42px] flex justify-center items-center rounded-full bg-[#EBE9FE]' >
<div className='w-[22px] h-[22px] rounded-full bg-[#7A5AF8] flex items-center justify-center text-white'>
<Plus className='w-3 h-3' />
</div>
</div>
<p className='pt-3 text-xs font-normal text-[#808080] tracking-[-0.12px] text-center'>
<span className='text-[#808080] underline underline-offset-4'>Click to Upload</span> or drag &amp; drop.
</p>
</div>
</> : <div className="flex gap-2 items-center justify-center h-full">
<div className="flex gap-2 items-center">
<div className="w-[55px] h-[55px] ml-auto mr-0 rounded-[9px] bg-[#8E8F8F] flex items-center justify-center relative">
<button className="absolute w-[16px] h-[16px] flex items-center justify-center -top-1.5 -right-1.5"
style={{
borderRadius: '54.545px',
border: '0.682px solid #EDEEEF',
background: '#FFF',
boxShadow: '0 4px 8px 0 rgba(0, 0, 0, 0.25)',
}}
disabled={isProcessing}
onClick={removeFile}
>
<X className="w-3 h-3 text-black " />
</button>
<FileText className="w-5 h-5 text-white" />
</div>
<div className="w-4/5">
<h3 className="text-[#4C4C4C] text-sm font-medium w-full truncate"> {selectedFile.name}</h3>
<p className="text-xs font-normal text-[#808080] tracking-[-0.12px]">Presentation ( {(selectedFile.size / (1024 * 1024)).toFixed(2)} MB)</p>
</div>
</div>
</div>
}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={removeFile}
disabled={
isProcessingPptx || slides.some((s) => s.processing)
}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<X className="w-4 h-4" />
</Button>
</div>
)}
<div className="mt-2">
<div className="flex items-center justify-between gap-2.5">
<div className="min-w-[140px] w-full">
{isProcessing ? (
<div className="flex items-center justify-end gap-3" aria-live="polite" aria-label="Processing">
<div
className="h-[14px] w-[74px] rounded-full bg-[#EFEDFF] overflow-hidden ring-1 ring-[#E4E0FF]"
aria-hidden="true"
>
<div className="h-full w-full rounded-full processing-stripes" />
</div>
<p className="text-sm font-medium text-[#9A9AA6] tracking-[-0.1px]">Processing</p>
{slides.length > 0 ? (
<p className="text-sm font-medium text-[#9A9AA6] tracking-[-0.1px]">
{completedSlides}/{slides.length} Slides
</p>
) : null}
<style jsx>{`
@keyframes stripes {
from {
background-position: 0 0;
}
to {
background-position: 24px 0;
}
}
.processing-stripes {
background: repeating-linear-gradient(
135deg,
rgba(122, 90, 248, 0.9) 0px,
rgba(122, 90, 248, 0.9) 9px,
rgba(122, 90, 248, 0.18) 9px,
rgba(122, 90, 248, 0.18) 18px
);
filter: saturate(1.05);
background-size: 24px 24px;
will-change: background-position;
animation: stripes 0.7s linear infinite;
}
`}</style>
</div>
) : (
<div className="flex items-center justify-end gap-2.5">
<button className="px-4 py-2.5 text-xs font-semibold text-[#101323] font-syne tracking-[-0.12px] flex gap-1"
style={{
borderRadius: '48px',
background: 'linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)',
}}
onClick={handleCheckFonts}
disabled={isProcessing}
>
{isProcessingPptx
? "Checking Fonts..."
: !selectedFile
? "Select a PPTX file"
: "Check Fonts"}
<ChevronRight className="w-3.5 h-3.5 text-black" />
</button>
</div>
)}
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-1 ">
<Button
onClick={processFile}
disabled={isProcessingPptx || slides.some((s) => s.processing)}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white"
>
{isProcessingPptx
? "Extracting Slides..."
: !selectedFile
? "Select a PDF or PPTX file"
: "Process File"}
</Button>
{isProcessingPptx && <Timer duration={90} />}
</div>
</CardContent>
</Card>
<ul className="flex items-center max-w-[85%] md:max-w-[70%] mx-auto mt-5 justify-between gap-2.5">
<li className="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8.5" cy="8.17041" r="4.5" fill="#EBE9FE" />
</svg>
<p className="md:text-sm text-[10px] font-normal text-[#3A3A3A] ">PPTX. Only</p>
</li>
<li className="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8.5" cy="8.17041" r="4.5" fill="#EBE9FE" />
</svg>
<p className="md:text-sm text-[10px] font-normal text-[#3A3A3A] ">Max 100MB</p>
</li>
<li className="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8.5" cy="8.17041" r="4.5" fill="#EBE9FE" />
</svg>
<p className="md:text-sm text-[10px] font-normal text-[#3A3A3A] ">5min Generation</p>
</li>
</ul>
</div>
</div>
);
};

View file

@ -1,77 +1,44 @@
import React, { useState, useRef } from "react";
import React, { useRef } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Upload,
CheckCircle,
AlertCircle,
CheckCircle2,
AlertTriangle,
X,
Loader2,
Type,
ChevronRight,
FileType,
Info,
} from "lucide-react";
interface UploadedFont {
fontName: string;
fontUrl: string;
fontPath: string;
}
interface FontData {
internally_supported_fonts: {
name: string;
google_fonts_url: string;
}[];
not_supported_fonts: string[];
}
interface FontManagerProps {
fontsData: FontData;
UploadedFonts: UploadedFont[];
uploadFont: (fontName: string, file: File) => Promise<string | null>;
removeFont: (fontUrl: string) => void;
getAllUnsupportedFonts: () => string[];
processSlideToHtml: () => void;
}
import { FontManagerProps, FontItem } from "../types";
const FontManager: React.FC<FontManagerProps> = ({
fontsData,
UploadedFonts,
uploadedFonts,
uploadFont,
removeFont,
getAllUnsupportedFonts,
processSlideToHtml,
onContinue,
isUploading = false,
}) => {
const [uploadingFonts, setUploadingFonts] = useState<Set<string>>(new Set());
const fileInputRefs = useRef<{ [key: string]: HTMLInputElement | null }>({});
const allUnsupportedFonts = getAllUnsupportedFonts();
// Filter out fonts that are already uploaded
const fontsNeedingUpload = allUnsupportedFonts.filter(
(fontName) =>
!UploadedFonts.some((uploadedFont) => uploadedFont.fontName === fontName)
// Get fonts that still need to be uploaded (unavailable fonts not yet uploaded)
const fontsNeedingUpload = fontsData.unavailable_fonts.filter(
(font) => !uploadedFonts.some((uploaded) => uploaded.fontName === font.name)
);
const handleFontUpload = async (fontName: string, file: File) => {
const allFontsUploaded = fontsNeedingUpload.length === 0;
const hasAvailableFonts = fontsData.available_fonts.length > 0;
const hasUploadedFonts = uploadedFonts.length > 0;
const handleFontUpload = (fontName: string, file: File) => {
if (!file) return;
setUploadingFonts((prev) => new Set(prev).add(fontName));
const result = uploadFont(fontName, file);
try {
const fontUrl = await uploadFont(fontName, file);
if (fontUrl) {
// Clear the file input
if (fileInputRefs.current[fontName]) {
fileInputRefs.current[fontName]!.value = "";
}
}
} finally {
setUploadingFonts((prev) => {
const newSet = new Set(prev);
newSet.delete(fontName);
return newSet;
});
if (result && fileInputRefs.current[fontName]) {
fileInputRefs.current[fontName]!.value = "";
}
};
@ -85,149 +52,188 @@ const FontManager: React.FC<FontManagerProps> = ({
}
};
if (allUnsupportedFonts.length === 0 && UploadedFonts.length === 0) {
return null;
}
return (
<Card className="my-6">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<Type className="w-6 h-6" />
Font Management
</CardTitle>
<p className="text-sm text-gray-600">
We couldn't load these fonts automatically. Please upload them manually. Make sure naem of the font should be exactly as shown.
</p>
</CardHeader>
<CardContent className="space-y-6">
{/* Supported Fonts */}
{fontsData.internally_supported_fonts.length > 0 && (
<div>
<h4 className="text-sm font-medium text-green-700 mb-3 flex items-center gap-1">
<CheckCircle className="w-4 h-4" />
Supported Fonts ({fontsData.internally_supported_fonts.length})
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{fontsData.internally_supported_fonts.map((font, index) => (
<div
key={index}
className="p-2 bg-green-50 border border-green-200 rounded text-sm text-green-800"
>
{font.name}
</div>
))}
<div className="my-8 max-w-[900px] mx-auto">
<div className="bg-white rounded-2xl border border-[#E5E7EB] shadow-sm overflow-hidden">
{/* Header */}
<div className="px-6 py-5 border-b border-[#F3F4F6] bg-[#FAFAFA]">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[#EBE9FE] flex items-center justify-center">
<Type className="w-6 h-6 text-[#7A5AF8]" />
</div>
<div>
<h2 className="text-xl font-semibold text-[#111827]">Font Management</h2>
<p className="text-sm text-[#6B7280] mt-0.5">
{allFontsUploaded
? "All fonts are ready! You can proceed to preview."
: "Upload missing fonts to ensure your presentation displays correctly."}
</p>
</div>
</div>
)}
</div>
{/* Fonts Needing Upload */}
{fontsNeedingUpload.length > 0 && (
<div>
<h4 className="text-sm font-medium text-orange-700 mb-3 flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
Fonts Needing Upload ({fontsNeedingUpload.length})
</h4>
<div className="space-y-3">
{fontsNeedingUpload.map((fontName: string, index: number) => (
<div
key={index}
className="p-4 bg-orange-50 border border-orange-200 rounded-lg"
>
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-orange-800">
{fontName}
</span>
<p className="text-xs text-orange-600 mt-1">
Required for presentation
</p>
<div className="p-6 space-y-6">
{/* Available Fonts */}
{hasAvailableFonts && (
<div className="p-4 bg-[#F0FDF4] rounded-xl border border-[#BBF7D0]">
<div className="flex items-center gap-2 mb-3">
<CheckCircle2 className="w-5 h-5 text-[#16A34A]" />
<h4 className="text-sm font-semibold text-[#166534]">
Available Fonts ({fontsData.available_fonts.length})
</h4>
</div>
<div className="flex flex-wrap gap-2">
{fontsData.available_fonts.map((font, index) => (
<span
key={index}
className="px-3 py-1.5 bg-white border border-[#D1FAE5] rounded-full text-xs font-medium text-[#166534] shadow-sm"
>
{font.name}
</span>
))}
</div>
</div>
)}
{/* Fonts Needing Upload */}
{fontsNeedingUpload.length > 0 && (
<div className="p-4 bg-[#FFFBEB] rounded-xl border border-[#FDE68A]">
<div className="flex items-center gap-2 mb-4">
<AlertTriangle className="w-5 h-5 text-[#D97706]" />
<h4 className="text-sm font-semibold text-[#92400E]">
Missing Fonts ({fontsNeedingUpload.length})
</h4>
</div>
<div className="space-y-3">
{fontsNeedingUpload.map((font, index) => (
<div
key={index}
className="flex items-center justify-between p-4 bg-white rounded-xl border border-[#FDE68A] shadow-sm"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-[#FEF3C7] flex items-center justify-center">
<FileType className="w-5 h-5 text-[#D97706]" />
</div>
<div>
<span className="text-sm font-semibold text-[#111827] block">
{font.name}
</span>
<span className="text-xs text-[#6B7280]">
.ttf, .otf, .woff, .woff2
</span>
</div>
</div>
<div className="flex items-center gap-2">
<div>
<input
ref={(el) => {
fileInputRefs.current[fontName] = el;
fileInputRefs.current[font.name] = el;
}}
type="file"
accept=".ttf,.otf,.woff,.woff2,.eot"
onChange={(e) => handleFileInputChange(fontName, e)}
onChange={(e) => handleFileInputChange(font.name, e)}
className="hidden"
id={`global-font-upload-${index}`}
id={`font-upload-${index}`}
/>
<Button
size="sm"
variant="outline"
disabled={uploadingFonts.has(fontName)}
onClick={() => fileInputRefs.current[fontName]?.click()}
className="text-xs bg-blue-600 text-white hover:text-white hover:bg-blue-700 border-blue-600"
onClick={() => fileInputRefs.current[font.name]?.click()}
className="rounded-full px-4 h-9 text-sm font-medium transition-all text-[#D97706] border-[#D97706] hover:bg-[#FFFBEB] hover:border-[#D97706]"
>
{uploadingFonts.has(fontName) ? (
<>
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
Uploading...
</>
) : (
<>
<Upload className="w-3 h-3 mr-1" />
Upload Font
</>
)}
<Upload className="w-4 h-4 mr-1" />
Upload
</Button>
</div>
</div>
</div>
))}
))}
</div>
</div>
</div>
)}
)}
{/* Successfully Uploaded Fonts */}
{UploadedFonts.length > 0 && (
<div>
<h4 className="text-sm font-medium text-green-700 mb-3 flex items-center gap-1">
<CheckCircle className="w-4 h-4" />
Uploaded Fonts ({UploadedFonts.length})
</h4>
<div className="space-y-2">
{UploadedFonts.map((font, index) => (
<div
key={index}
className="p-3 bg-green-50 border border-green-200 rounded-lg flex items-center justify-between"
>
<div>
<span className="text-sm font-medium text-green-800">
{font.fontName}
</span>
<p className="text-xs text-green-600 mt-1">
Available for all slides
</p>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => removeFont(font.fontUrl)}
className="text-red-600 hover:text-red-700 hover:bg-red-50 p-1"
{/* Uploaded Fonts */}
{hasUploadedFonts && (
<div className="p-4 bg-[#F0FDF4] rounded-xl border border-[#BBF7D0]">
<div className="flex items-center gap-2 mb-4">
<CheckCircle2 className="w-5 h-5 text-[#16A34A]" />
<h4 className="text-sm font-semibold text-[#166534]">
Uploaded Fonts ({uploadedFonts.length})
</h4>
</div>
<div className="space-y-2">
{uploadedFonts.map((font, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-white rounded-xl border border-[#D1FAE5] shadow-sm"
>
<X className="w-3 h-3" />
</Button>
</div>
))}
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-[#DCFCE7] flex items-center justify-center">
<CheckCircle2 className="w-4 h-4 text-[#16A34A]" />
</div>
<span className="text-sm font-medium text-[#166534]">
{font.fontName}
</span>
</div>
<button
onClick={() => removeFont(font.fontName)}
className="p-2 rounded-full text-[#6B7280] hover:text-[#DC2626] hover:bg-[#FEE2E2] transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
</div>
)}
<div className="flex justify-center mt-4">
)}
</div>
<Button
size="sm"
variant="outline"
onClick={processSlideToHtml}
className="text-xs px-8 py-2 font-semibold bg-blue-600 text-white hover:text-white hover:bg-blue-700 border-blue-600"
>
Extract Template
</Button>
{/* Action Footer */}
<div className={`px-6 py-5 border-t transition-colors duration-300 ${allFontsUploaded
? 'bg-[#F0FDF4] border-[#BBF7D0]'
: 'bg-[#FAFAFA] border-[#F3F4F6]'
}`}>
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
{!allFontsUploaded && (
<div className="flex items-start gap-2 text-sm text-[#6B7280]">
<Info className="w-4 h-4 mt-0.5 flex-shrink-0" />
<p>You can continue without all fonts, but some text may not display correctly.</p>
</div>
)}
{allFontsUploaded && (
<p className="text-sm text-[#16A34A] font-medium">
All fonts are ready
</p>
)}
<Button
size="lg"
onClick={onContinue}
disabled={isUploading}
className={`
px-5 py-2 h-auto text-sm font-semibold rounded-full transition-all duration-300
${isUploading
? 'bg-[#E5E7EB] text-[#9CA3AF]'
: allFontsUploaded
? 'bg-[#16A34A] text-white hover:bg-[#15803D] shadow-sm'
: 'bg-white text-[#374151] border border-[#E5E7EB] hover:bg-[#F9FAFB]'
}
`}
>
{isUploading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Processing...
</>
) : (
<>
{allFontsUploaded ? 'Continue to Preview' : 'Continue'}
<ChevronRight className="w-4 h-4 ml-1" />
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
};

View file

@ -1,6 +1,6 @@
import React from "react";
import { Loader2 } from "lucide-react";
import Header from "@/app/(presentation-generator)/(dashboard)/dashboard/components/Header";
import Header from "@/app/(dashboard)/components/Header";
interface LoadingSpinnerProps {
message: string;

View file

@ -1,3 +1,4 @@
'use client'
import React from "react";
import { Button } from "@/components/ui/button";
import { FileText, Loader2 } from "lucide-react";
@ -13,13 +14,18 @@ export const SaveLayoutButton: React.FC<SaveLayoutButtonProps> = ({
isSaving,
isProcessing,
}) => {
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50">
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 p-2"
style={{
borderRadius: '36px',
background: 'rgba(0, 0, 0, 0.28)',
}}
>
<Button
onClick={onSave}
disabled={isSaving || isProcessing}
className="bg-green-600 hover:bg-green-700 text-white shadow-lg hover:shadow-xl transition-all duration-200 px-10 py-3 text-lg"
className="bg-[#6938EF] hover:bg-[#6938EF]/90 rounded-[24px] text-white shadow-lg hover:shadow-xl transition-all duration-200 p-3.5 text-base font-semibold"
size="lg"
>
{isSaving ? (
@ -29,7 +35,7 @@ export const SaveLayoutButton: React.FC<SaveLayoutButtonProps> = ({
</>
) : (
<>
<FileText className="w-5 h-5 mr-2" />
Save as Template
</>
)}

View file

@ -1,3 +1,5 @@
'use client'
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -11,14 +13,16 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loader2, Save } from "lucide-react";
import { Loader2, Save, Info } from "lucide-react";
import { useRouter } from "next/navigation";
import ToolTip from "@/components/ToolTip";
interface SaveLayoutModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (layoutName: string, description: string) => Promise<string | null>;
onSave: (layoutName: string, description: string, template_info_id: string) => Promise<string | null>;
isSaving: boolean;
template_info_id: string;
}
export const SaveLayoutModal: React.FC<SaveLayoutModalProps> = ({
@ -26,23 +30,20 @@ export const SaveLayoutModal: React.FC<SaveLayoutModalProps> = ({
onClose,
onSave,
isSaving,
template_info_id,
}) => {
const router = useRouter();
const [layoutName, setLayoutName] = useState("");
const [description, setDescription] = useState("");
const handleSave = async () => {
if (!layoutName.trim()) {
return; // Don't save if name is empty
}
const id = await onSave(layoutName.trim(), description.trim());
if (id) {
// Redirect to the new template preview page
router.push(`/template-preview?slug=custom-${id}`);
}
// Reset form after navigation decision
setLayoutName("");
setDescription("");
await onSave(layoutName.trim(), description.trim(), template_info_id);
};
const handleClose = () => {
@ -55,44 +56,57 @@ export const SaveLayoutModal: React.FC<SaveLayoutModalProps> = ({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[425px]">
<DialogContent className="sm:max-w-[480px] " style={{ zIndex: 1000 }}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Save className="w-5 h-5 text-green-600" />
Save Template
<DialogTitle className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2">
<Save className="w-5 h-5 text-primary" />
Save Template
</span>
</DialogTitle>
<DialogDescription>
Enter a name and description for your template. This will help you identify it later.
Give your template a clear name and an optional description to find it later.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-5 py-4">
<div className="grid gap-2">
<Label htmlFor="layout-name" className="text-sm font-medium">
Template Name *
Template Name <span className="text-red-500">*</span>
</Label>
<Input
id="layout-name"
value={layoutName}
onChange={(e) => setLayoutName(e.target.value)}
placeholder="Enter template name..."
placeholder="e.g., Modern Tech Pitch"
disabled={isSaving}
className="w-full"
aria-required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description" className="text-sm font-medium">
Description
Description <span className="text-gray-400">(optional)</span>
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter a description for your template..."
placeholder="Add a short summary of what this template is best for..."
disabled={isSaving}
className="w-full resize-none"
rows={3}
/>
</div>
{isSaving && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-clock-icon lucide-clock"><path d="M12 6v6l4 2"><animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="10s" repeatCount="indefinite" /></path><circle cx="12" cy="12" r="10" /></svg>
<span>Saving your template. This may take a moment</span>
</div>
)}
</div>
<DialogFooter>
<Button
@ -106,6 +120,7 @@ export const SaveLayoutModal: React.FC<SaveLayoutModalProps> = ({
onClick={handleSave}
disabled={isSaving || !layoutName.trim()}
className="bg-green-600 hover:bg-green-700"
aria-busy={isSaving}
>
{isSaving ? (
<>
@ -121,6 +136,7 @@ export const SaveLayoutModal: React.FC<SaveLayoutModalProps> = ({
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,39 @@
/**
* Schema Editor Panel Component
* Wraps the SchemaEditor with compiled layout functionality
*/
import React from "react";
import { ProcessedSlide } from "../types";
import { SchemaEditor } from "./SchemaEditor";
import { useCompiledLayout } from "../hooks/useCompiledLayout";
interface SchemaEditorPanelProps {
slide: ProcessedSlide;
slideIndex: number;
onSave: (updatedReact: string) => void;
onCancel: () => void;
onFillContent?: (content: Record<string, any>) => void;
}
export const SchemaEditorPanel: React.FC<SchemaEditorPanelProps> = ({
slide,
slideIndex,
onSave,
onCancel,
onFillContent,
}) => {
const compiledLayout = useCompiledLayout(slide.react);
return (
<SchemaEditor
slide={slide}
compiledLayout={compiledLayout}
isOpen={true}
onSave={onSave}
onCancel={onCancel}
onFillContent={onFillContent}
/>
);
};

View file

@ -0,0 +1,307 @@
'use client'
import React, { useCallback, useEffect, useState, useMemo } from 'react'
import { useSchemaHighlight, getAllValuesAtPath } from './SchemaHighlightContext'
interface HighlightRect {
left: number
top: number
width: number
height: number
path: string
}
interface SchemaElementHighlighterProps {
containerRef: React.RefObject<HTMLDivElement | null>
sampleData: Record<string, any> | null
isActive: boolean
}
const SchemaElementHighlighter: React.FC<SchemaElementHighlighterProps> = ({
containerRef,
sampleData,
isActive,
}) => {
const {
highlightedSchemaPath,
setHighlightedElementPath,
} = useSchemaHighlight()
const [highlightRects, setHighlightRects] = useState<HighlightRect[]>([])
const [hoverRect, setHoverRect] = useState<HighlightRect | null>(null)
// Build a map of text content to schema paths
const textToPathMap = useMemo(() => {
if (!sampleData) return new Map<string, string>()
const map = new Map<string, string>()
const buildMap = (obj: any, parentPath: string = '') => {
if (!obj || typeof obj !== 'object') return
for (const [key, value] of Object.entries(obj)) {
// Skip internal fields
if (key.startsWith('__') || key.startsWith('_')) continue
const path = parentPath ? `${parentPath}.${key}` : key
if (typeof value === 'string' && value.trim().length > 0) {
// Store the text content mapped to its path
map.set(value.trim(), path)
} else if (typeof value === 'number') {
map.set(String(value), path)
} else if (Array.isArray(value)) {
// For arrays, map each item's content with array notation
value.forEach((item, index) => {
if (typeof item === 'object' && item !== null) {
buildMap(item, `${path}[]`)
} else if (typeof item === 'string' && item.trim().length > 0) {
map.set(item.trim(), `${path}[]`)
}
})
} else if (typeof value === 'object' && value !== null) {
buildMap(value, path)
}
}
}
buildMap(sampleData)
return map
}, [sampleData])
// Find elements that contain the highlighted schema path's value
const findElementsForPath = useCallback((path: string): HTMLElement[] => {
const container = containerRef.current
if (!container || !sampleData) return []
// Get all values for this path (handles arrays)
const values = getAllValuesAtPath(sampleData, path)
if (values.length === 0) return []
const elements: HTMLElement[] = []
// Walk through all text nodes and find matches
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
null
)
const valueSet = new Set(values.map(v => String(v).trim()))
let node: Text | null
while ((node = walker.nextNode() as Text | null)) {
const text = node.textContent?.trim()
if (text && valueSet.has(text)) {
const parent = node.parentElement
if (parent && !parent.closest('[data-inspector-overlay="1"]')) {
elements.push(parent)
}
}
}
return elements
}, [containerRef, sampleData])
// Find the schema path for an element based on its text content
const findPathForElement = useCallback((element: HTMLElement): string | null => {
const text = element.textContent?.trim()
if (!text) return null
// Look up the text in our map
return textToPathMap.get(text) || null
}, [textToPathMap])
// Calculate highlight rectangles for the highlighted schema path
useEffect(() => {
if (!isActive || !highlightedSchemaPath) {
setHighlightRects([])
return
}
const container = containerRef.current
if (!container) return
const elements = findElementsForPath(highlightedSchemaPath)
const rects: HighlightRect[] = elements.map(el => {
const rect = el.getBoundingClientRect()
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
path: highlightedSchemaPath,
}
}).filter(r => r.width > 0 && r.height > 0)
setHighlightRects(rects)
}, [highlightedSchemaPath, isActive, containerRef, findElementsForPath])
// Handle hover on elements to show which schema field they map to
useEffect(() => {
if (!isActive) return
const container = containerRef.current
if (!container) return
const handleMouseOver = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target || target.closest('[data-inspector-overlay="1"]')) {
return
}
const path = findPathForElement(target)
if (path) {
const rect = target.getBoundingClientRect()
setHoverRect({
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
path,
})
} else {
setHoverRect(null)
}
}
const handleMouseLeave = () => {
setHoverRect(null)
}
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target || target.closest('[data-inspector-overlay="1"]')) {
return
}
const path = findPathForElement(target)
if (path) {
e.preventDefault()
e.stopPropagation()
setHighlightedElementPath(path)
}
}
container.addEventListener('mouseover', handleMouseOver, true)
container.addEventListener('mouseleave', handleMouseLeave, true)
container.addEventListener('click', handleClick, true)
return () => {
container.removeEventListener('mouseover', handleMouseOver, true)
container.removeEventListener('mouseleave', handleMouseLeave, true)
container.removeEventListener('click', handleClick, true)
}
}, [isActive, containerRef, findPathForElement, setHighlightedElementPath])
// Recalculate on scroll/resize
useEffect(() => {
if (!isActive) return
const handleUpdate = () => {
if (highlightedSchemaPath) {
const container = containerRef.current
if (!container) return
const elements = findElementsForPath(highlightedSchemaPath)
const rects: HighlightRect[] = elements.map(el => {
const rect = el.getBoundingClientRect()
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
path: highlightedSchemaPath,
}
}).filter(r => r.width > 0 && r.height > 0)
setHighlightRects(rects)
}
}
window.addEventListener('scroll', handleUpdate, true)
window.addEventListener('resize', handleUpdate)
return () => {
window.removeEventListener('scroll', handleUpdate, true)
window.removeEventListener('resize', handleUpdate)
}
}, [isActive, highlightedSchemaPath, containerRef, findElementsForPath])
if (!isActive) return null
return (
<>
{/* Hover highlight - shows path label */}
{hoverRect && !highlightedSchemaPath && (
<>
<div
data-inspector-overlay="1"
style={{
position: 'fixed',
left: hoverRect.left - 2,
top: hoverRect.top - 2,
width: hoverRect.width + 4,
height: hoverRect.height + 4,
border: '2px dashed #10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
pointerEvents: 'none',
zIndex: 40,
borderRadius: '4px',
}}
/>
{/* Path label */}
<div
data-inspector-overlay="1"
style={{
position: 'fixed',
left: hoverRect.left,
top: hoverRect.top - 24,
backgroundColor: '#10b981',
color: 'white',
fontSize: '10px',
fontWeight: 600,
padding: '2px 6px',
borderRadius: '4px',
pointerEvents: 'none',
zIndex: 41,
whiteSpace: 'nowrap',
}}
>
{hoverRect.path}
</div>
</>
)}
{/* Schema path highlights */}
{highlightRects.map((rect, idx) => (
<div
key={`schema-highlight-${idx}`}
data-inspector-overlay="1"
className="animate-pulse"
style={{
position: 'fixed',
left: rect.left - 3,
top: rect.top - 3,
width: rect.width + 6,
height: rect.height + 6,
border: '3px solid #8b5cf6',
backgroundColor: 'rgba(139, 92, 246, 0.15)',
pointerEvents: 'none',
zIndex: 40,
borderRadius: '6px',
boxShadow: '0 0 0 2px rgba(139, 92, 246, 0.3), 0 4px 12px rgba(139, 92, 246, 0.2)',
}}
/>
))}
</>
)
}
export default SchemaElementHighlighter

View file

@ -0,0 +1,129 @@
'use client'
import React, { createContext, useContext, useState, RefObject } from 'react'
interface SchemaHighlightContextType {
// Currently highlighted schema path (from schema editor hover)
highlightedSchemaPath: string | null
setHighlightedSchemaPath: (path: string | null) => void
// Currently highlighted element path (from element click)
highlightedElementPath: string | null
setHighlightedElementPath: (path: string | null) => void
// Sample data for matching elements to schema paths
sampleData: Record<string, any> | null
setSampleData: (data: Record<string, any> | null) => void
// Container ref for the slide preview
slideContainerRef: RefObject<HTMLDivElement | null> | null
setSlideContainerRef: (ref: RefObject<HTMLDivElement | null> | null) => void
}
const SchemaHighlightContext = createContext<SchemaHighlightContextType | null>(null)
export const SchemaHighlightProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [highlightedSchemaPath, setHighlightedSchemaPath] = useState<string | null>(null)
const [highlightedElementPath, setHighlightedElementPath] = useState<string | null>(null)
const [sampleData, setSampleData] = useState<Record<string, any> | null>(null)
const [slideContainerRef, setSlideContainerRef] = useState<RefObject<HTMLDivElement | null> | null>(null)
return (
<SchemaHighlightContext.Provider value={{
highlightedSchemaPath,
setHighlightedSchemaPath,
highlightedElementPath,
setHighlightedElementPath,
sampleData,
setSampleData,
slideContainerRef,
setSlideContainerRef,
}}>
{children}
</SchemaHighlightContext.Provider>
)
}
export const useSchemaHighlight = () => {
const context = useContext(SchemaHighlightContext)
if (!context) {
// Return a no-op version if not in provider
return {
highlightedSchemaPath: null,
setHighlightedSchemaPath: () => { },
highlightedElementPath: null,
setHighlightedElementPath: () => { },
sampleData: null,
setSampleData: () => { },
slideContainerRef: null,
setSlideContainerRef: () => { },
}
}
return context
}
// Helper to get value from nested path in data object
export function getValueAtPath(data: Record<string, any> | null | undefined, path: string): any {
if (!data || !path) return undefined
// Handle array item paths like "items[].title" - get from first item
const parts = path.split('.')
let current: any = data
for (const part of parts) {
if (current === undefined || current === null) return undefined
// Check if this part has array notation
if (part.endsWith('[]')) {
const arrayKey = part.slice(0, -2)
current = current[arrayKey]
if (Array.isArray(current) && current.length > 0) {
current = current[0] // Get first item for matching
} else {
return undefined
}
} else {
current = current[part]
}
}
return current
}
// Helper to find all values for a schema path (for arrays, returns all item values)
export function getAllValuesAtPath(data: Record<string, any> | null | undefined, path: string): any[] {
if (!data || !path) return []
const parts = path.split('.')
let currentItems: any[] = [data]
for (const part of parts) {
const nextItems: any[] = []
for (const item of currentItems) {
if (item === undefined || item === null) continue
if (part.endsWith('[]')) {
const arrayKey = part.slice(0, -2)
const arr = item[arrayKey]
if (Array.isArray(arr)) {
nextItems.push(...arr)
}
} else {
if (item[part] !== undefined) {
nextItems.push(item[part])
}
}
}
currentItems = nextItems
}
return currentItems.filter(v => v !== undefined && v !== null)
}

View file

@ -1,21 +1,65 @@
'use client'
import React, { memo } from "react";
import { CompiledLayout, compileCustomLayout } from "@/app/hooks/compileLayout";
import { RotateCcw } from "lucide-react";
import React, { memo, useMemo } from "react";
interface SlideContentProps {
slide: any;
data?: Record<string, any> | null;
compiledLayout?: CompiledLayout | null;
retrySlide: (slideNumber: number) => void;
}
const SlideContent = memo(({ slide, data, compiledLayout, retrySlide }: SlideContentProps) => {
// Use provided compiled layout or compile (fallback for other usages)
const module = useMemo(() => {
if (compiledLayout) return compiledLayout;
if (!slide.react) return null;
return compileCustomLayout(slide.react);
}, [slide.react, compiledLayout]);
const sampleData = useMemo(() => {
// If custom data is provided, use it
if (data) {
return data;
}
// Otherwise use sampleData from compiled layout or generate from schema defaults
if (module?.sampleData && Object.keys(module.sampleData).length > 0) {
return module.sampleData;
}
try {
return module?.schema?.parse({}) ?? {};
} catch {
return {};
}
}, [module, data]);
const Component = useMemo(() => {
return module?.component;
}, [module]);
if (!slide?.react) return null;
if (!module) {
return (
<div className="w-full aspect-[16/9] h-[720px] bg-red-50 text-red-700 p-4 rounded border border-red-200 text-sm whitespace-pre-wrap break-words flex flex-col items-center justify-center">
<p className="text-center"> Failed to render slide component. Check console for details.</p>
<button onClick={() => retrySlide(slide.slide_number)} className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 flex items-center justify-center mt-6"> <RotateCcw className="w-4 h-4 mr-1" /> Re-Construct</button>
</div>
);
}
const SlideContent = memo(({ slide }: { slide: any }) => {
const cleanHtml = slide.html
.replace(/```html/g, "")
.replace(/```/g, "")
.replace(/<html>/g, "")
.replace(/<\/html>/g, "")
.replace(/html/g, "");
return (
<div
dangerouslySetInnerHTML={{
__html: cleanHtml,
}}
/>
<>
{Component && <Component data={sampleData} />}
</>
);
});
SlideContent.displayName = 'SlideContent';
export default SlideContent;

View file

@ -0,0 +1,104 @@
'use client'
import React from "react";
import { Button } from "@/components/ui/button";
import {
Loader2,
Images,
ChevronRight,
Sparkles
} from "lucide-react";
import { SlidePreviewSectionProps } from "../types";
export const SlidePreviewSection: React.FC<SlidePreviewSectionProps> = ({
previewData,
onInitTemplate,
isLoading,
}) => {
const slideCount = previewData.slide_image_urls?.length || 0;
return (
<div className="my-8 max-w-[1440px] mx-auto">
{/* Header Card */}
<div className="bg-white rounded-2xl border border-[#E5E7EB] shadow-sm overflow-hidden">
{/* Header */}
<div className="px-6 py-5 border-b border-[#F3F4F6] bg-gradient-to-r from-[#FAFAFA] to-white">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-[#EBE9FE] to-[#DDD6FE] flex items-center justify-center shadow-sm">
<Images className="w-6 h-6 text-[#7A5AF8]" />
</div>
<div>
<h2 className="text-xl font-semibold text-[#111827]">Slide Preview</h2>
<p className="text-sm text-[#6B7280] mt-0.5">
{slideCount} slide{slideCount !== 1 ? 's' : ''} ready for template generation
</p>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-4 py-4 max-h-[900px] overflow-y-auto">
{previewData.slide_image_urls?.map((url, index) => (
<div
key={index}
className="group relative aspect-video w-full max-w-[1280px] mx-auto rounded-xl overflow-hidden "
>
<img
src={`${process.env.NEXT_PUBLIC_FAST_API}${url}`}
alt={`Slide ${index + 1}`}
className="w-full h-full object-cover"
/>
{/* Slide number badge */}
<div className="absolute top-2 left-2 px-2.5 py-1 bg-black/70 backdrop-blur-sm rounded-lg text-xs font-semibold text-white shadow-lg">
{index + 1}
</div>
</div>
))}
</div>
{/* Action Footer */}
<div className="px-6 py-5 border-t border-[#F3F4F6] bg-gradient-to-r from-[#FAFAFA] to-white">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-sm text-[#6B7280] max-w-md text-center sm:text-left">
Ready to generate your template. Each slide will be converted to a reusable React component.
</p>
<Button
size="lg"
onClick={onInitTemplate}
disabled={isLoading}
className="px-4 py-2 h-auto text-xs font-semibold rounded-full shadow-lg hover:shadow-xl transition-all duration-300 "
style={{
background: isLoading
? '#E5E7EB'
: 'linear-gradient(135deg, #D5CAFC 0%, #E3D2EB 35%, #F4DCD3 70%, #FDE4C2 100%)',
color: isLoading ? '#9CA3AF' : '#111827',
}}
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
Starting...
</>
) : (
<>
<Sparkles className="w-4 h-4 mr-1" />
Generate Template
<ChevronRight className="w-4 h-4 ml-1" />
</>
)}
</Button>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,160 @@
import React from "react";
import {
Upload,
Type,
Images,
Sparkles,
Check,
Loader2,
} from "lucide-react";
import { TemplateCreationStep } from "../types";
interface TemplateCreationProgressProps {
currentStep: TemplateCreationStep;
totalSlides?: number;
processedSlides?: number;
}
interface StepConfig {
id: TemplateCreationStep;
label: string;
icon: React.ReactNode;
}
const steps: StepConfig[] = [
{
id: 'file-upload',
label: 'Upload',
icon: <Upload className="w-4 h-4" />
},
{
id: 'font-check',
label: 'Fonts',
icon: <Type className="w-4 h-4" />
},
{
id: 'slides-preview',
label: 'Preview',
icon: <Images className="w-4 h-4" />
},
{
id: 'template-creation',
label: 'Generate',
icon: <Sparkles className="w-4 h-4" />
},
{
id: 'completed',
label: 'Done',
icon: <Check className="w-4 h-4" />
},
];
export const TemplateCreationProgress: React.FC<TemplateCreationProgressProps> = ({
currentStep,
totalSlides = 0,
processedSlides = 0,
}) => {
const getCurrentStepIndex = () => {
if (currentStep === 'font-upload') return 1;
const stepIndex = steps.findIndex(s => s.id === currentStep);
return stepIndex >= 0 ? stepIndex : 0;
};
const currentStepIndex = getCurrentStepIndex();
const getStepStatus = (stepIndex: number): 'completed' | 'current' | 'pending' => {
if (currentStep === 'completed') return 'completed';
if (stepIndex < currentStepIndex) return 'completed';
if (stepIndex === currentStepIndex) return 'current';
return 'pending';
};
const progressPercentage = totalSlides > 0
? Math.round((processedSlides / totalSlides) * 100)
: 0;
return (
<div className="w-full max-w-[700px] mx-auto mb-8">
{/* Steps */}
<div className="flex items-center justify-between">
{steps.map((step, index) => {
const status = getStepStatus(index);
const isLast = index === steps.length - 1;
return (
<React.Fragment key={step.id}>
<div className="flex flex-col items-center">
{/* Circle */}
<div
className={`
w-10 h-10 rounded-full flex items-center justify-center border-2 transition-all duration-200
${status === 'completed'
? 'bg-[#7A5AF8] border-[#7A5AF8] text-white'
: status === 'current'
? 'bg-white border-[#7A5AF8] text-[#7A5AF8]'
: 'bg-white border-[#E5E7EB] text-[#9CA3AF]'
}
`}
>
{status === 'completed' ? (
<Check className="w-4 h-4" />
) : status === 'current' && currentStep === 'template-creation' ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
step.icon
)}
</div>
{/* Label */}
<span
className={`
mt-2 text-xs font-medium
${status === 'completed' || status === 'current'
? 'text-[#374151]'
: 'text-[#9CA3AF]'
}
`}
>
{step.label}
</span>
</div>
{/* Connector */}
{!isLast && (
<div className="flex-1 h-px mx-3 -mt-5">
<div
className={`h-full transition-colors duration-200 ${getStepStatus(index + 1) !== 'pending'
? 'bg-[#7A5AF8]'
: 'bg-[#E5E7EB]'
}`}
/>
</div>
)}
</React.Fragment>
);
})}
</div>
{/* Processing Progress */}
{currentStep === 'template-creation' && totalSlides > 0 && (
<div className="mt-6 p-4 bg-[#F9FAFB] rounded-xl border border-[#E5E7EB]">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-[#374151]">
Processing slides
</span>
<span className="text-sm font-medium text-[#374151]">
{processedSlides} / {totalSlides}
</span>
</div>
<div className="w-full h-2 bg-[#E5E7EB] rounded-full overflow-hidden">
<div
className="h-full bg-[#7A5AF8] rounded-full transition-all duration-300"
style={{ width: `${progressPercentage}%` }}
/>
</div>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,17 @@
import React from "react";
export const TemplateStudioHeader: React.FC = () => {
return (
<div className="text-center my-[52px] px-2 md:px-0">
<h1 className="font-unbounded text-[36px] sm:text-[38px] md:text-[64px] text-[#101323] font-normal tracking-[-1.92px] pb-2">
Template Studio
</h1>
<p className="text-[#101323CC] text-base md:text-xl font-syne font-normal max-w-[600px] mx-auto">
Upload your PPTX file to extract slides and convert them to a template which you can use to generate AI presentations.
</p>
</div>
);
};

View file

@ -14,34 +14,18 @@ const Timer = ({ duration }: TimerProps) => {
// Guard against invalid durations
const totalMs = Math.max(0, duration * 1000)
const easeOutCubic = (x: number) => 1 - Math.pow(1 - x, 3)
const easeOutSine = (x: number) => Math.sin((x * Math.PI) / 2)
const tick = (now: number) => {
if (startTimeRef.current === null) startTimeRef.current = now
const elapsed = now - startTimeRef.current
const t = totalMs === 0 ? 1 : Math.min(elapsed / totalMs, 1)
// Piecewise progression:
// - Reach ~75% around 60% of the total duration (faster start)
// - Then ease slowly towards 99% for the remainder
let nextProgress: number
if (t <= 0.6) {
nextProgress = 75 * easeOutCubic(t / 0.6)
} else {
nextProgress = 75 + 24 * easeOutSine((t - 0.6) / 0.4)
}
setProgress(prev => (t <= prev ? prev : t))
// Clamp and ensure we never hit 100
nextProgress = Math.min(99, nextProgress)
setProgress(prev => (nextProgress < prev ? prev : nextProgress))
if (t < 1 && nextProgress < 99) {
if (t < 1) {
rafIdRef.current = requestAnimationFrame(tick)
} else {
// End at 99 and stop
setProgress(99)
// Ensure we finish at 100%
setProgress(1)
if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current)
rafIdRef.current = null
}
@ -59,41 +43,55 @@ const Timer = ({ duration }: TimerProps) => {
}
}, [duration])
const progressValue = Math.min(1, Number(progress.toFixed(4)))
const displayedProgress = Math.round(progressValue * 100)
return (
<div className="w-full space-y-2">
<div className="flex justify-end items-center text-gray-800 text-sm">
<span className="font-inter text-end font-semibold text-xs">{Math.round(progress)}%</span>
</div>
<div className="w-full">
{/* Progress bar container */}
<div
className="w-full rounded-full h-3 overflow-hidden shadow-inner"
className="relative w-full h-2 rounded-full bg-[#E5E7EB] overflow-hidden"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(progress)}
aria-valuenow={displayedProgress}
>
<div className="relative h-full rounded-full" style={{
width: `${progress}%`,
backgroundImage: 'linear-gradient(90deg, #9034EA, #5146E5, #9034EA)',
backgroundSize: '200% 100%',
animation: 'gradient 2s linear infinite'
}}>
<div className="absolute inset-0 opacity-25" style={{
backgroundImage:
'linear-gradient(45deg, rgba(255,255,255,.8) 25%, transparent 25%, transparent 50%, rgba(255,255,255,.8) 50%, rgba(255,255,255,.8) 75%, transparent 75%, transparent)',
backgroundSize: '16px 16px',
animation: 'stripes 1s linear infinite'
}} />
{/* Progress fill */}
<div
className="absolute inset-y-0 left-0 rounded-full transition-[width] duration-100 ease-out"
style={{
width: `${progressValue * 100}%`,
background: 'linear-gradient(90deg, #7A5AF8, #9B8AFB, #7A5AF8)',
backgroundSize: '200% 100%',
animation: 'shimmer 2s linear infinite',
}}
>
{/* Animated stripes overlay */}
<div
className="absolute inset-0 opacity-30"
style={{
backgroundImage: 'linear-gradient(45deg, rgba(255,255,255,0.4) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.4) 50%, rgba(255,255,255,0.4) 75%, transparent 75%, transparent)',
backgroundSize: '12px 12px',
animation: 'stripes 0.8s linear infinite',
}}
/>
</div>
<div className="absolute inset-0" />
</div>
{/* Percentage text */}
<div className="flex justify-end mt-1.5">
<span className="text-xs font-medium text-[#6B7280] tabular-nums">
{displayedProgress}%
</span>
</div>
<style jsx>{`
@keyframes gradient {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
@keyframes shimmer {
0% { background-position: 200% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes stripes {
to { background-position: 16px 0; }
to { background-position: 12px 0; }
}
`}</style>
</div>

View file

@ -0,0 +1,63 @@
/**
* Slides List Component
* Renders the grid of slides being edited
*/
import React from "react";
import { ProcessedSlide } from "../../types";
import SlideErrorBoundary from "@/app/(presentation-generator)/components/SlideErrorBoundary";
import EachSlide from "../EachSlide/NewEachSlide";
interface SlidesListProps {
slides: ProcessedSlide[];
setSlides: React.Dispatch<React.SetStateAction<ProcessedSlide[]>>;
retrySlide: (index: number) => void;
onSlideUpdate: (index: number, updatedSlideData: Partial<ProcessedSlide>) => void;
onOpenSchemaEditor: (index: number | null) => void;
schemaEditorSlideIndex: number | null;
schemaPreviewData: Record<number, Record<string, any>>;
onClearSchemaPreview: (slideIndex: number) => void;
isSchemaEditorOpen: boolean;
}
export const SlidesList: React.FC<SlidesListProps> = ({
slides,
setSlides,
retrySlide,
onSlideUpdate,
onOpenSchemaEditor,
schemaEditorSlideIndex,
schemaPreviewData,
onClearSchemaPreview,
isSchemaEditorOpen,
}) => {
const containerWidth = isSchemaEditorOpen ? 'w-[calc(100%-540px)]' : 'w-full';
return (
<div className={`space-y-5 w-full p-5 rounded-2xl ${containerWidth}`}>
{slides.map((slide, index) => (
<SlideErrorBoundary
key={index}
label={`Slide ${index + 1}`}
>
<EachSlide
key={index}
slide={slide}
index={index}
isProcessing={slides.some((s) => s.processing)}
retrySlide={retrySlide}
setSlides={setSlides}
onSlideUpdate={(updatedSlideData) =>
onSlideUpdate(index, updatedSlideData)
}
onOpenSchemaEditor={onOpenSchemaEditor}
isSchemaEditorOpen={schemaEditorSlideIndex === index}
schemaPreviewData={schemaPreviewData[index] ?? null}
onClearSchemaPreview={() => onClearSchemaPreview(index)}
/>
</SlideErrorBoundary>
))}
</div>
);
};

View file

@ -0,0 +1,40 @@
/**
* Step 2: Font Management
* Handles font checking and uploading
*/
import React from "react";
import FontManager from "../FontManager";
import { FontData, UploadedFont } from "../../types";
interface Step2FontManagementProps {
fontsData: FontData | null;
uploadedFonts: UploadedFont[];
uploadFont: (fontName: string, file: File) => string | null;
removeFont: (fontName: string) => void;
onContinue: () => Promise<void>;
isUploading: boolean;
}
export const Step2FontManagement: React.FC<Step2FontManagementProps> = ({
fontsData,
uploadedFonts,
uploadFont,
removeFont,
onContinue,
isUploading,
}) => {
if (!fontsData) return null;
return (
<FontManager
fontsData={fontsData}
uploadedFonts={uploadedFonts}
uploadFont={uploadFont}
removeFont={removeFont}
onContinue={onContinue}
isUploading={isUploading}
/>
);
};

View file

@ -0,0 +1,31 @@
/**
* Step 3: Slide Preview
* Displays preview of slides with uploaded fonts
*/
import React from "react";
import { SlidePreviewSection } from "../SlidePreviewSection";
import { FontUploadPreviewResponse } from "../../types";
interface Step3SlidePreviewProps {
previewData: FontUploadPreviewResponse | null;
onInitTemplate: () => void;
isLoading: boolean;
}
export const Step3SlidePreview: React.FC<Step3SlidePreviewProps> = ({
previewData,
onInitTemplate,
isLoading,
}) => {
if (!previewData) return null;
return (
<SlidePreviewSection
previewData={previewData}
onInitTemplate={onInitTemplate}
isLoading={isLoading}
/>
);
};

View file

@ -0,0 +1,81 @@
import React from "react";
import { ProcessedSlide } from "../../types";
import { SchemaHighlightProvider } from "../SchemaHighlightContext";
import { SlidesList } from "./SlidesList";
import { SchemaEditorPanel } from "../SchemaEditorPanel";
interface Step4TemplateCreationProps {
slides: ProcessedSlide[];
setSlides: React.Dispatch<React.SetStateAction<ProcessedSlide[]>>;
retrySlide: (index: number) => void;
onSlideUpdate: (index: number, updatedSlideData: Partial<ProcessedSlide>) => void;
// Schema editor state
schemaEditorSlideIndex: number | null;
onOpenSchemaEditor: (index: number | null) => void;
onCloseSchemaEditor: () => void;
onSchemaEditorSave: (updatedReact: string) => void;
// Schema preview state
schemaPreviewData: Record<number, Record<string, any>>;
onSchemaPreviewContent: (content: Record<string, any>) => void;
onClearSchemaPreview: (slideIndex: number) => void;
}
export const Step4TemplateCreation: React.FC<Step4TemplateCreationProps> = ({
slides,
setSlides,
retrySlide,
onSlideUpdate,
schemaEditorSlideIndex,
onOpenSchemaEditor,
onCloseSchemaEditor,
onSchemaEditorSave,
schemaPreviewData,
onSchemaPreviewContent,
onClearSchemaPreview,
}) => {
const schemaEditorSlide = schemaEditorSlideIndex !== null ? slides[schemaEditorSlideIndex] : null;
const isSchemaEditorOpen = schemaEditorSlideIndex !== null;
return (
<SchemaHighlightProvider>
<div className="mt-8 mx-auto">
<div className="transition-all duration-300 flex-1">
<div className="flex items-stretch gap-2">
{/* Slides List */}
<SlidesList
slides={slides}
setSlides={setSlides}
retrySlide={retrySlide}
onSlideUpdate={onSlideUpdate}
onOpenSchemaEditor={onOpenSchemaEditor}
schemaEditorSlideIndex={schemaEditorSlideIndex}
schemaPreviewData={schemaPreviewData}
onClearSchemaPreview={onClearSchemaPreview}
isSchemaEditorOpen={isSchemaEditorOpen}
/>
{/* Schema Editor Panel (Right Sidebar) */}
{isSchemaEditorOpen && schemaEditorSlide && (
<div className="w-[520px] sticky top-20 self-start">
<div className="bg-white rounded-2xl border border-gray-200 shadow-lg overflow-hidden">
<SchemaEditorPanel
slide={schemaEditorSlide}
slideIndex={schemaEditorSlideIndex}
onSave={onSchemaEditorSave}
onCancel={onCloseSchemaEditor}
onFillContent={onSchemaPreviewContent}
/>
</div>
</div>
)}
</div>
</div>
</div>
</SchemaHighlightProvider>
);
};

View file

@ -0,0 +1,94 @@
/**
* Constants for Custom Template Creation Flow
*/
import { TemplateCreationStep } from "../types";
// Step configuration
export const TEMPLATE_STEPS: Record<TemplateCreationStep, { title: string; description: string }> = {
'file-upload': {
title: 'Upload Template',
description: 'Upload your PPTX file to begin',
},
'font-check': {
title: 'Font Check',
description: 'Checking fonts in your presentation',
},
'font-upload': {
title: 'Upload Fonts',
description: 'Upload missing fonts for accurate rendering',
},
'slides-preview': {
title: 'Preview Slides',
description: 'Review your slides before processing',
},
'template-creation': {
title: 'Template Creation',
description: 'Converting slides to reusable templates',
},
'completed': {
title: 'Completed',
description: 'Your template is ready to save',
},
};
// UI Configuration
export const UI_CONFIG = {
schemaEditorWidth: '520px',
slideGridGap: '20px',
maxContentWidth: '1400px',
}
// Highlights for benefits section
export const HIGHLIGHTS_ITEMS = [
{
number: "1",
title: "Time-consume",
description: "Manual formatting and slide copying wastes hours every week",
},
{
number: "2",
title: "Expensive",
description: "Design resources spent on repetitive tasks instead of innovation",
},
{
number: "3",
title: "Inconsistent",
description: "AI generates unpredictable layouts that require constant cleanup",
},
]
// External scripts
export const TAILWIND_CDN_URL = "https://cdn.tailwindcss.com";
export const FAQS = [
{
question: "What is Custom Template Creation?",
answer: "Custom Template Creation is a feature that allows you to create custom templates for your presentations.",
},
{
question: "How do I create a custom template?",
answer: "You can create a custom template by uploading a PPTX file and then editing the template to your liking.",
},
{
question: "How do I edit a custom template?",
answer: "You can edit a custom template by uploading a PPTX file and then editing the template to your liking.",
},
{
question: "How do I delete a custom template?",
answer: "You can delete a custom template by uploading a PPTX file and then editing the template to your liking.",
},
{
question: "How do I create a custom template?",
answer: "You can create a custom template by uploading a PPTX file and then editing the template to your liking.",
},
{
question: "How do I edit a custom template?",
answer: "You can edit a custom template by uploading a PPTX file and then editing the template to your liking.",
},
{
question: "How do I delete a custom template?",
answer: "You can delete a custom template by uploading a PPTX file and then editing the template to your liking.",
},
]

View file

@ -0,0 +1,12 @@
/**
* Hooks Index
* Central export point for all custom template hooks
*/
export { useFileUpload } from "./useFileUpload";
export { useTemplateCreation } from "./useTemplateCreation";
export { useLayoutSaving } from "./useLayoutSaving";
export { useCompiledLayout } from "./useCompiledLayout";
export { useSlideEdit } from "./useSlideEdit";
export { useSlideUndoRedo } from "./useSlideUndoRedo";

View file

@ -1,31 +0,0 @@
import { useState, useEffect } from "react";
export const useAPIKeyCheck = () => {
const [hasRequiredKey, setHasRequiredKey] = useState(false);
const [isRequiredKeyLoading, setIsRequiredKeyLoading] = useState(true);
useEffect(() => {
const checkKey = async () => {
try {
let data;
// Check if running in Electron environment
if (typeof window !== 'undefined' && window.electron?.hasRequiredKey) {
// Use Electron IPC handler
data = await window.electron.hasRequiredKey();
} else {
// Fallback to API route for web-based deployments
const res = await fetch("/api/has-required-key");
data = await res.json();
}
setHasRequiredKey(Boolean(data.hasKey));
setIsRequiredKeyLoading(false);
} catch {
setIsRequiredKeyLoading(false);
}
};
checkKey();
}, []);
return { hasRequiredKey, isRequiredKeyLoading };
};

View file

@ -0,0 +1,19 @@
import { useMemo } from "react";
import { compileCustomLayout, CompiledLayout } from "@/app/hooks/compileLayout";
/**
* Hook to compile layout code once and memoize the result.
* This prevents double compilation when both SlideContent and SchemaEditor need the compiled layout.
*/
export function useCompiledLayout(code: string | undefined): CompiledLayout | null {
return useMemo(() => {
if (!code) return null;
try {
return compileCustomLayout(code);
} catch (error) {
console.error("Error compiling layout:", error);
return null;
}
}, [code]);
}

View file

@ -1,32 +0,0 @@
import { useState, useEffect } from "react";
import { ProcessedSlide } from "../types";
export const useCustomLayout = () => {
const [slides, setSlides] = useState<ProcessedSlide[]>([]);
const [isLayoutSaved, setIsLayoutSaved] = useState(false);
// Warning before page unload
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
return "You have unsaved changes. Are you sure you want to leave?";
};
if (slides.length > 0 && !isLayoutSaved) {
window.addEventListener("beforeunload", handleBeforeUnload);
}
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [slides, isLayoutSaved]);
// Calculate progress
const completedSlides = slides.filter(
(slide) => slide.processed || slide.error
).length;
return {
slides,
setSlides,
completedSlides,
isLayoutSaved,
setIsLayoutSaved,
};
};

View file

@ -1,167 +0,0 @@
import { useState, useCallback, useRef } from "react";
export const useDrawingCanvas = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const slideDisplayRef = useRef<HTMLDivElement>(null);
const [strokeWidth, setStrokeWidth] = useState(3);
const [strokeColor, setStrokeColor] = useState("#000000");
const [eraserMode, setEraserMode] = useState(false);
const [isDrawing, setIsDrawing] = useState(false);
const [canvasDimensions, setCanvasDimensions] = useState({
width: 1280,
height: 720,
});
const [didYourDraw, setDidYourDraw] = useState(false);
const getCanvasContext = () => {
const canvas = canvasRef.current;
if (!canvas) return null;
return canvas.getContext("2d");
};
const getMousePos = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
};
const getTouchPos = (e: React.TouchEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
const touch = e.touches[0];
return {
x: touch.clientX - rect.left,
y: touch.clientY - rect.top,
};
};
const startDrawing = useCallback(
(pos: { x: number; y: number }) => {
const ctx = getCanvasContext();
if (!ctx) return;
setIsDrawing(true);
ctx.beginPath();
ctx.moveTo(pos.x, pos.y);
if (eraserMode) {
ctx.globalCompositeOperation = "destination-out";
ctx.lineWidth = strokeWidth * 2;
} else {
ctx.globalCompositeOperation = "source-over";
ctx.strokeStyle = strokeColor;
ctx.lineWidth = strokeWidth;
}
ctx.lineCap = "round";
ctx.lineJoin = "round";
},
[eraserMode, strokeColor, strokeWidth]
);
const draw = useCallback(
(pos: { x: number; y: number }) => {
if (!isDrawing) return;
setDidYourDraw(true);
const ctx = getCanvasContext();
if (!ctx) return;
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
},
[isDrawing]
);
const stopDrawing = useCallback(() => {
setIsDrawing(false);
}, []);
// Mouse events
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
e.preventDefault();
const pos = getMousePos(e);
startDrawing(pos);
};
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
e.preventDefault();
const pos = getMousePos(e);
draw(pos);
};
const handleMouseUp = (e: React.MouseEvent<HTMLCanvasElement>) => {
e.preventDefault();
stopDrawing();
};
// Touch events
const handleTouchStart = (e: React.TouchEvent<HTMLCanvasElement>) => {
e.preventDefault();
const pos = getTouchPos(e);
startDrawing(pos);
};
const handleTouchMove = (e: React.TouchEvent<HTMLCanvasElement>) => {
e.preventDefault();
const pos = getTouchPos(e);
draw(pos);
};
const handleTouchEnd = (e: React.TouchEvent<HTMLCanvasElement>) => {
e.preventDefault();
stopDrawing();
};
const handleClearCanvas = () => {
const canvas = canvasRef.current;
setDidYourDraw(false);
const ctx = getCanvasContext();
if (!canvas || !ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
};
const handleEraserModeChange = (isEraser: boolean) => {
setEraserMode(isEraser);
};
const handleStrokeColorChange = (color: string) => {
setStrokeColor(color);
setEraserMode(false);
};
const handleStrokeWidthChange = (width: number) => {
setStrokeWidth(width);
};
return {
canvasRef,
slideDisplayRef,
strokeWidth,
strokeColor,
eraserMode,
isDrawing,
canvasDimensions,
setCanvasDimensions,
didYourDraw,
setDidYourDraw,
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
handleClearCanvas,
handleEraserModeChange,
handleStrokeColorChange,
handleStrokeWidthChange,
};
};

View file

@ -12,9 +12,8 @@ export const useFileUpload = () => {
// Validate file type
const lowerName = file.name.toLowerCase();
const isPptx = lowerName.endsWith(".pptx");
const isPdf = lowerName.endsWith(".pdf");
if (!isPptx && !isPdf) {
toast.error("Please select a valid PDF or PPTX file");
if (!isPptx) {
toast.error("Please select a valid PPTX file");
return;
}

View file

@ -1,153 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { toast } from "sonner";
import { UploadedFont, FontData } from "../types";
import { getApiUrl } from "@/utils/api";
export const useFontManagement = () => {
const [UploadedFonts, setUploadedFonts] = useState<UploadedFont[]>([]);
const [fontsData, setFontsData] = useState<FontData | null>(null);
// Load uploaded fonts dynamically
useEffect(() => {
UploadedFonts.forEach((font) => {
// Check if font style already exists
const existingStyle = document.querySelector(
`style[data-font-url="${font.fontUrl}"]`
);
if (!existingStyle) {
const style = document.createElement("style");
style.setAttribute("data-font-url", font.fontUrl);
// Use the actual font name for font-family
style.textContent = `
@font-face {
font-family: '${font.fontName}';
src: url('${font.fontUrl}') format('truetype');
font-display: swap;
}
`;
document.head.appendChild(style);
}
});
}, [UploadedFonts]);
// Load Google Fonts from fontsData
useEffect(() => {
if (fontsData?.internally_supported_fonts) {
fontsData.internally_supported_fonts.forEach((font) => {
// Check if font link already exists
const existingFont = document.querySelector(
`link[href="${font.google_fonts_url}"]`
);
// Only add if font doesn't already exist
if (!existingFont) {
const link = document.createElement("link");
link.href = font.google_fonts_url;
link.rel = "stylesheet";
document.head.appendChild(link);
}
});
}
}, [fontsData]);
const uploadFont = useCallback(
async (fontName: string, file: File): Promise<string | null> => {
// Check if font is already uploaded
const existingFont = UploadedFonts.find((f) => f.fontName === fontName);
if (existingFont) {
toast.info(`Font "${fontName}" is already uploaded`);
return existingFont.fontUrl;
}
// Validate file type
const validExtensions = [".ttf", ".otf", ".woff", ".woff2", ".eot"];
const fileExtension = file.name
.toLowerCase()
.substring(file.name.lastIndexOf("."));
if (!validExtensions.includes(fileExtension)) {
toast.error(
"Invalid font file type. Please upload .ttf, .otf, .woff, .woff2, or .eot files"
);
return null;
}
// Validate file size (10MB limit)
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
toast.error("Font file size must be less than 10MB");
return null;
}
try {
const formData = new FormData();
formData.append("font_file", file);
const response = await fetch(getApiUrl(`${process.env.NEXT_PUBLIC_FAST_API}/api/v1/ppt/fonts/upload`), {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
const newFont: UploadedFont = {
fontName: data.font_name || fontName,
fontUrl: data.font_url,
fontPath: data.font_path,
};
setUploadedFonts((prev) => [...prev, newFont]);
toast.success(`Font "${fontName}" uploaded successfully`);
return newFont.fontUrl;
} else {
throw new Error(data.message || "Upload failed");
}
} catch (error) {
console.error("Error uploading font:", error);
toast.error(`Failed to upload font "${fontName}"`, {
description:
error instanceof Error
? error.message
: "An unexpected error occurred",
});
return null;
}
},
[UploadedFonts]
);
const removeFont = useCallback((fontUrl: string) => {
setUploadedFonts((prev) => prev.filter((font) => font.fontUrl !== fontUrl));
// Remove the style element for this font
const styleElement = document.querySelector(
`style[data-font-url="${fontUrl}"]`
);
if (styleElement) {
styleElement.remove();
}
toast.info("Font removed globally");
}, []);
const getAllUnsupportedFonts = useCallback((): string[] => {
if (!fontsData?.not_supported_fonts) {
return [];
}
return fontsData.not_supported_fonts;
}, [fontsData]);
return {
UploadedFonts,
fontsData,
setFontsData,
uploadFont,
removeFont,
getAllUnsupportedFonts,
};
};

View file

@ -1,49 +0,0 @@
import { useState } from "react";
import { ProcessedSlide } from "../types";
export const useHtmlEdit = (
slide: ProcessedSlide,
index: number,
onSlideUpdate?: (updatedSlideData: any) => void,
setSlides?: React.Dispatch<React.SetStateAction<ProcessedSlide[]>>
) => {
const [isHtmlEditMode, setIsHtmlEditMode] = useState(false);
const handleHtmlEditClick = () => {
setIsHtmlEditMode(true);
};
const handleHtmlEditCancel = () => {
setIsHtmlEditMode(false);
};
const handleHtmlSave = (html: string) => {
const updatedSlideData = {
slide_number: slide.slide_number,
html: html,
processed: true,
processing: false,
error: undefined,
modified: true,
};
if (onSlideUpdate) {
onSlideUpdate(updatedSlideData);
} else if (setSlides) {
setSlides((prevSlides) =>
prevSlides.map((s, i) =>
i === index ? { ...s, ...updatedSlideData } : s
)
);
}
setIsHtmlEditMode(false);
};
return {
isHtmlEditMode,
handleHtmlEditClick,
handleHtmlEditCancel,
handleHtmlSave,
};
};

View file

@ -1,16 +1,15 @@
import { useState, useCallback } from "react";
import { toast } from "sonner";
import { v4 as uuidv4 } from "uuid";
import { ApiResponseHandler } from "@/app/(presentation-generator)/services/api/api-error-handler";
import { ProcessedSlide, UploadedFont, FontData } from "../types";
import { ProcessedSlide } from "../types";
import { getHeader } from "@/app/(presentation-generator)/services/api/header";
import { getApiUrl } from "@/utils/api";
export const useLayoutSaving = (
slides: ProcessedSlide[],
UploadedFonts: UploadedFont[],
fontsData: FontData | null,
// refetch: () => void,
setSlides: React.Dispatch<React.SetStateAction<ProcessedSlide[]>>
) => {
const [isSavingLayout, setIsSavingLayout] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
@ -23,63 +22,10 @@ export const useLayoutSaving = (
setIsModalOpen(false);
}, []);
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
const convertSlideToReact = async (slide: ProcessedSlide, presentationId: string, FontUrls: string[]) => {
const maxRetries = 3;
let retryCount = 0;
console.log("Slide to convert to react", {
html: slide.html,
image: slide.screenshot_url,
})
while (retryCount < maxRetries) {
try {
const response = await fetch(getApiUrl("/api/v1/ppt/html-to-react/"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
html: slide.html,
image: slide.screenshot_url,
}),
});
const data = await ApiResponseHandler.handleResponse(
response,
`Failed to convert slide ${slide.slide_number} to React`
);
return {
presentation: presentationId,
layout_id: `${slide.slide_number}`,
layout_name: `Slide${slide.slide_number}`,
layout_code: data.react_component || data.component_code,
fonts: FontUrls,
};
} catch (error) {
retryCount++;
console.error(`Error converting slide ${slide.slide_number} (attempt ${retryCount}):`, error);
if (retryCount < maxRetries) {
toast.error(`Failed to convert slide ${slide.slide_number}. Retrying in 2 minutes...`, {
description: `Attempt ${retryCount}/${maxRetries}. Error: ${error instanceof Error ? error.message : "An unexpected error occurred"}`,
});
// Wait for 2 minutes before retrying
await delay(2 * 60 * 1000);
toast.info(`Retrying conversion for slide ${slide.slide_number}...`);
} else {
throw new Error(`Failed to convert slide ${slide.slide_number} after ${maxRetries} attempts: ${error instanceof Error ? error.message : "An unexpected error occurred"}`);
}
}
}
};
const saveLayout = useCallback(async (layoutName: string, description: string): Promise<string | null> => {
const saveLayout = useCallback(async (layoutName: string, description: string, template_info_id: string): Promise<string | null> => {
if (!slides.length) {
toast.error("No slides to save");
return null;
@ -88,70 +34,28 @@ export const useLayoutSaving = (
setIsSavingLayout(true);
try {
// Convert each slide HTML to React component
const reactComponents: any[] = [];
const presentationId = uuidv4();
// Collect uploaded font URLs and Google Fonts CSS URLs
const uploadedFontUrls = UploadedFonts.map((font) => font.fontUrl);
const googleFontCssUrls = fontsData?.internally_supported_fonts?.map(f => f.google_fonts_url).filter(Boolean) || [];
const FontUrls = Array.from(new Set([...(uploadedFontUrls || []), ...googleFontCssUrls]));
for (let i = 0; i < slides.length; i++) {
const slide = slides[i];
if (!slide.html) {
toast.error(`Slide ${slide.slide_number} has no HTML content`);
continue;
}
const reactComponents = slides.map((slide) => ({
layout_id: `${slide.slide_number}`,
layout_name: `Slide${slide.slide_number}`,
layout_code: slide.react,
}));
// Mark current slide as converting to React
setSlides(prev => prev.map((s, idx) => idx === i ? { ...s, convertingToReact: true } : s));
try {
const reactComponent = await convertSlideToReact(slide, presentationId, FontUrls);
reactComponents.push(reactComponent);
// Update progress
toast.success(
`Converted slide ${slide.slide_number} to React component`
);
} catch (error) {
console.error(`Error converting slide ${slide.slide_number}:`, error);
toast.error(`Failed to convert slide ${slide.slide_number} after all retries`, {
description:
error instanceof Error
? error.message
: "An unexpected error occurred",
});
// Continue with other slides even if one fails
} finally {
// Clear converting flag for this slide
setSlides(prev => prev.map((s, idx) => idx === i ? { ...s, convertingToReact: false } : s));
}
}
if (reactComponents.length === 0) {
toast.error("No slides were successfully converted");
return null;
}
// First create/update the template metadata
await fetch(getApiUrl("/api/v1/ppt/template-management/templates"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: presentationId, name: layoutName, description }),
});
// Save the layout components to the app_data/layouts folder
const saveResponse = await fetch(
getApiUrl("/api/v1/ppt/template-management/save-templates"),
getApiUrl(`/api/v1/ppt/template/save`),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: getHeader(),
body: JSON.stringify({
template_info_id: template_info_id,
name: layoutName,
description: description,
layouts: reactComponents,
}),
}
);
@ -160,8 +64,7 @@ export const useLayoutSaving = (
saveResponse,
"Failed to save layout components"
);
if (!data.success) {
if (!data) {
toast.error("Failed to save layout components");
return null;
}
@ -174,9 +77,9 @@ export const useLayoutSaving = (
});
toast.success(`Layout "${layoutName}" saved successfully`);
// refetch();
closeSaveModal();
return presentationId;
return data.id;
} catch (error) {
console.error("Error saving layout:", error);
toast.error("Failed to save layout", {
@ -189,7 +92,7 @@ export const useLayoutSaving = (
} finally {
setIsSavingLayout(false);
}
}, [slides, UploadedFonts, fontsData, closeSaveModal, setSlides]);
}, [slides, closeSaveModal]);
return {
isSavingLayout,

View file

@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from "react";
import html2canvas from "html2canvas";
import { ProcessedSlide } from "../types";
import { getHeader } from "@/app/(presentation-generator)/services/api/header";
import { toast } from "sonner";
import { getApiUrl } from "@/utils/api";
export const useSlideEdit = (
@ -12,140 +13,25 @@ export const useSlideEdit = (
const [isEditMode, setIsEditMode] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [prompt, setPrompt] = useState("");
const [slideHtml, setSlideHtml] = useState("");
const slideContentRef = useRef<HTMLDivElement>(null);
// Set up canvas when entering edit mode
useEffect(() => {
if (isEditMode && slideContentRef.current && slide.html) {
const rect = slideContentRef.current.getBoundingClientRect();
setSlideHtml(slide.html);
}
}, [isEditMode, slide.html]);
// Apply optimizations once after slide content is rendered in edit mode
useEffect(() => {
if (isEditMode && slideContentRef.current && slideHtml) {
const slideContent = slideContentRef.current;
slideContent.style.pointerEvents = "none";
slideContent.style.userSelect = "none";
slideContent.style.transform = "translateZ(0)";
slideContent.style.willChange = "auto";
slideContent.style.backfaceVisibility = "hidden";
const interactiveElements = slideContent.querySelectorAll(
"img, video, iframe, a, button, input, textarea, select"
);
interactiveElements.forEach((element) => {
const el = element as HTMLElement;
el.style.pointerEvents = "none";
el.style.userSelect = "none";
(el.style as any).webkitUserSelect = "none";
(el.style as any).webkitTouchCallout = "none";
(el.style as any).webkitUserDrag = "none";
el.style.transform = "translateZ(0)";
el.style.backfaceVisibility = "hidden";
if (element.tagName === "IMG") {
(element as HTMLImageElement).draggable = false;
}
el.onclick = null;
el.onmousedown = null;
el.onmouseup = null;
el.onmousemove = null;
});
}
}, [isEditMode, slideHtml]);
// Convert data URL to blob for form data
const dataURLToBlob = (dataURL: string): Blob => {
const parts = dataURL.split(",");
const contentType = parts[0].match(/:(.*?);/)?.[1] || "image/png";
const raw = window.atob(parts[1]);
const rawLength = raw.length;
const uInt8Array = new Uint8Array(rawLength);
for (let i = 0; i < rawLength; ++i) {
uInt8Array[i] = raw.charCodeAt(i);
}
return new Blob([uInt8Array], { type: contentType });
};
const handleSave = async (
slideDisplayRef: React.RefObject<HTMLDivElement>,
didYourDraw: boolean
) => {
if (
!slideContentRef.current ||
!slideDisplayRef.current ||
!slide.html
)
return;
const handleSave = async (): Promise<boolean> => {
if (!prompt.trim()) {
alert("Please enter a prompt before saving.");
return;
return false;
}
setIsUpdating(true);
try {
// Take screenshot of the slide display area (slide only)
const slideOnly = await html2canvas(slideDisplayRef.current, {
backgroundColor: "#ffffff",
scale: 1,
logging: false,
useCORS: true,
ignoreElements: (element) => {
return element.tagName === "CANVAS";
},
});
let slideWithCanvas;
if (didYourDraw) {
// Take screenshot of the entire slide display area including canvas
slideWithCanvas = await html2canvas(slideDisplayRef.current, {
backgroundColor: "#ffffff",
scale: 1,
logging: false,
useCORS: true,
});
}
const currentHtml = slide.html;
const currentUiImageBlob = dataURLToBlob(
slideOnly.toDataURL("image/png")
);
let sketchImageBlob;
if (didYourDraw && slideWithCanvas) {
sketchImageBlob = dataURLToBlob(slideWithCanvas.toDataURL("image/png"));
}
const formData = new FormData();
formData.append(
"current_ui_image",
currentUiImageBlob,
`slide-${slide.slide_number}-current.png`
);
if (didYourDraw && slideWithCanvas && sketchImageBlob) {
formData.append(
"sketch_image",
sketchImageBlob,
`slide-${slide.slide_number}-sketch.png`
);
}
formData.append("html", currentHtml);
formData.append("prompt", prompt);
const response = await fetch(getApiUrl("/api/v1/ppt/html-edit/"), {
const response = await fetch(getApiUrl(`/api/v1/ppt/template/slide-layout/edit`), {
method: "POST",
body: formData,
body: JSON.stringify({
prompt: prompt,
react_component: slide.react ?? "",
}),
headers: getHeader(),
});
if (!response.ok) {
@ -153,15 +39,14 @@ export const useSlideEdit = (
}
const data = await response.json();
const updatedSlideData = {
slide_number: slide.slide_number,
html: data.edited_html || currentHtml,
react: data.react_component,
processed: true,
processing: false,
error: undefined,
};
if (onSlideUpdate) {
onSlideUpdate(updatedSlideData);
@ -176,13 +61,14 @@ export const useSlideEdit = (
// Exit edit mode
setIsEditMode(false);
setPrompt("");
return true;
} catch (error) {
console.error("Error updating slide:", error);
alert(
`Error updating slide: ${
error instanceof Error ? error.message : "Unknown error"
toast.error(
`Error updating slide: ${error instanceof Error ? error.message : "Unknown error"
}`
);
return false;
} finally {
setIsUpdating(false);
}
@ -201,8 +87,7 @@ export const useSlideEdit = (
isEditMode,
isUpdating,
prompt,
slideContentRef,
slideHtml,
setPrompt,
handleSave,
handleEditClick,

View file

@ -1,229 +0,0 @@
import { useState, useCallback } from "react";
import { toast } from "sonner";
import { ApiResponseHandler } from "@/app/(presentation-generator)/services/api/api-error-handler";
import { ProcessedSlide, SlideData, FontData } from "../types";
import { getApiUrl } from "@/utils/api";
export const useSlideProcessing = (
selectedFile: File | null,
slides: ProcessedSlide[],
setSlides: React.Dispatch<React.SetStateAction<ProcessedSlide[]>>,
setFontsData: React.Dispatch<React.SetStateAction<FontData | null>>
) => {
const [isProcessingPptx, setIsProcessingPptx] = useState(false);
// Process individual slide to HTML
const processSlideToHtml = useCallback(
async (slide: SlideData, index: number) => {
console.log(
`Starting to process slide ${slide.slide_number} at index ${index}`
);
// Update slide to processing state
setSlides((prev) =>
prev.map((s, i) =>
i === index ? { ...s, processing: true, error: undefined } : s
)
);
try {
const htmlResponse = await fetch(getApiUrl("/api/v1/ppt/slide-to-html/"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image: slide.screenshot_url,
xml: slide.xml_content,
fonts: slide.normalized_fonts ?? [],
}),
});
const htmlData = await ApiResponseHandler.handleResponse(
htmlResponse,
`Failed to convert slide ${slide.slide_number} to HTML`
);
console.log(`Successfully processed slide ${slide.slide_number}`);
// Update slide with success
setSlides((prev) => {
const newSlides = prev.map((s, i) =>
i === index
? {
...s,
processing: false,
processed: true,
html: htmlData.html,
}
: s
);
// Process next slide if available
const nextIndex = index + 1;
if (
nextIndex < newSlides.length &&
!newSlides[nextIndex].processed &&
!newSlides[nextIndex].processing
) {
console.log(
`Scheduling next slide ${nextIndex + 1} for processing`
);
setTimeout(() => {
const nextSlide = newSlides[nextIndex];
processSlideToHtml(nextSlide, nextIndex);
}, 1000); // 1 second delay between slides
}
return newSlides;
});
} catch (error) {
console.error(`Error processing slide ${slide.slide_number}:`, error);
const errorMessage =
error instanceof Error ? error.message : "Failed to convert to HTML";
// Update slide with error
setSlides((prev) => {
const newSlides = prev.map((s, i) =>
i === index
? {
...s,
processing: false,
processed: false,
error: errorMessage,
}
: s
);
// Continue with next slide even if this one failed
const nextIndex = index + 1;
if (
nextIndex < newSlides.length &&
!newSlides[nextIndex].processed &&
!newSlides[nextIndex].processing
) {
console.log(`Scheduling next slide ${nextIndex + 1} after error`);
setTimeout(() => {
const nextSlide = newSlides[nextIndex];
processSlideToHtml(nextSlide, nextIndex);
}, 1000);
}
return newSlides;
});
}
},
[]
);
// Process PDF or PPTX file to extract slides
const processFile = useCallback(async () => {
if (!selectedFile) {
toast.error("Please select a PDF or PPTX file first");
return;
}
try {
setIsProcessingPptx(true);
const formData = new FormData();
const fileName = selectedFile.name.toLowerCase();
const isPdf = fileName.endsWith(".pdf");
const isPptx = fileName.endsWith(".pptx");
let slidesResponseData: any = null;
if (isPdf) {
formData.append("pdf_file", selectedFile);
const pdfResponse = await fetch(getApiUrl("/api/v1/ppt/pdf-slides/process"), {
method: "POST",
body: formData,
});
slidesResponseData = await ApiResponseHandler.handleResponse(
pdfResponse,
"Failed to process PDF file"
);
} else if (isPptx) {
formData.append("pptx_file", selectedFile);
const pptxResponse = await fetch(getApiUrl("/api/v1/ppt/pptx-slides/process"), {
method: "POST",
body: formData,
});
slidesResponseData = await ApiResponseHandler.handleResponse(
pptxResponse,
"Failed to process PPTX file"
);
} else {
throw new Error("Unsupported file type. Please upload a PDF or PPTX file.");
}
if (!slidesResponseData.success || !slidesResponseData.slides?.length) {
throw new Error("No slides found in the uploaded file");
}
// Extract fonts data only for PPTX where available
if (slidesResponseData.fonts) {
setFontsData(slidesResponseData.fonts);
}
// Initialize slides with skeleton state; for PDF, xml/fonts won't exist
const initialSlides: ProcessedSlide[] = slidesResponseData.slides.map(
(slide: any) => ({
slide_number: slide.slide_number,
screenshot_url: slide.screenshot_url,
xml_content: slide.xml_content ?? "",
normalized_fonts: slide.normalized_fonts ?? [],
processing: false,
processed: false,
})
);
setSlides(initialSlides);
const hasUnsupported = Array.isArray(slidesResponseData.fonts?.not_supported_fonts) && slidesResponseData.fonts.not_supported_fonts.length > 0;
toast.success(
`Template Processing Finished`,
{
description: hasUnsupported
? `Please Upload the not supported fonts, and click Extract Template`
: `All fonts are supported. Starting template extraction...`
}
);
// If all fonts are supported, auto-start extraction from the first slide
if (!hasUnsupported && initialSlides.length > 0) {
const firstSlide = initialSlides[0];
setTimeout(() => processSlideToHtml(firstSlide, 0), 300);
}
} catch (error) {
console.error("Error processing file:", error);
const errorMessage =
error instanceof Error ? error.message : "An unexpected error occurred";
toast.error("Processing failed", {
description: errorMessage,
});
} finally {
setIsProcessingPptx(false);
}
}, [selectedFile, processSlideToHtml, setSlides, setFontsData]);
// Retry failed slide
const retrySlide = useCallback(
(index: number) => {
const slide = slides[index];
if (slide) {
processSlideToHtml(slide, index);
}
},
[slides, processSlideToHtml]
);
return {
isProcessingPptx,
processFile,
processSlideToHtml,
retrySlide,
};
};

View file

@ -0,0 +1,200 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { ProcessedSlide } from "../types";
interface SlideHistoryState {
react: string;
timestamp: number;
}
interface UseSlideUndoRedoOptions {
maxHistorySize?: number;
}
// Overload 1: Array-based setSlides (for use with parent state)
export function useSlideUndoRedo(
slide: ProcessedSlide,
setSlides: React.Dispatch<React.SetStateAction<ProcessedSlide[]>>,
slideIndex: number,
options?: UseSlideUndoRedoOptions
): {
undo: () => void;
redo: () => void;
canUndo: boolean;
canRedo: boolean;
clearHistory: () => void;
historyInfo: { pastCount: number; futureCount: number; canUndo: boolean; canRedo: boolean };
};
// Overload 2: Single slide state setter (for local state management)
export function useSlideUndoRedo(
slide: ProcessedSlide,
setSlideState: React.Dispatch<React.SetStateAction<ProcessedSlide>>,
slideIndex: null,
options?: UseSlideUndoRedoOptions
): {
undo: () => void;
redo: () => void;
canUndo: boolean;
canRedo: boolean;
clearHistory: () => void;
historyInfo: { pastCount: number; futureCount: number; canUndo: boolean; canRedo: boolean };
};
// Implementation
export function useSlideUndoRedo(
slide: ProcessedSlide,
setSlides: React.Dispatch<React.SetStateAction<ProcessedSlide[]>> | React.Dispatch<React.SetStateAction<ProcessedSlide>>,
slideIndex: number | null,
options: UseSlideUndoRedoOptions = {}
) {
const { maxHistorySize = 50 } = options;
const [past, setPast] = useState<SlideHistoryState[]>([]);
const [future, setFuture] = useState<SlideHistoryState[]>([]);
const isUndoRedoAction = useRef(false);
const lastReact = useRef<string | undefined>(slide.react);
// Determine if we're in single-slide mode
const isSingleSlideMode = slideIndex === null;
// Track changes to the slide's react content
useEffect(() => {
// Skip if this is an undo/redo action or if slide is processing
if (isUndoRedoAction.current || slide.processing || !slide.processed) {
isUndoRedoAction.current = false;
return;
}
// Skip if react content hasn't changed
if (slide.react === lastReact.current) {
return;
}
// Save current state to past before updating
if (lastReact.current !== undefined) {
setPast(prev => {
const newPast = [
...prev,
{ react: lastReact.current!, timestamp: Date.now() }
];
// Limit history size
if (newPast.length > maxHistorySize) {
return newPast.slice(-maxHistorySize);
}
return newPast;
});
// Clear future when new changes are made
setFuture([]);
}
lastReact.current = slide.react;
}, [slide.react, slide.processing, slide.processed, maxHistorySize]);
// Reset history when slide changes (different slide_number) - only in array mode
useEffect(() => {
if (!isSingleSlideMode) {
setPast([]);
setFuture([]);
lastReact.current = slide.react;
}
}, [slide.slide_number, isSingleSlideMode]);
const canUndo = past.length > 0;
const canRedo = future.length > 0;
const undo = useCallback(() => {
if (!canUndo || !slide.react) return;
const previousState = past[past.length - 1];
// Mark as undo/redo action to prevent history recording
isUndoRedoAction.current = true;
// Save current state to future
setFuture(prev => [
{ react: slide.react!, timestamp: Date.now() },
...prev
]);
// Remove from past
setPast(prev => prev.slice(0, -1));
// Update the slide based on mode
if (isSingleSlideMode) {
// Single slide mode - update directly
(setSlides as React.Dispatch<React.SetStateAction<ProcessedSlide>>)(
prev => ({ ...prev, react: previousState.react })
);
} else {
// Array mode - update at index
(setSlides as React.Dispatch<React.SetStateAction<ProcessedSlide[]>>)(
prevSlides => prevSlides.map((s, i) =>
i === slideIndex ? { ...s, react: previousState.react } : s
)
);
}
lastReact.current = previousState.react;
}, [canUndo, slide.react, past, slideIndex, setSlides, isSingleSlideMode]);
const redo = useCallback(() => {
if (!canRedo) return;
const nextState = future[0];
// Mark as undo/redo action to prevent history recording
isUndoRedoAction.current = true;
// Save current state to past
if (slide.react) {
setPast(prev => [
...prev,
{ react: slide.react!, timestamp: Date.now() }
]);
}
// Remove from future
setFuture(prev => prev.slice(1));
// Update the slide based on mode
if (isSingleSlideMode) {
// Single slide mode - update directly
(setSlides as React.Dispatch<React.SetStateAction<ProcessedSlide>>)(
prev => ({ ...prev, react: nextState.react })
);
} else {
// Array mode - update at index
(setSlides as React.Dispatch<React.SetStateAction<ProcessedSlide[]>>)(
prevSlides => prevSlides.map((s, i) =>
i === slideIndex ? { ...s, react: nextState.react } : s
)
);
}
lastReact.current = nextState.react;
}, [canRedo, slide.react, future, slideIndex, setSlides, isSingleSlideMode]);
// Clear history
const clearHistory = useCallback(() => {
setPast([]);
setFuture([]);
}, []);
// Get history info
const historyInfo = {
pastCount: past.length,
futureCount: future.length,
canUndo,
canRedo,
};
return {
undo,
redo,
canUndo,
canRedo,
clearHistory,
historyInfo,
};
}

View file

@ -0,0 +1,402 @@
import { useState, useCallback } from "react";
import { toast } from "sonner";
import { getHeader, getHeaderForFormData } from "@/app/(presentation-generator)/services/api/header";
import { ApiResponseHandler } from "@/app/(presentation-generator)/services/api/api-error-handler";
import {
TemplateCreationStep,
TemplateCreationState,
FontData,
FontUploadPreviewResponse,
SlideLayoutResponse,
UploadedFont,
ProcessedSlide,
} from "../types";
import { getApiUrl } from "@/utils/api";
const initialState: TemplateCreationState = {
step: 'file-upload',
isLoading: false,
error: null,
fontsData: null,
previewData: null,
templateId: null,
totalSlides: 0,
slideLayouts: [],
currentSlideIndex: 0,
};
export const useTemplateCreation = () => {
const [state, setState] = useState<TemplateCreationState>(initialState);
const [uploadedFonts, setUploadedFonts] = useState<UploadedFont[]>([]);
const [slides, setSlides] = useState<ProcessedSlide[]>([]);
// Helper to update state partially
const updateState = useCallback((updates: Partial<TemplateCreationState>) => {
setState(prev => ({ ...prev, ...updates }));
}, []);
// Reset to initial state
const reset = useCallback(() => {
setState(initialState);
setUploadedFonts([]);
setSlides([]);
}, []);
// Step 1: Check fonts in PPTX file
const checkFonts = useCallback(async (pptxFile: File): Promise<FontData | null> => {
updateState({ isLoading: true, error: null });
try {
const formData = new FormData();
formData.append("pptx_file", pptxFile);
const response = await fetch(getApiUrl(`/api/v1/ppt/fonts/check`), {
method: "POST",
headers: getHeaderForFormData(),
body: formData,
});
const data = await ApiResponseHandler.handleResponse(
response,
"Failed to check fonts in the presentation"
);
updateState({
fontsData: data,
step: 'font-check',
isLoading: false
});
return data;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Font check failed";
updateState({ error: errorMessage, isLoading: false });
toast.error("Font Check Failed", { description: errorMessage });
return null;
}
}, [updateState]);
const uploadFont = useCallback((fontName: string, file: File): string | null => {
// Check if font is already added
const existingFont = uploadedFonts.find((f) => f.fontName === fontName);
if (existingFont) {
toast.info(`Font "${fontName}" is already added`);
return fontName;
}
// Validate file type
const validExtensions = [".ttf", ".otf", ".woff", ".woff2", ".eot"];
const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf("."));
if (!validExtensions.includes(fileExtension)) {
toast.error("Invalid font file type. Please upload .ttf, .otf, .woff, .woff2, or .eot files");
return null;
}
// Validate file size (10MB limit)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
toast.error("Font file size must be less than 10MB");
return null;
}
// Store font locally
const newFont: UploadedFont = {
fontName: fontName,
fontUrl: '', // Will be set after upload
fontPath: '',
file: file,
};
setUploadedFonts(prev => [...prev, newFont]);
toast.success(`Font "${fontName}" added`);
return fontName;
}, [uploadedFonts]);
// Remove a font
const removeFont = useCallback((fontName: string) => {
setUploadedFonts(prev => prev.filter(font => font.fontName !== fontName));
toast.info("Font removed");
}, []);
// Get all unsupported fonts that need upload
const getUnsupportedFonts = useCallback((): string[] => {
if (!state.fontsData?.unavailable_fonts) {
return [];
}
return state.fontsData.unavailable_fonts
.map(font => font.name)
.filter(fontName => !uploadedFonts.some(uploaded => uploaded.fontName === fontName));
}, [state.fontsData, uploadedFonts]);
// Check if all required fonts are uploaded
const allFontsUploaded = useCallback((): boolean => {
return getUnsupportedFonts().length === 0;
}, [getUnsupportedFonts]);
// Step 2: Upload fonts and get slide preview
const fontUploadAndPreview = useCallback(async (
pptxFile: File
): Promise<FontUploadPreviewResponse | null> => {
updateState({ isLoading: true, error: null, step: 'font-upload' });
try {
const formData = new FormData();
formData.append("pptx_file", pptxFile);
// Add uploaded font files (actual File objects)
uploadedFonts.forEach(font => {
formData.append("font_files", font.file);
formData.append("original_font_names", font.fontName);
});
const response = await fetch(
getApiUrl(`/api/v1/ppt/template/fonts-upload-and-slides-preview`),
{
method: "POST",
headers: getHeaderForFormData(),
body: formData,
}
);
const data = await ApiResponseHandler.handleResponse(
response,
"Failed to upload fonts and preview slides"
);
updateState({
previewData: data,
step: 'slides-preview',
isLoading: false
});
toast.success("Slides preview generated successfully");
return data;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Preview generation failed";
updateState({ error: errorMessage, isLoading: false });
toast.error("Preview Failed", { description: errorMessage });
return null;
}
}, [uploadedFonts, updateState]);
// Step 3: Initialize template creation
const initTemplateCreation = useCallback(async (): Promise<string | null> => {
if (!state.previewData) {
toast.error("No preview data available");
return null;
}
updateState({ isLoading: true, error: null, step: 'template-creation' });
try {
const response = await fetch(getApiUrl(`/api/v1/ppt/template/create/init`), {
method: "POST",
headers: getHeader(),
body: JSON.stringify({
pptx_url: state.previewData.modified_pptx_url,
slide_image_urls: state.previewData.slide_image_urls,
fonts: state.previewData.fonts,
}),
});
const data = await ApiResponseHandler.handleResponse(
response,
"Failed to initialize template creation"
);
// Initialize slides array based on preview images
const initialSlides: ProcessedSlide[] = state.previewData.slide_image_urls.map(
(url, index) => ({
slide_number: index + 1,
screenshot_url: url,
processing: false,
processed: false,
})
);
setSlides(initialSlides);
updateState({
templateId: data.id || data,
totalSlides: state.previewData.slide_image_urls.length,
isLoading: false
});
toast.success("Template creation initialized");
// Automatically start processing the first slide
if (typeof data === 'string') {
createSlideLayout(data, 0);
} else if (data.id) {
createSlideLayout(data.id, 0);
}
return typeof data === 'string' ? data : data.id;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Initialization failed";
updateState({ error: errorMessage, isLoading: false });
toast.error("Initialization Failed", { description: errorMessage });
// reset the state
reset();
return null;
}
}, [state.previewData, updateState]);
// Step 4: Create slide layout for a specific slide (with auto-advance for initial processing)
const createSlideLayout = useCallback(async (
templateId: string,
slideIndex: number,
autoAdvance: boolean = true,
retry: boolean = false,
_isAutoRetry: boolean = false
): Promise<SlideLayoutResponse | null> => {
// Mark slide as processing
setSlides(prev => prev.map((s, i) =>
i === slideIndex ? { ...s, processing: true, error: undefined } : s
));
updateState({ currentSlideIndex: slideIndex });
try {
const response = await fetch(getApiUrl(`/api/v1/ppt/template/slide-layout/create?is_reconstruct=${retry}`), {
method: "POST",
headers: getHeader(),
body: JSON.stringify({
id: templateId,
index: slideIndex,
}),
});
const data = await ApiResponseHandler.handleResponse(
response,
`Failed to create layout for slide ${slideIndex + 1}`
);
// Update slide with the react component
setSlides(prev => {
const newSlides = prev.map((s, i) =>
i === slideIndex ? {
...s,
processing: false,
processed: true,
react: data.react_component,
layout_id: data.layout_id,
layout_name: data.layout_name,
layout_description: data.layout_description,
} : s
);
// Only auto-advance during initial processing
if (autoAdvance) {
const nextIndex = slideIndex + 1;
if (nextIndex < newSlides.length && !newSlides[nextIndex].processed) {
setTimeout(() => {
createSlideLayout(templateId, nextIndex, true);
}, 500);
} else {
// Check if all slides are processed
const allProcessed = newSlides.every(s => s.processed || s.error);
if (allProcessed) {
updateState({ step: 'completed' });
toast.success("All slides processed successfully!");
}
}
} else {
// Single slide reconstruction - just show success
toast.success(`Slide ${slideIndex + 1} reconstructed successfully`);
}
return newSlides;
});
return data;
} catch (error) {
// Auto-retry once on failure before showing error
if (!_isAutoRetry) {
console.log(`Auto-retrying slide ${slideIndex + 1} after API failure...`);
return createSlideLayout(templateId, slideIndex, autoAdvance, true, true);
}
const errorMessage = error instanceof Error ? error.message : "Layout creation failed";
// Mark slide with error
setSlides(prev => {
const newSlides = prev.map((s, i) =>
i === slideIndex ? { ...s, processing: false, error: errorMessage } : s
);
// Only auto-advance during initial processing
if (autoAdvance) {
const nextIndex = slideIndex + 1;
if (nextIndex < newSlides.length && !newSlides[nextIndex].processed) {
setTimeout(() => {
createSlideLayout(templateId, nextIndex, true);
}, 500);
} else {
const allProcessed = newSlides.every(s => s.processed || s.error);
if (allProcessed) {
updateState({ step: 'completed' });
}
}
}
return newSlides;
});
toast.error(`Slide ${slideIndex + 1} Failed`, { description: errorMessage });
return null;
}
}, [updateState]);
// Reconstruct a single slide (no auto-advance)
const retrySlide = useCallback((slideIndex: number) => {
if (state.templateId) {
// Pass false for autoAdvance to only reconstruct this specific slide
createSlideLayout(state.templateId, slideIndex, false, true);
}
}, [state.templateId, createSlideLayout]);
// Move to font upload step (when font check is done)
const proceedToFontUpload = useCallback(() => {
updateState({ step: 'font-upload' });
}, [updateState]);
// Calculate progress
const completedSlides = slides.filter(s => s.processed || s.error).length;
const progressPercentage = state.totalSlides > 0
? Math.round((completedSlides / state.totalSlides) * 100)
: 0;
return {
// State
state,
uploadedFonts,
slides,
setSlides,
// Progress
completedSlides,
progressPercentage,
// Font operations
checkFonts,
uploadFont,
removeFont,
getUnsupportedFonts,
allFontsUploaded,
// Template creation operations
fontUploadAndPreview,
initTemplateCreation,
createSlideLayout,
retrySlide,
// Navigation
proceedToFontUpload,
reset,
updateState,
};
};

View file

@ -1,182 +1,16 @@
"use client";
import React, { useEffect } from "react";
import FontManager from "./components/FontManager";
import Header from "../(dashboard)/dashboard/components/Header";
import { useCustomLayout } from "./hooks/useCustomLayout";
import { useFontManagement } from "./hooks/useFontManagement";
import { useFileUpload } from "./hooks/useFileUpload";
import { useSlideProcessing } from "./hooks/useSlideProcessing";
import { useLayoutSaving } from "./hooks/useLayoutSaving";
import { useAPIKeyCheck } from "./hooks/useAPIKeyCheck";
import { useRouter, usePathname } from "next/navigation";
import { LoadingSpinner } from "./components/LoadingSpinner";
import { FileUploadSection } from "./components/FileUploadSection";
import { SaveLayoutButton } from "./components/SaveLayoutButton";
import { SaveLayoutModal } from "./components/SaveLayoutModal";
import EachSlide from "./components/EachSlide/NewEachSlide";
import { APIKeyWarning } from "./components/APIKeyWarning";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
const CustomTemplatePage = () => {
const router = useRouter();
const pathname = usePathname();
import React from 'react'
import CustomTemplatePage from './CustomTemplatePage'
// Custom hooks for different concerns
const { hasRequiredKey, isRequiredKeyLoading } = useAPIKeyCheck();
const { selectedFile, handleFileSelect, removeFile } = useFileUpload();
const { slides, setSlides, completedSlides } = useCustomLayout();
const { fontsData, UploadedFonts, uploadFont, removeFont, getAllUnsupportedFonts, setFontsData } = useFontManagement();
const { isProcessingPptx, processFile, retrySlide, processSlideToHtml } = useSlideProcessing(
selectedFile,
slides,
setSlides,
setFontsData
);
const { isSavingLayout, isModalOpen, openSaveModal, closeSaveModal, saveLayout } = useLayoutSaving(
slides,
UploadedFonts,
fontsData,
setSlides
);
const page = () => {
const handleSaveTemplate = async (layoutName: string, description: string): Promise<string | null> => {
trackEvent(MixpanelEvent.CustomTemplate_Save_Templates_API_Call);
const id = await saveLayout(layoutName, description);
if (id) {
router.push(`/template-preview?slug=custom-${id}`);
}
return id;
};
const handleProcessSlideToHtml = (slide: any) => {
processSlideToHtml(slide, 0)
}
// Handle slide updates
const handleSlideUpdate = (index: number, updatedSlideData: any) => {
setSlides((prevSlides) =>
prevSlides.map((s, i) =>
i === index
? {
...s,
...updatedSlideData,
modified: true,
}
: s
)
);
};
useEffect(() => {
const existingScript = document.querySelector(
'script[src*="tailwindcss.com"]'
);
if (!existingScript) {
const script = document.createElement("script");
script.src = "https://cdn.tailwindcss.com";
script.async = true;
document.head.appendChild(script);
}
}, []);
// Loading state
if (isRequiredKeyLoading) {
return <LoadingSpinner message="Checking API Key..." />;
}
// Anthropic key warning
if (!hasRequiredKey) {
return <APIKeyWarning />;
}
return (
<div className="min-h-screen bg-linear-to-br from-slate-50 to-slate-100">
<Header />
<div className="max-w-[1440px] aspect-video mx-auto px-6">
{/* Header */}
<div className="text-center space-y-2 my-6">
<h1 className="text-4xl font-bold text-gray-900">
Custom Template Processor
</h1>
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
Upload your PDF or PPTX file to extract slides and convert them to
a template which you can use to generate AI presentations.
</p>
<div className="max-w-2xl mx-auto mt-2">
<div className="inline-block rounded border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
AI template generation can take around 5 minutes per slide.
</div>
</div>
</div>
{/* File Upload Section */}
<FileUploadSection
selectedFile={selectedFile}
handleFileSelect={handleFileSelect}
removeFile={removeFile}
processFile={processFile}
isProcessingPptx={isProcessingPptx}
slides={slides}
completedSlides={completedSlides}
/>
{/* Global Font Management */}
{fontsData && (
<FontManager
fontsData={fontsData}
UploadedFonts={UploadedFonts}
uploadFont={uploadFont}
removeFont={removeFont}
getAllUnsupportedFonts={getAllUnsupportedFonts}
processSlideToHtml={() => handleProcessSlideToHtml(slides[0])}
/>
)}
{/* Slides Section */}
{slides.length > 0 && (
<div className="space-y-6 mt-10">
{slides.map((slide, index) => (
<EachSlide
key={index}
slide={slide}
index={index}
isProcessing={slides.some((s) => s.processing)}
retrySlide={retrySlide}
setSlides={setSlides}
onSlideUpdate={(updatedSlideData) =>
handleSlideUpdate(index, updatedSlideData)
}
/>
))}
</div>
)}
{/* Floating Save Template Button */}
{slides.length > 0 && slides.some((s) => s.processed) && (
<SaveLayoutButton
onSave={openSaveModal}
isSaving={isSavingLayout}
isProcessing={slides.some((s) => s.processing)}
/>
)}
{/* Save Template Modal */}
<SaveLayoutModal
isOpen={isModalOpen}
onClose={closeSaveModal}
onSave={handleSaveTemplate}
isSaving={isSavingLayout}
/>
</div>
</div>
);
};
export default CustomTemplatePage;
return (
<>
<CustomTemplatePage />
</>
)
}
export default page

View file

@ -1,35 +1,105 @@
import type React from "react";
// Types for Custom Layout functionality
// ================== Core Types ==================
export interface SlideData {
slide_number: number;
screenshot_url: string;
xml_content?: string;
normalized_fonts?: string[];
markdown_content?: string;
}
export interface UploadedFont {
fontName: string;
fontUrl: string;
fontPath: string;
file: File; // Original file for re-upload
}
export interface FontItem {
name: string;
url: string | null;
}
export interface FontData {
available_fonts: FontItem[];
unavailable_fonts: FontItem[];
}
// ================== Template Creation Flow Types ==================
export type TemplateCreationStep =
| 'file-upload'
| 'font-check'
| 'font-upload'
| 'slides-preview'
| 'template-creation'
| 'completed';
export interface FontUploadPreviewResponse {
slide_image_urls: string[];
original_pptx_url: string;
modified_pptx_url: string;
fonts: {
[key: string]: string;
};
}
export interface FontInfo {
name: string;
url?: string;
path?: string;
}
export interface TemplateCreationInitResponse {
id: string;
total_slides: number;
}
export interface SlideLayoutResponse {
slide_index: number;
react_component: string;
layout_id: string;
layout_name: string;
layout_description?: string;
}
export interface TemplateCreationState {
step: TemplateCreationStep;
isLoading: boolean;
error: string | null;
// Font check data
fontsData: FontData | null;
// Font upload & preview data
previewData: FontUploadPreviewResponse | null;
// Template creation data
templateId: string | null;
totalSlides: number;
// Slide layouts
slideLayouts: SlideLayoutResponse[];
currentSlideIndex: number;
}
// ================== Processed Slide Types ==================
export interface ProcessedSlide extends SlideData {
html?: string;
react?: string;
uploaded_fonts?: string[];
processing?: boolean;
processed?: boolean;
error?: string;
modified?: boolean;
convertingToReact?: boolean; // indicates HTML-to-React conversion in progress
modified?: boolean;
layout_id?: string;
layout_name?: string;
layout_description?: string;
}
export interface FontData {
internally_supported_fonts: {
name: string;
google_fonts_url: string;
}[];
not_supported_fonts: string[];
}
// ================== Component Props Types ==================
export interface EachSlideProps {
slide: ProcessedSlide;
@ -38,6 +108,31 @@ export interface EachSlideProps {
setSlides: React.Dispatch<React.SetStateAction<ProcessedSlide[]>>;
onSlideUpdate?: (updatedSlideData: any) => void;
isProcessing: boolean;
onOpenSchemaEditor?: (index: number | null) => void;
isSchemaEditorOpen?: boolean;
schemaPreviewData?: Record<string, any> | null; // Preview data from schema editor AI fill
onClearSchemaPreview?: () => void; // Callback to clear schema preview data in parent
}
export interface FontManagerProps {
fontsData: FontData;
uploadedFonts: UploadedFont[];
uploadFont: (fontName: string, file: File) => string | null;
removeFont: (fontName: string) => void;
onContinue: () => void;
isUploading?: boolean;
}
export interface SlidePreviewSectionProps {
previewData: FontUploadPreviewResponse;
onInitTemplate: () => void;
isLoading: boolean;
}
export interface TemplateCreationProgressProps {
currentStep: TemplateCreationStep;
totalSlides: number;
processedSlides: number;
}
export interface DrawingCanvasProps {
@ -54,59 +149,4 @@ export interface DrawingCanvasProps {
onClearCanvas: () => void;
}
export interface EditControlsProps {
isEditMode: boolean;
prompt: string;
isUpdating: boolean;
strokeWidth: number;
strokeColor: string;
eraserMode: boolean;
onPromptChange: (value: string) => void;
onSave: () => void;
onCancel: () => void;
onStrokeWidthChange: (width: number) => void;
onStrokeColorChange: (color: string) => void;
onEraserModeChange: (isEraser: boolean) => void;
onClearCanvas: () => void;
}
export interface SlideActionsProps {
slide: ProcessedSlide;
index: number;
isProcessing: boolean;
isEditMode: boolean;
isHtmlEditMode: boolean;
onEditClick: () => void;
onHtmlEditClick: () => void;
onRetry: () => void;
onDelete: () => void;
}
export interface SlideContentDisplayProps {
slide: ProcessedSlide;
isEditMode: boolean;
isHtmlEditMode: boolean;
slideContentRef: React.RefObject<HTMLDivElement>;
slideDisplayRef: React.RefObject<HTMLDivElement>;
canvasRef: React.RefObject<HTMLCanvasElement>;
canvasDimensions: { width: number; height: number };
strokeWidth: number;
strokeColor: string;
eraserMode: boolean;
isDrawing: boolean;
didYourDraw: boolean;
onMouseDown: (e: React.MouseEvent<HTMLCanvasElement>) => void;
onMouseMove: (e: React.MouseEvent<HTMLCanvasElement>) => void;
onMouseUp: (e: React.MouseEvent<HTMLCanvasElement>) => void;
onTouchStart: (e: React.TouchEvent<HTMLCanvasElement>) => void;
onTouchMove: (e: React.TouchEvent<HTMLCanvasElement>) => void;
onTouchEnd: (e: React.TouchEvent<HTMLCanvasElement>) => void;
retrySlide: (slideNumber: number) => void;
}
export interface HtmlEditorProps {
slide: ProcessedSlide;
isHtmlEditMode: boolean;
onSave: (html: string) => void;
onCancel: () => void;
}

View file

@ -18,7 +18,7 @@ export const CustomTemplateCard = memo(function CustomTemplateCard({
onSelectTemplate: (template: string) => void;
selectedTemplate: string | null;
}) {
const { previewLayouts, loading, totalLayouts } = useCustomTemplatePreview(template.id);
const { previewLayouts, loading } = useCustomTemplatePreview(template.id);
const isSelected = selectedTemplate === template.id;
return (
@ -32,7 +32,7 @@ export const CustomTemplateCard = memo(function CustomTemplateCard({
onClick={() => onSelectTemplate(template.id)}
>
<TemplatePreviewStage>
<LayoutsBadge count={totalLayouts} />
<LayoutsBadge count={template.layoutCount} />
<CustomTemplatePreview
previewLayouts={previewLayouts}
loading={loading}

View file

@ -1,11 +1,24 @@
import { ApiResponseHandler } from "./api-error-handler";
import { getApiUrl } from "@/utils/api";
import { ApiResponseHandler } from "./api-error-handler";
import { getHeader } from "./header";
export interface CloneTemplatePayload {
id: string;
name?: string;
description?: string;
}
export interface CloneLayoutPayload {
template_id: string;
layout_id: string;
layout_name?: string;
}
class TemplateService {
static async getCustomTemplateSummaries() {
try {
const response = await fetch(getApiUrl(`/api/v1/ppt/template-management/summary`),);
const response = await fetch(getApiUrl(`/api/v1/ppt/template/all`),);
return await ApiResponseHandler.handleResponse(response, "Failed to get custom template summaries");
} catch (error) {
console.error("Failed to get custom template summaries", error);
@ -15,7 +28,7 @@ class TemplateService {
static async getCustomTemplateDetails(templateId: string) {
try {
const response = await fetch(getApiUrl(`/api/v1/ppt/template-management/get-templates/${templateId}`),);
const response = await fetch(getApiUrl(`/api/v1/ppt/template/${templateId}/layouts`),);
return await ApiResponseHandler.handleResponse(response, "Failed to get custom template details");
} catch (error) {
console.error("Failed to get custom template details", error);
@ -25,13 +38,41 @@ class TemplateService {
static async deleteCustomTemplate(presentationId: string) {
try {
const response = await fetch(getApiUrl(`/api/v1/ppt/template-management/delete-templates/${presentationId}`), { method: "DELETE" });
const response = await fetch(getApiUrl(`/api/v1/ppt/template-management/delete-templates/${presentationId}`), { method: "DELETE", headers: getHeader() });
return await ApiResponseHandler.handleResponseWithResult(response, "Failed to delete custom template");
} catch (error) {
console.error("Failed to delete custom template", error);
throw error;
}
}
static async cloneCustomTemplate(payload: CloneTemplatePayload) {
try {
const response = await fetch(getApiUrl(`/api/v1/ppt/template/clone`), {
method: "POST",
headers: getHeader(),
body: JSON.stringify(payload),
});
return await ApiResponseHandler.handleResponse(response, "Failed to clone template");
} catch (error) {
console.error("Failed to clone template", error);
throw error;
}
}
static async cloneTemplateLayout(payload: CloneLayoutPayload) {
try {
const response = await fetch(getApiUrl(`/api/v1/ppt/template/slide-layout/clone`), {
method: "POST",
headers: getHeader(),
body: JSON.stringify(payload),
});
return await ApiResponseHandler.handleResponse(response, "Failed to clone layout");
} catch (error) {
console.error("Failed to clone layout", error);
throw error;
}
}
}
export default TemplateService;

View file

@ -5,6 +5,9 @@ import { useState, useEffect, useCallback } from "react";
import { compileCustomLayout, CompiledLayout } from "./compileLayout";
import TemplateService from "../(presentation-generator)/services/api/template";
/**
* API response types
*/
export interface TemplateSummary {
@ -216,27 +219,17 @@ export function useCustomTemplateSummaries() {
setLoading(true);
setError(null);
const data = await TemplateService.getCustomTemplateSummaries();
// const mappedTemplates: CustomTemplates[] = data.filter(item => item.total_layouts && item.total_layouts > 0).map((item) => {
const data: TemplateSummary[] = await TemplateService.getCustomTemplateSummaries();
const mappedTemplates: CustomTemplates[] = data.filter(item => item.total_layouts && item.total_layouts > 0).map((item) => {
// return {
// id: item.id,
// name: item.name || "Custom Template",
// layoutCount: item.total_layouts,
// isCustom: true as const,
// }
// });
const mappedTemplates: CustomTemplates[] = data.presentations.map((item: any) => {
return {
id: item.template.id,
name: item.template.name || "Custom Template",
layoutCount: 0,
id: item.id,
name: item.name || "Custom Template",
layoutCount: item.total_layouts,
isCustom: true as const,
}
});
setTemplates(mappedTemplates);
} catch (err) {
console.error("Error fetching custom templates:", err);
@ -389,7 +382,6 @@ export function useCustomTemplateDetails(templateDetail: { id: string, name: str
export function useCustomTemplatePreview(presentationId: string) {
const [previewLayouts, setPreviewLayouts] = useState<CompiledLayout[]>([]);
const [loading, setLoading] = useState(true);
const [totalLayouts, setTotalLayouts] = useState(0);
@ -400,10 +392,10 @@ export function useCustomTemplatePreview(presentationId: string) {
try {
setLoading(true);
const data = await TemplateService.getCustomTemplateDetails(presentationId);
setTotalLayouts(data.layouts.length);
// Compile first 4 layouts for preview
const compiled: CompiledLayout[] = [];
const layoutsToPreview = data.layouts.slice(0, 2);
const layoutsToPreview = data.layouts.slice(0, 4);
for (const layout of layoutsToPreview) {
try {
@ -427,7 +419,7 @@ export function useCustomTemplatePreview(presentationId: string) {
fetchPreviews();
}, [presentationId]);
return { previewLayouts, loading: loading, totalLayouts: totalLayouts };
return { previewLayouts, loading: loading };
}
/**

View file

@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@babel/standalone": "^7.28.2",
"@babel/traverse": "^7.29.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@ -119,12 +120,12 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@ -132,33 +133,56 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
"dev": true,
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.0"
"@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
@ -185,15 +209,46 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0",
"debug": "^4.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.28.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz",
"integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==",
"dev": true,
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1"
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@ -6623,6 +6678,18 @@
"integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
"license": "MIT"
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
},
"engines": {
"node": ">=6"
}
},
"node_modules/json-parse-even-better-errors": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",

View file

@ -11,6 +11,7 @@
},
"dependencies": {
"@babel/standalone": "^7.28.2",
"@babel/traverse": "^7.29.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",