feat(Nextjs): Custom Layout Extractor & Canvas Draw implemented
This commit is contained in:
parent
6722a91748
commit
201e2ee11d
6 changed files with 1184 additions and 8 deletions
|
|
@ -1,13 +1,13 @@
|
|||
services:
|
||||
services:
|
||||
production:
|
||||
# image: ghcr.io/presenton/presenton:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
ports:
|
||||
# You can replace 5000 with any other port number of your choice to run Presenton on a different port number.
|
||||
- "5000:80"
|
||||
volumes:
|
||||
volumes:
|
||||
- ./app_data:/app_data
|
||||
environment:
|
||||
- CAN_CHANGE_KEYS=${CAN_CHANGE_KEYS}
|
||||
|
|
@ -34,10 +34,10 @@ services:
|
|||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
ports:
|
||||
ports:
|
||||
# You can replace 5000 with any other port number of your choice to run Presenton on a different port number.
|
||||
- "5000:80"
|
||||
volumes:
|
||||
volumes:
|
||||
- ./app_data:/app_data
|
||||
environment:
|
||||
- CAN_CHANGE_KEYS=${CAN_CHANGE_KEYS}
|
||||
|
|
@ -57,7 +57,7 @@ services:
|
|||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "5000:80"
|
||||
- "5000:80"
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./app_data:/app_data
|
||||
|
|
@ -74,6 +74,7 @@ services:
|
|||
- CUSTOM_MODEL=${CUSTOM_MODEL}
|
||||
- PEXELS_API_KEY=${PEXELS_API_KEY}
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
|
||||
development-gpu:
|
||||
build:
|
||||
|
|
@ -87,7 +88,7 @@ services:
|
|||
count: 1
|
||||
capabilities: [gpu]
|
||||
ports:
|
||||
- "5000:80"
|
||||
- "5000:80"
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./app_data:/app_data
|
||||
|
|
@ -103,4 +104,5 @@ services:
|
|||
- CUSTOM_LLM_API_KEY=${CUSTOM_LLM_API_KEY}
|
||||
- CUSTOM_MODEL=${CUSTOM_MODEL}
|
||||
- PEXELS_API_KEY=${PEXELS_API_KEY}
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
|
|
|
|||
387
servers/nextjs/app/custom-layout/components/DrawingCanvas.tsx
Normal file
387
servers/nextjs/app/custom-layout/components/DrawingCanvas.tsx
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
"use client";
|
||||
|
||||
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 html2canvas from "html2canvas";
|
||||
|
||||
interface DrawingCanvasProps {
|
||||
slideElement: HTMLElement | null;
|
||||
onClose: () => void;
|
||||
slideNumber: number;
|
||||
}
|
||||
|
||||
const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
slideElement,
|
||||
onClose,
|
||||
slideNumber,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(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: 800,
|
||||
height: 600,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (slideElement && containerRef.current) {
|
||||
const rect = slideElement.getBoundingClientRect();
|
||||
|
||||
// Set canvas dimensions to match the slide element
|
||||
setCanvasDimensions({
|
||||
width: Math.max(rect.width, 800),
|
||||
height: Math.max(rect.height, 600),
|
||||
});
|
||||
}
|
||||
}, [slideElement]);
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
const ctx = getCanvasContext();
|
||||
if (!canvas || !ctx) return;
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!slideElement || !canvasRef.current || !slideDisplayRef.current) return;
|
||||
|
||||
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) => {
|
||||
// Ignore the canvas element when taking screenshot of slide only
|
||||
return element.tagName === "CANVAS";
|
||||
},
|
||||
});
|
||||
|
||||
// Take screenshot of the entire slide display area including canvas
|
||||
const slideWithCanvas = await html2canvas(slideDisplayRef.current, {
|
||||
backgroundColor: "#ffffff",
|
||||
scale: 1,
|
||||
logging: false,
|
||||
useCORS: true,
|
||||
});
|
||||
|
||||
// Download both images
|
||||
const slideOnlyDataURL = slideOnly.toDataURL("image/png");
|
||||
const slideWithCanvasDataURL = slideWithCanvas.toDataURL("image/png");
|
||||
|
||||
downloadImage(slideOnlyDataURL, `slide-${slideNumber}-original.png`);
|
||||
downloadImage(
|
||||
slideWithCanvasDataURL,
|
||||
`slide-${slideNumber}-with-annotations.png`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error capturing slide:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEraserModeChange = (isEraser: boolean) => {
|
||||
setEraserMode(isEraser);
|
||||
};
|
||||
|
||||
const handleStrokeColorChange = (color: string) => {
|
||||
setStrokeColor(color);
|
||||
setEraserMode(false); // Switch back to draw mode when selecting color
|
||||
};
|
||||
|
||||
const handleStrokeWidthChange = (width: number) => {
|
||||
setStrokeWidth(width);
|
||||
};
|
||||
|
||||
const colors = [
|
||||
"#000000",
|
||||
"#FF0000",
|
||||
"#00FF00",
|
||||
"#0000FF",
|
||||
"#FFFF00",
|
||||
"#FF00FF",
|
||||
"#00FFFF",
|
||||
"#FFA500",
|
||||
];
|
||||
|
||||
const strokeWidths = [1, 3, 5, 8, 12];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative bg-white rounded-lg shadow-xl max-w-[95vw] max-h-[95vh] overflow-hidden flex flex-col">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between p-4 border-b bg-gray-50 flex-shrink-0">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<h3 className="text-lg font-semibold">Edit Slide {slideNumber}</h3>
|
||||
|
||||
{/* Drawing Tools */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={!eraserMode ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleEraserModeChange(false)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
Draw
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={eraserMode ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleEraserModeChange(true)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Eraser size={16} />
|
||||
Erase
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Color Picker */}
|
||||
{!eraserMode && (
|
||||
<div className="flex items-center gap-1">
|
||||
{colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
className={`w-6 h-6 rounded-full border-2 ${
|
||||
strokeColor === color
|
||||
? "border-gray-800"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => handleStrokeColorChange(color)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stroke Width */}
|
||||
<div className="flex items-center gap-1">
|
||||
{strokeWidths.map((width) => (
|
||||
<button
|
||||
key={width}
|
||||
className={`w-8 h-8 rounded border flex items-center justify-center ${
|
||||
strokeWidth === width
|
||||
? "bg-blue-100 border-blue-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
onClick={() => handleStrokeWidthChange(width)}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-gray-800"
|
||||
style={{
|
||||
width: `${width + 2}px`,
|
||||
height: `${width + 2}px`,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearCanvas}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
Clear
|
||||
</Button>
|
||||
</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}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<X size={16} />
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas Area */}
|
||||
<div className="flex-1 overflow-auto bg-gray-100 p-4">
|
||||
<div
|
||||
ref={slideDisplayRef}
|
||||
className="relative mx-auto bg-white shadow-lg"
|
||||
style={{
|
||||
width: canvasDimensions.width,
|
||||
height: canvasDimensions.height,
|
||||
}}
|
||||
>
|
||||
{/* Slide Background */}
|
||||
{slideElement && (
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none z-10"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: slideElement.innerHTML,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drawing Canvas */}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={canvasDimensions.width}
|
||||
height={canvasDimensions.height}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 20,
|
||||
cursor: eraserMode ? "grab" : "crosshair",
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DrawingCanvas;
|
||||
265
servers/nextjs/app/custom-layout/components/EachSlide.tsx
Normal file
265
servers/nextjs/app/custom-layout/components/EachSlide.tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
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 React, { useState, useEffect, useRef } from "react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import ToolTip from "@/components/ToolTip";
|
||||
import DrawingCanvas from "./DrawingCanvas";
|
||||
|
||||
const EachSlide = ({
|
||||
slide,
|
||||
index,
|
||||
retrySlide,
|
||||
setSlides,
|
||||
}: {
|
||||
slide: any;
|
||||
index: number;
|
||||
retrySlide: (index: number) => void;
|
||||
setSlides: (slides: any[]) => void;
|
||||
}) => {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [showDrawingCanvas, setShowDrawingCanvas] = useState(false);
|
||||
const slideContentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load Tailwind CSS dynamically for slide content
|
||||
useEffect(() => {
|
||||
if (slide.processed && slide.html) {
|
||||
// Check if Tailwind CSS is already loaded
|
||||
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]);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const handleCloseDrawingCanvas = () => {
|
||||
setShowDrawingCanvas(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card key={slide.slide_number} className="border-2 w-full relative">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-xl flex items-center justify-between">
|
||||
{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" />
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Slide Content */}
|
||||
{slide.processing ? (
|
||||
<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>
|
||||
</div>
|
||||
) : slide.processed && slide.html ? (
|
||||
<div
|
||||
ref={slideContentRef}
|
||||
className="relative "
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: slide.html,
|
||||
}}
|
||||
></div>
|
||||
) : slide.error ? (
|
||||
<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>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => retrySlide(index)}
|
||||
disabled={slide.processing}
|
||||
className="w-full text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
🔄 Retry Conversion
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
<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 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
|
||||
onClick={handleEditClick}
|
||||
className={`p-2 group-hover:scale-105 rounded-lg bg-[#5141e5] hover:shadow-md transition-all duration-300 cursor-pointer shadow-md `}
|
||||
>
|
||||
<Edit className="w-4 sm:w-5 h-4 sm:h-5 text-white" />
|
||||
</div>
|
||||
</ToolTip>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Drawing Canvas Modal */}
|
||||
{showDrawingCanvas && slideContentRef.current && (
|
||||
<DrawingCanvas
|
||||
slideElement={slideContentRef.current}
|
||||
onClose={handleCloseDrawingCanvas}
|
||||
slideNumber={slide.slide_number}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EachSlide;
|
||||
122
servers/nextjs/app/custom-layout/data.ts
Normal file
122
servers/nextjs/app/custom-layout/data.ts
Normal file
File diff suppressed because one or more lines are too long
398
servers/nextjs/app/custom-layout/page.tsx
Normal file
398
servers/nextjs/app/custom-layout/page.tsx
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
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 { ApiResponseHandler } from "@/app/(presentation-generator)/services/api/api-error-handler";
|
||||
|
||||
// Types
|
||||
import EachSlide from "./components/EachSlide";
|
||||
import { firstSlide, processData, slide2, slide3, slide4 } from "./data";
|
||||
interface SlideData {
|
||||
slide_number: number;
|
||||
screenshot_url: string;
|
||||
xml_content: string;
|
||||
}
|
||||
|
||||
interface ProcessedSlide extends SlideData {
|
||||
html?: string;
|
||||
processing?: boolean;
|
||||
processed?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const CustomLayoutPage = () => {
|
||||
// State management
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isProcessingPptx, setIsProcessingPptx] = useState(false);
|
||||
const [slides, setSlides] = useState<ProcessedSlide[]>([]);
|
||||
|
||||
// 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) => {
|
||||
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,
|
||||
// }),
|
||||
// });
|
||||
let htmlResponse: any;
|
||||
if (index === 0) {
|
||||
htmlResponse = firstSlide;
|
||||
} else if (index === 1) {
|
||||
htmlResponse = slide2;
|
||||
} else if (index === 2) {
|
||||
htmlResponse = slide3;
|
||||
} else {
|
||||
htmlResponse = slide4;
|
||||
}
|
||||
|
||||
// const htmlData: SlideToHtmlResponse =
|
||||
// await ApiResponseHandler.handleResponse(
|
||||
// htmlResponse,
|
||||
// `Failed to convert slide ${slide.slide_number} to HTML`
|
||||
// );
|
||||
|
||||
const htmlData = htmlResponse;
|
||||
|
||||
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 pptxResponse = processData;
|
||||
const pptxData = pptxResponse;
|
||||
|
||||
// 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");
|
||||
// }
|
||||
|
||||
// Initialize slides with skeleton state
|
||||
const initialSlides: ProcessedSlide[] = pptxData.slides.map((slide) => ({
|
||||
...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) => {
|
||||
const slide = slides[index];
|
||||
if (slide) {
|
||||
processSlideToHtml(slide, index);
|
||||
}
|
||||
},
|
||||
[slides, processSlideToHtml]
|
||||
);
|
||||
|
||||
// Calculate progress
|
||||
const completedSlides = slides.filter(
|
||||
(slide) => slide.processed || slide.error
|
||||
).length;
|
||||
const progressPercentage =
|
||||
slides.length > 0 ? Math.round((completedSlides / slides.length) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 p-4">
|
||||
<div className="max-w-[1440px] aspect-video mx-auto px-6 py-8">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-4xl font-bold text-gray-900">
|
||||
Custom Layout Processor
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
Upload your PPTX file to extract slides and convert them to
|
||||
interactive HTML layouts
|
||||
</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>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!selectedFile ? (
|
||||
<div className="border-2 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="hidden"
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Progress Section */}
|
||||
{slides.length > 0 && (
|
||||
<Card className="mt-10">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Processing Progress</span>
|
||||
<span className="text-sm font-normal text-gray-600">
|
||||
{completedSlides}/{slides.length} slides completed
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Converting slides to HTML layouts...
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Progress value={progressPercentage} className="w-full" />
|
||||
<div className="flex justify-between text-sm text-gray-600 mt-2">
|
||||
<span>Progress: {progressPercentage}%</span>
|
||||
<span>{slides.length} total slides</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Slides Section */}
|
||||
{slides.length > 0 && (
|
||||
<div className="space-y-6 mt-10">
|
||||
{slides.map((slide, index) => (
|
||||
<EachSlide
|
||||
key={index}
|
||||
slide={slide}
|
||||
index={index}
|
||||
retrySlide={retrySlide}
|
||||
setSlides={setSlides}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomLayoutPage;
|
||||
|
|
@ -41,6 +41,7 @@
|
|||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jsonrepair": "^3.12.0",
|
||||
"lucide-react": "^0.447.0",
|
||||
"marked": "^15.0.11",
|
||||
|
|
@ -51,6 +52,7 @@
|
|||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-sketch-canvas": "^6.2.0",
|
||||
"recharts": "^2.15.4",
|
||||
"sharp": "^0.34.3",
|
||||
"sonner": "^2.0.6",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue