feat(Nextjs): Custom Layout Editing & convert to React Component

This commit is contained in:
shiva raj badu 2025-07-31 14:25:58 +05:45
parent 4ebd480d46
commit 0b8db37b73
No known key found for this signature in database
5 changed files with 475 additions and 170 deletions

View file

@ -4,7 +4,15 @@ FROM python:3.11-slim-bookworm
RUN apt-get update && apt-get install -y \
nginx \
curl \
redis-server
redis-server \
default-libmysqlclient-dev \
build-essential \
pkg-config \
libreoffice \
fontconfig \
imagemagick
RUN sed -i 's/rights="none" pattern="PDF"/rights="read|write" pattern="PDF"/' /etc/ImageMagick-6/policy.xml
# Install Node.js 20 using NodeSource repository

View file

@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { existsSync } from "fs";
export async function POST(request: NextRequest) {
try {
const { layout_name, components } = await request.json();
if (!layout_name || !components || !Array.isArray(components)) {
return NextResponse.json(
{
error:
"Invalid request body. Expected layout_name and components array.",
},
{ status: 400 }
);
}
// Define the layouts directory path
const layoutsDir = join(process.cwd(), "app_data", "layouts", layout_name);
// Create the directory if it doesn't exist
if (!existsSync(layoutsDir)) {
await mkdir(layoutsDir, { recursive: true });
}
// Save each component as a separate file
const savedFiles = [];
for (const component of components) {
const { slide_number, component_code, component_name } = component;
if (!component_code || !component_name) {
console.warn(
`Skipping component for slide ${slide_number}: missing code or name`
);
continue;
}
const fileName = `${component_name}.tsx`;
const filePath = join(layoutsDir, fileName);
await writeFile(filePath, component_code, "utf8");
savedFiles.push({
slide_number,
component_name,
file_path: filePath,
file_name: fileName,
});
}
return NextResponse.json({
success: true,
layout_name,
path: layoutsDir,
saved_files: savedFiles.length,
components: savedFiles,
});
} catch (error) {
console.error("Error saving layout:", error);
return NextResponse.json(
{ error: "Failed to save layout components" },
{ status: 500 }
);
}
}

View file

@ -2,27 +2,41 @@
import React, { useRef, useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Save, X, Pencil, Eraser, RotateCcw, Download } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import {
X,
Pencil,
Eraser,
RotateCcw,
Download,
SendHorizontal,
} from "lucide-react";
import html2canvas from "html2canvas";
interface DrawingCanvasProps {
slideElement: HTMLElement | null;
onClose: () => void;
slideNumber: number;
onSlideUpdate: (updatedSlide: any) => void;
}
const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
slideElement,
onClose,
slideNumber,
onSlideUpdate,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const slideDisplayRef = useRef<HTMLDivElement>(null);
const slideContentRef = useRef<HTMLDivElement>(null);
const [strokeWidth, setStrokeWidth] = useState(3);
const [strokeColor, setStrokeColor] = useState("#000000");
const [eraserMode, setEraserMode] = useState(false);
const [isDrawing, setIsDrawing] = useState(false);
const [prompt, setPrompt] = useState("");
const [isUpdating, setIsUpdating] = useState(false);
const [slideHtml, setSlideHtml] = useState("");
const [canvasDimensions, setCanvasDimensions] = useState({
width: 800,
height: 600,
@ -30,6 +44,7 @@ const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
useEffect(() => {
if (slideElement && containerRef.current) {
console.log("slideElement", slideElement);
const rect = slideElement.getBoundingClientRect();
// Set canvas dimensions to match the slide element
@ -37,9 +52,52 @@ const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
width: Math.max(rect.width, 800),
height: Math.max(rect.height, 600),
});
// Store the HTML once to prevent re-renders
setSlideHtml(slideElement.innerHTML);
}
}, [slideElement]);
// Apply optimizations once after slide content is rendered
useEffect(() => {
if (slideContentRef.current && slideHtml) {
const slideContent = slideContentRef.current;
// Apply styles to prevent interactions and flickering
slideContent.style.pointerEvents = "none";
slideContent.style.userSelect = "none";
slideContent.style.transform = "translateZ(0)";
slideContent.style.willChange = "auto";
slideContent.style.backfaceVisibility = "hidden";
// Target all interactive elements
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;
}
// Remove any event listeners
el.onclick = null;
el.onmousedown = null;
el.onmouseup = null;
el.onmousemove = null;
});
}
}, [slideHtml]);
const getCanvasContext = () => {
const canvas = canvasRef.current;
if (!canvas) return null;
@ -154,18 +212,31 @@ const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
ctx.clearRect(0, 0, canvas.width, canvas.height);
};
const downloadImage = (dataURL: string, filename: string) => {
const link = document.createElement("a");
link.download = filename;
link.href = dataURL;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 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 () => {
if (!slideElement || !canvasRef.current || !slideDisplayRef.current) 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, {
@ -187,17 +258,63 @@ const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
useCORS: true,
});
// Download both images
const slideOnlyDataURL = slideOnly.toDataURL("image/png");
const slideWithCanvasDataURL = slideWithCanvas.toDataURL("image/png");
// Get the current HTML content from the original slide element
const currentHtml = slideElement.innerHTML;
downloadImage(slideOnlyDataURL, `slide-${slideNumber}-original.png`);
downloadImage(
slideWithCanvasDataURL,
`slide-${slideNumber}-with-annotations.png`
// Convert canvas images to blobs
const currentUiImageBlob = dataURLToBlob(
slideOnly.toDataURL("image/png")
);
const sketchImageBlob = dataURLToBlob(
slideWithCanvas.toDataURL("image/png")
);
// Prepare form data
const formData = new FormData();
formData.append(
"current_ui_image",
currentUiImageBlob,
`slide-${slideNumber}-current.png`
);
formData.append(
"sketch_image",
sketchImageBlob,
`slide-${slideNumber}-sketch.png`
);
formData.append("html", currentHtml);
formData.append("prompt", prompt);
// Call the API
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();
// Update the slide with new data
onSlideUpdate({
slide_number: slideNumber,
html: data.edited_html || currentHtml,
processed: true,
processing: false,
error: undefined,
});
// Close the drawing canvas
onClose();
} catch (error) {
console.error("Error capturing slide:", error);
console.error("Error updating slide:", error);
alert(
`Error updating slide: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
} finally {
setIsUpdating(false);
}
};
@ -319,13 +436,6 @@ const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
onClick={handleSave}
className="flex items-center gap-1 bg-green-600 hover:bg-green-700"
>
<Download size={16} />
Save & Download
</Button>
<Button
variant="outline"
onClick={onClose}
@ -337,6 +447,42 @@ const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
</div>
</div>
{/* Prompt Section */}
<div className="p-4 border-b bg-gray-50 flex-shrink-0">
<div className="space-y-2">
<label
htmlFor="edit-prompt"
className="text-sm font-medium 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) => setPrompt(e.target.value)}
className="flex-1 min-h-[80px] max-h-[80px] resize-none"
disabled={isUpdating}
/>
<Button
onClick={handleSave}
disabled={isUpdating || !prompt.trim()}
className="flex items-center gap-1 bg-green-600 hover:bg-green-700 px-6"
>
{isUpdating ? (
"Updating..."
) : (
<>
<SendHorizontal size={16} />
Update Slide
</>
)}
</Button>
</div>
</div>
</div>
{/* Canvas Area */}
<div className="flex-1 overflow-auto bg-gray-100 p-4">
<div
@ -347,15 +493,19 @@ const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
height: canvasDimensions.height,
}}
>
{/* Slide Background */}
{slideElement && (
<div
className="absolute inset-0 pointer-events-none z-10"
dangerouslySetInnerHTML={{
__html: slideElement.innerHTML,
}}
/>
)}
{/* Slide Background - Static HTML content */}
<div
ref={slideContentRef}
className="absolute inset-0 z-10"
style={{
overflow: "hidden",
isolation: "isolate",
contain: "layout style paint",
}}
dangerouslySetInnerHTML={{
__html: slideHtml,
}}
/>
{/* Drawing Canvas */}
<canvas
@ -368,6 +518,8 @@ const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
left: 0,
zIndex: 20,
cursor: eraserMode ? "grab" : "crosshair",
pointerEvents: "auto",
touchAction: "none",
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
@ -376,6 +528,7 @@ const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
</div>

View file

@ -1,20 +1,8 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
AlertCircle,
CheckCircle,
Edit,
Loader2,
SendHorizontal,
WandSparkles,
} from "lucide-react";
import { AlertCircle, CheckCircle, Edit, Loader2 } from "lucide-react";
import React, { useState, useEffect, useRef } from "react";
import { Textarea } from "@/components/ui/textarea";
import ToolTip from "@/components/ToolTip";
import DrawingCanvas from "./DrawingCanvas";
@ -23,11 +11,13 @@ const EachSlide = ({
index,
retrySlide,
setSlides,
onSlideUpdate,
}: {
slide: any;
index: number;
retrySlide: (index: number) => void;
setSlides: (slides: any[]) => void;
setSlides: React.Dispatch<React.SetStateAction<any[]>>;
onSlideUpdate?: (updatedSlideData: any) => void;
}) => {
const [isUpdating, setIsUpdating] = useState(false);
const [prompt, setPrompt] = useState("");
@ -50,25 +40,6 @@ const EachSlide = ({
}
}, [slide.processed, slide.html]);
const handleSubmit = async () => {
setIsUpdating(true);
try {
const response = await fetch("/api/update-slide", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ slide_number: slide.slide_number, prompt }),
});
const data = await response.json();
console.log(data);
} catch (error) {
console.error("Error updating slide:", error);
} finally {
setIsUpdating(false);
}
};
const handleEditClick = () => {
setShowDrawingCanvas(true);
};
@ -77,6 +48,19 @@ const EachSlide = ({
setShowDrawingCanvas(false);
};
const handleSlideUpdate = (updatedSlideData: any) => {
if (onSlideUpdate) {
onSlideUpdate(updatedSlideData);
} else {
// Fallback to original behavior
setSlides((prevSlides) =>
prevSlides.map((s, i) =>
i === index ? { ...s, ...updatedSlideData } : s
)
);
}
};
return (
<>
<Card key={slide.slide_number} className="border-2 w-full relative">
@ -184,60 +168,6 @@ const EachSlide = ({
>
Open in new tab
</Button>
<div className="absolute top-2 z-20 sm:top-4 hidden md:block left-2 sm:left-4 transition-transform">
<Popover>
<PopoverTrigger>
<ToolTip content="Update slide using prompt">
<div
className={`p-2 group-hover:scale-105 rounded-lg bg-[#5141e5] hover:shadow-md transition-all duration-300 cursor-pointer shadow-md `}
>
<WandSparkles className="w-4 sm:w-5 h-4 sm:h-5 text-white" />
</div>
</ToolTip>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
sideOffset={10}
className="w-[280px] sm:w-[400px] z-20"
>
<div className="space-y-4">
<form
className="flex flex-col gap-3"
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<Textarea
id={`slide-${slide.index}-prompt`}
placeholder="Enter your prompt here..."
className="w-full min-h-[100px] max-h-[100px] p-2 text-sm border rounded-lg focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
disabled={isUpdating}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
rows={4}
wrap="soft"
/>
<button
disabled={isUpdating}
type="submit"
className={`bg-gradient-to-r from-[#9034EA] to-[#5146E5] rounded-[32px] px-4 py-2 text-white flex items-center justify-end gap-2 ml-auto ${
isUpdating ? "opacity-70 cursor-not-allowed" : ""
}`}
>
{isUpdating ? "Updating..." : "Update"}
<SendHorizontal className="w-4 sm:w-5 h-4 sm:h-5" />
</button>
</form>
</div>
</PopoverContent>
</Popover>
</div>
<div className="absolute top-2 z-20 sm:top-4 hidden md:block left-2 sm:left-16 transition-transform">
<ToolTip content="Edit slide">
<div
@ -256,6 +186,7 @@ const EachSlide = ({
slideElement={slideContentRef.current}
onClose={handleCloseDrawingCanvas}
slideNumber={slide.slide_number}
onSlideUpdate={handleSlideUpdate}
/>
)}
</>

View file

@ -1,6 +1,6 @@
"use client";
import React, { useState, useCallback } from "react";
import React, { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
@ -13,7 +13,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { toast } from "sonner";
import { Upload, FileText, X } from "lucide-react";
import { Upload, FileText, X, Loader2 } from "lucide-react";
import { ApiResponseHandler } from "@/app/(presentation-generator)/services/api/api-error-handler";
// Types
@ -30,6 +30,7 @@ interface ProcessedSlide extends SlideData {
processing?: boolean;
processed?: boolean;
error?: string;
modified?: boolean; // Added for unsaved changes
}
const CustomLayoutPage = () => {
@ -37,6 +38,121 @@ const CustomLayoutPage = () => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isProcessingPptx, setIsProcessingPptx] = useState(false);
const [slides, setSlides] = useState<ProcessedSlide[]>([]);
const [isSavingLayout, setIsSavingLayout] = 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?";
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, []);
// Save layout functionality
const saveLayout = useCallback(async () => {
if (!slides.length) {
toast.error("No slides to save");
return;
}
setIsSavingLayout(true);
const layoutName = `layout-${Date.now()}`;
try {
// Convert each slide HTML to React component
const reactComponents = [];
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,
component_name: `Slide${slide.slide_number}`,
}),
});
if (!response.ok) {
throw new Error(
`Failed to convert slide ${slide.slide_number} to React`
);
}
const data = await response.json();
reactComponents.push({
slide_number: slide.slide_number,
component_code: data.react_component || data.component_code,
component_name: `Slide${slide.slide_number}`,
});
// 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}`);
}
}
if (reactComponents.length === 0) {
toast.error("No slides were successfully converted");
return;
}
// Save the layout components to the app_data/layouts folder
const saveResponse = await fetch("/api/save-layout", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
layout_name: layoutName,
components: reactComponents,
}),
});
if (!saveResponse.ok) {
throw new Error("Failed to save layout components");
}
const saveData = await saveResponse.json();
// Mark all slides as saved (remove modified flag)
setSlides((prevSlides) =>
prevSlides.map((slide) => ({ ...slide, modified: false }))
);
toast.success(`Layout saved successfully as ${layoutName}`, {
description: `${reactComponents.length} React components saved to ${
saveData.path || "/app_data/layouts/"
}`,
});
} 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]);
// File upload handler
const handleFileSelect = useCallback(
@ -84,34 +200,21 @@ const CustomLayoutPage = () => {
);
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,
// }),
// });
let htmlResponse: any;
if (index === 0) {
htmlResponse = firstSlide;
} else if (index === 1) {
htmlResponse = slide2;
} else if (index === 2) {
htmlResponse = slide3;
} else {
htmlResponse = slide4;
}
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: SlideToHtmlResponse =
// await ApiResponseHandler.handleResponse(
// htmlResponse,
// `Failed to convert slide ${slide.slide_number} to HTML`
// );
const htmlData = htmlResponse;
const htmlData = await ApiResponseHandler.handleResponse(
htmlResponse,
`Failed to convert slide ${slide.slide_number} to HTML`
);
console.log(`Successfully processed slide ${slide.slide_number}`);
@ -198,29 +301,27 @@ const CustomLayoutPage = () => {
const formData = new FormData();
formData.append("pptx_file", selectedFile);
// const pptxResponse = await fetch("/api/v1/ppt/pptx-slides/process", {
// method: "POST",
// body: formData,
// });
const pptxResponse = processData;
const pptxData = pptxResponse;
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"
);
// const pptxData: PptxProcessResponse =
// 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");
// }
if (!pptxData.success || !pptxData.slides?.length) {
throw new Error("No slides found in the PPTX file");
}
// Initialize slides with skeleton state
const initialSlides: ProcessedSlide[] = pptxData.slides.map((slide) => ({
...slide,
processing: false,
processed: false,
}));
const initialSlides: ProcessedSlide[] = pptxData.slides.map(
(slide: any) => ({
...slide,
processing: false,
processed: false,
})
);
setSlides(initialSlides);
@ -256,6 +357,24 @@ const CustomLayoutPage = () => {
[slides, processSlideToHtml]
);
// Mark slide as modified when it's updated
const handleSlideUpdate = useCallback(
(index: number, updatedSlideData: any) => {
setSlides((prevSlides) =>
prevSlides.map((s, i) =>
i === index
? {
...s,
...updatedSlideData,
modified: true,
}
: s
)
);
},
[]
);
// Calculate progress
const completedSlides = slides.filter(
(slide) => slide.processed || slide.error
@ -386,10 +505,37 @@ const CustomLayoutPage = () => {
index={index}
retrySlide={retrySlide}
setSlides={setSlides}
onSlideUpdate={(updatedSlideData) =>
handleSlideUpdate(index, updatedSlideData)
}
/>
))}
</div>
)}
{/* Floating Save Layout Button */}
{slides.length > 0 && slides.some((s) => s.processed) && (
<div className="fixed bottom-6 right-6 z-50">
<Button
onClick={saveLayout}
disabled={isSavingLayout}
className="bg-green-600 hover:bg-green-700 text-white shadow-lg hover:shadow-xl transition-all duration-200 px-6 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>
)}
</div>
</div>
);