feat(Nextjs): Custom Layout Html edit and layout title & descritpion modal added

This commit is contained in:
shiva raj badu 2025-08-06 00:21:15 +05:45
parent d9cbf3ea5a
commit 146338f77e
No known key found for this signature in database
23 changed files with 2203 additions and 697 deletions

View file

@ -0,0 +1,20 @@
import React from "react";
import Header from "@/components/Header";
export const AnthropicKeyWarning: 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 put Anthropic Key To Process The Layout
</h1>
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
It Only works on Anthropic(Claude-4).
</p>
</div>
</div>
</div>
);
};

View file

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

View file

@ -0,0 +1,99 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Save, X, Eye, Code } from "lucide-react";
import { ProcessedSlide } from "../../types";
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 || "");
const [isPreviewMode, setIsPreviewMode] = useState(false);
if (!isHtmlEditMode) return null;
const handleSave = () => {
onSave(htmlContent);
};
const handleCancel = () => {
setHtmlContent(slide.html || "");
onCancel();
};
return (
<Card className="border-2 border-purple-200 bg-purple-50">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Code className="w-5 h-5 text-purple-600" />
<span className="text-purple-800">HTML Editor</span>
</div>
<div className="flex gap-2">
<Button
variant={isPreviewMode ? "default" : "outline"}
size="sm"
onClick={() => setIsPreviewMode(!isPreviewMode)}
className="flex items-center gap-1"
>
<Eye size={14} />
{isPreviewMode ? "Code" : "Preview"}
</Button>
<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"
>
<Save size={14} />
Save HTML
</Button>
</div>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{isPreviewMode ? (
<div className="border rounded-lg p-4 bg-white">
<div
className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
</div>
) : (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">
Edit HTML Content:
</label>
<Textarea
value={htmlContent}
onChange={(e) => setHtmlContent(e.target.value)}
className="font-mono text-sm h-96 resize-none"
placeholder="Enter HTML content here..."
/>
<div className="text-xs text-gray-500">
Tip: You can edit the HTML directly. Make sure to maintain proper HTML structure.
</div>
</div>
)}
</CardContent>
</Card>
);
};

View file

@ -0,0 +1,192 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useDrawingCanvas } from "../../hooks/useDrawingCanvas";
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";
const EachSlide: React.FC<EachSlideProps> = ({
slide,
index,
retrySlide,
setSlides,
onSlideUpdate,
isProcessing,
}) => {
// Custom hooks
const {
canvasRef,
slideDisplayRef,
strokeWidth,
strokeColor,
eraserMode,
isDrawing,
canvasDimensions,
setCanvasDimensions,
didYourDraw,
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
handleClearCanvas,
handleEraserModeChange,
handleStrokeColorChange,
handleStrokeWidthChange,
} = useDrawingCanvas();
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);
};
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
slide={slide}
index={index}
isProcessing={isProcessing}
isEditMode={isEditMode}
isHtmlEditMode={isHtmlEditMode}
onEditClick={handleEditClick}
onHtmlEditClick={handleHtmlEditClick}
onRetry={handleRetrySlide}
onDelete={handleDeleteSlide}
/>
</CardTitle>
</CardHeader>
<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}
onTouchEnd={handleTouchEnd}
/>
</CardContent>
{/* Action Buttons */}
<div className="p-4 pt-0 flex gap-2">
<Button
onClick={() => {
const newWindow = window.open("", "_blank");
if (newWindow) {
newWindow.document.write(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Slide ${slide.slide_number} - HTML Preview</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div class="slide-container">
${slide.html}
</div>
</body>
</html>`);
}
}}
variant="outline"
size="sm"
>
Open in new tab
</Button>
</div>
</Card>
);
};
export default EachSlide;

View file

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

View file

@ -0,0 +1,122 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import SlideContent from "../SlideContent";
import { SlideContentDisplayProps } from "../../types";
export const SlideContentDisplay: React.FC<SlideContentDisplayProps> = ({
slide,
isEditMode,
isHtmlEditMode,
slideContentRef,
slideDisplayRef,
canvasRef,
canvasDimensions,
strokeWidth,
strokeColor,
eraserMode,
isDrawing,
didYourDraw,
onMouseDown,
onMouseMove,
onMouseUp,
onTouchStart,
onTouchMove,
onTouchEnd,
}) => {
// Don't show slide content when in HTML edit mode
if (isHtmlEditMode) {
return null;
}
if (slide.processing) {
return (
<div className="space-y-4">
<p className="text-base text-blue-600 font-medium">
🔄 Converting to HTML...
</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="h-64 bg-gray-200 rounded"></div>
</div>
</div>
);
}
if (slide.processed && slide.html) {
return (
<div className="relative">
<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()}
/>
)}
</div>
</div>
);
}
if (slide.error) {
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>
);
}
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>
</div>
);
};

View file

@ -0,0 +1,115 @@
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 { ProcessedSlide } from "../types";
interface FileUploadSectionProps {
selectedFile: File | null;
handleFileSelect: (event: React.ChangeEvent<HTMLInputElement>) => void;
removeFile: () => void;
processFile: () => void;
isProcessingPptx: boolean;
slides: ProcessedSlide[];
completedSlides: number;
}
export const FileUploadSection: React.FC<FileUploadSectionProps> = ({
selectedFile,
handleFileSelect,
removeFile,
processFile,
isProcessingPptx,
slides,
completedSlides,
}) => {
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="w-5 h-5" />
Upload PPTX File
</CardTitle>
<CardDescription>
Select a PowerPoint file (.pptx) to process. Maximum file size: 50MB
</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>
)}
</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 PPTX file
</span>
<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"
/>
</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>
<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="flex gap-3">
<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 PPTX file"
: "Process File"}
</Button>
</div>
</CardContent>
</Card>
);
};

View file

@ -0,0 +1,21 @@
import React from "react";
import { Loader2 } from "lucide-react";
import Header from "@/components/Header";
interface LoadingSpinnerProps {
message: string;
}
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({ message }) => {
return (
<div className="min-h-screen 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-6 rounded-lg shadow-md">
<Loader2 className="w-6 h-6 animate-spin text-blue-600 mx-auto" />
<p>{message}</p>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,39 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { FileText, Loader2 } from "lucide-react";
interface SaveLayoutButtonProps {
onSave: () => void;
isSaving: boolean;
isProcessing: boolean;
}
export const SaveLayoutButton: React.FC<SaveLayoutButtonProps> = ({
onSave,
isSaving,
isProcessing,
}) => {
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50">
<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"
size="lg"
>
{isSaving ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Saving Layout...
</>
) : (
<>
<FileText className="w-5 h-5 mr-2" />
Save Layout
</>
)}
</Button>
</div>
);
};

View file

@ -0,0 +1,120 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loader2, Save } from "lucide-react";
interface SaveLayoutModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (layoutName: string, description: string) => void;
isSaving: boolean;
}
export const SaveLayoutModal: React.FC<SaveLayoutModalProps> = ({
isOpen,
onClose,
onSave,
isSaving,
}) => {
const [layoutName, setLayoutName] = useState("");
const [description, setDescription] = useState("");
const handleSave = () => {
if (!layoutName.trim()) {
return; // Don't save if name is empty
}
onSave(layoutName.trim(), description.trim());
// Reset form
setLayoutName("");
setDescription("");
};
const handleClose = () => {
if (!isSaving) {
setLayoutName("");
setDescription("");
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Save className="w-5 h-5 text-green-600" />
Save Layout
</DialogTitle>
<DialogDescription>
Enter a name and description for your layout. This will help you identify it later.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="layout-name" className="text-sm font-medium">
Layout Name *
</Label>
<Input
id="layout-name"
value={layoutName}
onChange={(e) => setLayoutName(e.target.value)}
placeholder="Enter layout name..."
disabled={isSaving}
className="w-full"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description" className="text-sm font-medium">
Description
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter a description for your layout..."
disabled={isSaving}
className="w-full resize-none"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={isSaving}
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={isSaving || !layoutName.trim()}
className="bg-green-600 hover:bg-green-700"
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Save Layout
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,17 @@
import { useState, useEffect } from "react";
export const useAnthropicKeyCheck = () => {
const [hasAnthropicKey, setHasAnthropicKey] = useState(false);
const [isAnthropicKeyLoading, setIsAnthropicKeyLoading] = useState(true);
useEffect(() => {
fetch("/api/has-anthropic-key")
.then((res) => res.json())
.then((data) => {
setHasAnthropicKey(data.hasKey);
setIsAnthropicKeyLoading(false);
});
}, []);
return { hasAnthropicKey, isAnthropicKeyLoading };
};

View file

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

View file

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

View file

@ -0,0 +1,39 @@
import { useState, useCallback } from "react";
import { toast } from "sonner";
export const useFileUpload = () => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const handleFileSelect = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.name.toLowerCase().endsWith(".pptx")) {
toast.error("Please select a valid PPTX file");
return;
}
// Validate file size (50MB limit)
const maxSize = 50 * 1024 * 1024; // 50MB
if (file.size > maxSize) {
toast.error("File size must be less than 50MB");
return;
}
setSelectedFile(file);
},
[]
);
const removeFile = useCallback(() => {
setSelectedFile(null);
}, []);
return {
selectedFile,
handleFileSelect,
removeFile,
};
};

View file

@ -0,0 +1,152 @@
import { useState, useEffect, useCallback } from "react";
import { toast } from "sonner";
import { UploadedFont, FontData } from "../types";
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("/api/v1/ppt/fonts/upload", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
const newFont: UploadedFont = {
fontName: data.font_name || fontName,
fontUrl: data.font_url,
fontPath: data.font_path,
};
setUploadedFonts((prev) => [...prev, newFont]);
toast.success(`Font "${fontName}" uploaded successfully`);
return newFont.fontUrl;
} else {
throw new Error(data.message || "Upload failed");
}
} catch (error) {
console.error("Error uploading font:", error);
toast.error(`Failed to upload font "${fontName}"`, {
description:
error instanceof Error
? error.message
: "An unexpected error occurred",
});
return null;
}
},
[UploadedFonts]
);
const removeFont = useCallback((fontUrl: string) => {
setUploadedFonts((prev) => prev.filter((font) => font.fontUrl !== fontUrl));
// Remove the style element for this font
const styleElement = document.querySelector(
`style[data-font-url="${fontUrl}"]`
);
if (styleElement) {
styleElement.remove();
}
toast.info("Font removed globally");
}, []);
const getAllUnsupportedFonts = useCallback((): string[] => {
if (!fontsData?.not_supported_fonts) {
return [];
}
return fontsData.not_supported_fonts;
}, [fontsData]);
return {
UploadedFonts,
fontsData,
setFontsData,
uploadFont,
removeFont,
getAllUnsupportedFonts,
};
};

View file

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

View file

@ -0,0 +1,149 @@
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 } from "../types";
export const useLayoutSaving = (
slides: ProcessedSlide[],
UploadedFonts: UploadedFont[],
refetch: () => void
) => {
const [isSavingLayout, setIsSavingLayout] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const openSaveModal = useCallback(() => {
setIsModalOpen(true);
}, []);
const closeSaveModal = useCallback(() => {
setIsModalOpen(false);
}, []);
const saveLayout = useCallback(async (layoutName: string, description: string) => {
if (!slides.length) {
toast.error("No slides to save");
return;
}
setIsSavingLayout(true);
try {
// Convert each slide HTML to React component
const reactComponents = [];
const presentationId = uuidv4();
// Get all uploaded font URLs
const FontUrls = UploadedFonts.map((font) => font.fontUrl);
console.log("FontUrls", FontUrls);
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;
}
try {
const response = await fetch("/api/v1/ppt/html-to-react/", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
html: slide.html,
}),
});
const data = await ApiResponseHandler.handleResponse(
response,
`Failed to convert slide ${slide.slide_number} to React`
);
reactComponents.push({
presentation_id: presentationId,
layout_id: `${slide.slide_number}`,
layout_name: `Slide${slide.slide_number}`,
layout_code: data.react_component || data.component_code,
fonts: FontUrls,
});
// Update progress
toast.info(
`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}`, {
description:
error instanceof Error
? error.message
: "An unexpected error occurred",
});
}
}
if (reactComponents.length === 0) {
toast.error("No slides were successfully converted");
return;
}
console.log(reactComponents);
// Save the layout components to the app_data/layouts folder
const saveResponse = await fetch(
"/api/v1/ppt/layout-management/save-layouts",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
layouts: reactComponents,
layout_name: layoutName,
description: description,
}),
}
);
const data = await ApiResponseHandler.handleResponse(
saveResponse,
"Failed to save layout components"
);
if (!data.success) {
toast.error("Failed to save layout components");
return;
}
toast.success("Layout saved successfully");
// Mark all slides as saved (remove modified flag)
slides.forEach((slide) => {
slide.modified = false;
});
toast.success(`Layout "${layoutName}" saved successfully`);
refetch();
closeSaveModal();
} catch (error) {
console.error("Error saving layout:", error);
toast.error("Failed to save layout", {
description:
error instanceof Error
? error.message
: "An unexpected error occurred",
});
} finally {
setIsSavingLayout(false);
}
}, [slides, UploadedFonts, refetch, closeSaveModal]);
return {
isSavingLayout,
isModalOpen,
openSaveModal,
closeSaveModal,
saveLayout,
};
};

View file

@ -0,0 +1,222 @@
import { useState, useEffect, useRef } from "react";
import html2canvas from "html2canvas";
import { ProcessedSlide } from "../types";
export const useSlideEdit = (
slide: ProcessedSlide,
index: number,
onSlideUpdate?: (updatedSlideData: any) => void,
setSlides?: React.Dispatch<React.SetStateAction<ProcessedSlide[]>>
) => {
const [isEditMode, setIsEditMode] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [prompt, setPrompt] = useState("");
const [slideHtml, setSlideHtml] = useState("");
const slideContentRef = useRef<HTMLDivElement>(null);
// Load Tailwind CSS dynamically for slide content
useEffect(() => {
if (slide.processed && slide.html) {
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);
}
}
}, [slide.processed, slide.html]);
// 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;
if (!prompt.trim()) {
alert("Please enter a prompt before saving.");
return;
}
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("/api/v1/ppt/html-edit/", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`API call failed: ${response.statusText}`);
}
const data = await response.json();
const updatedSlideData = {
slide_number: slide.slide_number,
html: data.edited_html || currentHtml,
processed: true,
processing: false,
error: undefined,
};
if (onSlideUpdate) {
onSlideUpdate(updatedSlideData);
} else if (setSlides) {
setSlides((prevSlides) =>
prevSlides.map((s, i) =>
i === index ? { ...s, ...updatedSlideData } : s
)
);
}
// Exit edit mode
setIsEditMode(false);
setPrompt("");
} catch (error) {
console.error("Error updating slide:", error);
alert(
`Error updating slide: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
} finally {
setIsUpdating(false);
}
};
const handleEditClick = () => {
setIsEditMode(true);
};
const handleCancelEdit = () => {
setIsEditMode(false);
setPrompt("");
};
return {
isEditMode,
isUpdating,
prompt,
slideContentRef,
slideHtml,
setPrompt,
handleSave,
handleEditClick,
handleCancelEdit,
};
};

View file

@ -0,0 +1,198 @@
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";
export const useSlideProcessing = (
selectedFile: File | null,
slides: ProcessedSlide[],
setSlides: React.Dispatch<React.SetStateAction<ProcessedSlide[]>>,
fontsData: FontData | null,
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("/api/v1/ppt/slide-to-html/", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image: slide.screenshot_url,
xml: slide.xml_content,
}),
});
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 PPTX file to extract slides
const processFile = useCallback(async () => {
if (!selectedFile) {
toast.error("Please select a PPTX file first");
return;
}
try {
setIsProcessingPptx(true);
const formData = new FormData();
formData.append("pptx_file", selectedFile);
const pptxResponse = await fetch("/api/v1/ppt/pptx-slides/process", {
method: "POST",
body: formData,
});
const pptxData = await ApiResponseHandler.handleResponse(
pptxResponse,
"Failed to process PPTX file"
);
if (!pptxData.success || !pptxData.slides?.length) {
throw new Error("No slides found in the PPTX file");
}
// Extract fonts data from the response
if (pptxData.fonts) {
setFontsData(pptxData.fonts);
}
// Initialize slides with skeleton state
const initialSlides: ProcessedSlide[] = pptxData.slides.map(
(slide: any) => ({
...slide,
processing: false,
processed: false,
})
);
setSlides(initialSlides);
toast.success(
`Successfully extracted ${pptxData.slides.length} slides! Converting to HTML...`
);
// Start processing first slide
setTimeout(() => {
console.log("Starting to process first slide");
processSlideToHtml(initialSlides[0], 0);
}, 500);
} catch (error) {
console.error("Error processing file:", error);
const errorMessage =
error instanceof Error ? error.message : "An unexpected error occurred";
toast.error("Processing failed", {
description: errorMessage,
});
} finally {
setIsProcessingPptx(false);
}
}, [selectedFile, processSlideToHtml, setSlides, setFontsData]);
// Retry failed slide
const retrySlide = useCallback(
(index: number) => {
const slide = slides[index];
if (slide) {
processSlideToHtml(slide, index);
}
},
[slides, processSlideToHtml]
);
return {
isProcessingPptx,
processFile,
processSlideToHtml,
retrySlide,
};
};

View file

@ -1,613 +1,72 @@
"use client";
import React, { useState, useCallback, useEffect } 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 { toast } from "sonner";
import { Upload, FileText, X, Loader2 } from "lucide-react";
import { ApiResponseHandler } from "@/app/(presentation-generator)/services/api/api-error-handler";
import { v4 as uuidv4 } from "uuid";
import EachSlide from "./components/EachSlide";
import React from "react";
import FontManager from "./components/FontManager";
import Header from "@/components/Header";
import { useLayout } from "../(presentation-generator)/context/LayoutContext";
// Types
interface SlideData {
slide_number: number;
screenshot_url: string;
xml_content: string;
}
interface UploadedFont {
fontName: string;
fontUrl: string;
fontPath: string;
}
interface ProcessedSlide extends SlideData {
html?: string;
uploaded_fonts?: string[];
processing?: boolean;
processed?: boolean;
error?: string;
modified?: boolean; // Added for unsaved changes
}
interface FontData {
internally_supported_fonts: {
name: string;
google_fonts_url: string;
}[];
not_supported_fonts: string[];
}
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 { useAnthropicKeyCheck } from "./hooks/useAnthropicKeyCheck";
import { LoadingSpinner } from "./components/LoadingSpinner";
import { AnthropicKeyWarning } from "./components/AnthropicKeyWarning";
import { FileUploadSection } from "./components/FileUploadSection";
import { SaveLayoutButton } from "./components/SaveLayoutButton";
import { SaveLayoutModal } from "./components/SaveLayoutModal";
import EachSlide from "./components/EachSlide/NewEachSlide";
const CustomLayoutPage = () => {
const { refetch } = useLayout();
// State management
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isProcessingPptx, setIsProcessingPptx] = useState(false);
const [slides, setSlides] = useState<ProcessedSlide[]>([]);
const [isSavingLayout, setIsSavingLayout] = useState(false);
const [isLayoutSaved, setIsLayoutSaved] = useState(false);
const [UploadedFonts, setUploadedFonts] = useState<UploadedFont[]>([]);
const [fontsData, setFontsData] = useState<FontData | null>(null);
const [hasAnthropicKey, setHasAnthropicKey] = useState(false);
const [isAnthropicKeyLoading, setIsAnthropicKeyLoading] = useState(true);
useEffect(() => {
fetch("/api/has-anthropic-key")
.then((res) => res.json())
.then((data) => {
setHasAnthropicKey(data.hasKey);
setIsAnthropicKeyLoading(false);
});
}, []);
// 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]);
// 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]);
// If User does not put Anthropic Key, Can't process the layout
// Font management functions
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("/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]
// Custom hooks for different concerns
const { hasAnthropicKey, isAnthropicKeyLoading } = useAnthropicKeyCheck();
const { selectedFile, handleFileSelect, removeFile } = useFileUpload();
const { slides, setSlides, completedSlides } = useCustomLayout();
const { fontsData, UploadedFonts, uploadFont, removeFont, getAllUnsupportedFonts, setFontsData } = useFontManagement();
const { isProcessingPptx, processFile, retrySlide } = useSlideProcessing(
selectedFile,
slides,
setSlides,
fontsData,
setFontsData
);
const { isSavingLayout, isModalOpen, openSaveModal, closeSaveModal, saveLayout } = useLayoutSaving(
slides,
UploadedFonts,
refetch
);
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}"]`
// Handle slide updates
const handleSlideUpdate = (index: number, updatedSlideData: any) => {
setSlides((prevSlides) =>
prevSlides.map((s, i) =>
i === index
? {
...s,
...updatedSlideData,
modified: true,
}
: s
)
);
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]);
// Save layout functionality
const saveLayout = useCallback(async () => {
if (!slides.length) {
toast.error("No slides to save");
return;
}
setIsSavingLayout(true);
try {
// Convert each slide HTML to React component
const reactComponents = [];
const presentationId = uuidv4();
// Get all uploaded font URLs
const FontUrls = UploadedFonts.map((font) => font.fontUrl);
console.log("FontUrls", FontUrls);
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;
}
try {
const response = await fetch("/api/v1/ppt/html-to-react/", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
html: slide.html,
}),
});
const data = await ApiResponseHandler.handleResponse(
response,
`Failed to convert slide ${slide.slide_number} to React`
);
reactComponents.push({
presentation_id: presentationId,
layout_id: `${slide.slide_number}`,
layout_name: `Slide${slide.slide_number}`,
layout_code: data.react_component || data.component_code,
fonts: FontUrls,
});
// Update progress
toast.info(
`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}`, {
description:
error instanceof Error
? error.message
: "An unexpected error occurred",
});
}
}
if (reactComponents.length === 0) {
toast.error("No slides were successfully converted");
return;
}
console.log(reactComponents);
// Save the layout components to the app_data/layouts folder
const saveResponse = await fetch(
"/api/v1/ppt/layout-management/save-layouts",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
layouts: reactComponents,
}),
}
);
const data = await ApiResponseHandler.handleResponse(
saveResponse,
"Failed to save layout components"
);
if (!data.success) {
toast.error("Failed to save layout components");
return;
}
toast.success("Layout saved successfully");
// Mark all slides as saved (remove modified flag)
setSlides((prevSlides) =>
prevSlides.map((slide) => ({ ...slide, modified: false }))
);
toast.success(`Layout saved successfully`);
refetch();
setIsLayoutSaved(true);
} catch (error) {
console.error("Error saving layout:", error);
toast.error("Failed to save layout", {
description:
error instanceof Error
? error.message
: "An unexpected error occurred",
});
} finally {
setIsSavingLayout(false);
}
}, [slides, UploadedFonts]);
// File upload handler
const handleFileSelect = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.name.toLowerCase().endsWith(".pptx")) {
toast.error("Please select a valid PPTX file");
return;
}
// Validate file size (50MB limit)
const maxSize = 50 * 1024 * 1024; // 50MB
if (file.size > maxSize) {
toast.error("File size must be less than 50MB");
return;
}
setSelectedFile(file);
setSlides([]);
},
[]
);
// Remove selected file
const removeFile = useCallback(() => {
setSelectedFile(null);
setSlides([]);
}, []);
// Process individual slide to HTML
const processSlideToHtml = useCallback(
async (slide: SlideData, index: number) => {
setIsLayoutSaved(false);
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("/api/v1/ppt/slide-to-html/", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image: slide.screenshot_url,
xml: slide.xml_content,
}),
});
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 PPTX file to extract slides
const processFile = useCallback(async () => {
if (!selectedFile) {
toast.error("Please select a PPTX file first");
return;
}
try {
setIsProcessingPptx(true);
const formData = new FormData();
formData.append("pptx_file", selectedFile);
const pptxResponse = await fetch("/api/v1/ppt/pptx-slides/process", {
method: "POST",
body: formData,
});
const pptxData = await ApiResponseHandler.handleResponse(
pptxResponse,
"Failed to process PPTX file"
);
if (!pptxData.success || !pptxData.slides?.length) {
throw new Error("No slides found in the PPTX file");
}
// Extract fonts data from the response
if (pptxData.fonts) {
setFontsData(pptxData.fonts);
}
// const pptxData = processData;
// Initialize slides with skeleton state
const initialSlides: ProcessedSlide[] = pptxData.slides.map(
(slide: any) => ({
...slide,
processing: false,
processed: false,
})
);
setSlides(initialSlides);
toast.success(
`Successfully extracted ${pptxData.slides.length} slides! Converting to HTML...`
);
// Start processing first slide
setTimeout(() => {
console.log("Starting to process first slide");
processSlideToHtml(initialSlides[0], 0);
}, 500);
} 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]);
// Retry failed slide
const retrySlide = useCallback(
(index: number) => {
setIsLayoutSaved(false);
const slide = slides[index];
if (slide) {
processSlideToHtml(slide, index);
}
},
[slides, processSlideToHtml]
);
// Mark slide as modified when it's updated
const handleSlideUpdate = useCallback(
(index: number, updatedSlideData: any) => {
setIsLayoutSaved(false);
setSlides((prevSlides) =>
prevSlides.map((s, i) =>
i === index
? {
...s,
...updatedSlideData,
modified: true,
}
: s
)
);
},
[]
);
// Calculate progress
const completedSlides = slides.filter(
(slide) => slide.processed || slide.error
).length;
};
// Loading state
if (isAnthropicKeyLoading) {
return (
<div className="min-h-screen 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-6 rounded-lg shadow-md">
<Loader2 className="w-6 h-6 animate-spin text-blue-600 mx-auto" />
<p>Checking Anthropic Key...</p>
</div>
</div>
</div>
);
return <LoadingSpinner message="Checking Anthropic Key..." />;
}
// Anthropic key warning
if (!hasAnthropicKey) {
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 put Anthropic Key To Process The Layout
</h1>
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
It Only works on Anthropic(Claude-4).
</p>
</div>
</div>
</div>
);
return <AnthropicKeyWarning />;
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 ">
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<Header />
<div className="max-w-[1440px] aspect-video mx-auto px-6 ">
<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">
@ -619,88 +78,16 @@ const CustomLayoutPage = () => {
</p>
</div>
{/* Upload Section */}
<Card className="w-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="w-5 h-5" />
Upload PPTX File
</CardTitle>
<CardDescription>
Select a PowerPoint file (.pptx) to process. Maximum file size:
50MB
</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>
)}
</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 PPTX file
</span>
<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"
/>
</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>
<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="flex gap-3">
<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 PPTX file"
: "Process File"}
</Button>
</div>
</CardContent>
</Card>
{/* File Upload Section */}
<FileUploadSection
selectedFile={selectedFile}
handleFileSelect={handleFileSelect}
removeFile={removeFile}
processFile={processFile}
isProcessingPptx={isProcessingPptx}
slides={slides}
completedSlides={completedSlides}
/>
{/* Global Font Management */}
{fontsData && (
@ -734,27 +121,20 @@ const CustomLayoutPage = () => {
{/* Floating Save Layout Button */}
{slides.length > 0 && slides.some((s) => s.processed) && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50">
<Button
onClick={saveLayout}
disabled={isSavingLayout || slides.some((s) => s.processing)}
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"
size="lg"
>
{isSavingLayout ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Saving Layout...
</>
) : (
<>
<FileText className="w-5 h-5 mr-2" />
Save Layout
</>
)}
</Button>
</div>
<SaveLayoutButton
onSave={openSaveModal}
isSaving={isSavingLayout}
isProcessing={slides.some((s) => s.processing)}
/>
)}
{/* Save Layout Modal */}
<SaveLayoutModal
isOpen={isModalOpen}
onClose={closeSaveModal}
onSave={saveLayout}
isSaving={isSavingLayout}
/>
</div>
</div>
);

View file

@ -0,0 +1,108 @@
// Types for Custom Layout functionality
export interface SlideData {
slide_number: number;
screenshot_url: string;
xml_content: string;
}
export interface UploadedFont {
fontName: string;
fontUrl: string;
fontPath: string;
}
export interface ProcessedSlide extends SlideData {
html?: string;
uploaded_fonts?: string[];
processing?: boolean;
processed?: boolean;
error?: string;
modified?: boolean;
}
export interface FontData {
internally_supported_fonts: {
name: string;
google_fonts_url: string;
}[];
not_supported_fonts: string[];
}
export interface EachSlideProps {
slide: ProcessedSlide;
index: number;
retrySlide: (index: number) => void;
setSlides: React.Dispatch<React.SetStateAction<ProcessedSlide[]>>;
onSlideUpdate?: (updatedSlideData: any) => void;
isProcessing: boolean;
}
export interface DrawingCanvasProps {
canvasRef: React.RefObject<HTMLCanvasElement>;
slideDisplayRef: React.RefObject<HTMLDivElement>;
strokeWidth: number;
strokeColor: string;
eraserMode: boolean;
isDrawing: boolean;
canvasDimensions: { width: number; height: number };
onStrokeWidthChange: (width: number) => void;
onStrokeColorChange: (color: string) => void;
onEraserModeChange: (isEraser: boolean) => void;
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;
}
export interface HtmlEditorProps {
slide: ProcessedSlide;
isHtmlEditMode: boolean;
onSave: (html: string) => void;
onCancel: () => void;
}

View file

@ -57,7 +57,7 @@
"sharp": "^0.34.3",
"sonner": "^2.0.6",
"tailwind-merge": "^2.5.3",
"tailwind-scrollbar-hide": "^2.0.0",
"tailwindcss-animate": "^1.0.7",
"tiptap-markdown": "^0.8.10",
"uuid": "^11.1.0",

View file

@ -1,5 +1,4 @@
import type { Config } from "tailwindcss";
import scrollbarHide from "tailwind-scrollbar-hide";
const config: Config = {
darkMode: ["class"],
@ -91,7 +90,6 @@ const config: Config = {
},
plugins: [
require("tailwindcss-animate"),
scrollbarHide,
require("@tailwindcss/typography"),
],
};