Merge pull request #503 from presenton/refactor/custom_template
refactor/custom template
This commit is contained in:
commit
9903df99d6
47 changed files with 4533 additions and 2157 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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.",
|
||||
},
|
||||
]
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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]);
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
103
electron/servers/nextjs/package-lock.json
generated
103
electron/servers/nextjs/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue