feat(Nextjs): Custom Layout Html edit and layout title & descritpion modal added
This commit is contained in:
parent
d9cbf3ea5a
commit
146338f77e
23 changed files with 2203 additions and 697 deletions
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
120
servers/nextjs/app/custom-layout/components/SaveLayoutModal.tsx
Normal file
120
servers/nextjs/app/custom-layout/components/SaveLayoutModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 };
|
||||
};
|
||||
32
servers/nextjs/app/custom-layout/hooks/useCustomLayout.ts
Normal file
32
servers/nextjs/app/custom-layout/hooks/useCustomLayout.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
167
servers/nextjs/app/custom-layout/hooks/useDrawingCanvas.ts
Normal file
167
servers/nextjs/app/custom-layout/hooks/useDrawingCanvas.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
39
servers/nextjs/app/custom-layout/hooks/useFileUpload.ts
Normal file
39
servers/nextjs/app/custom-layout/hooks/useFileUpload.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
152
servers/nextjs/app/custom-layout/hooks/useFontManagement.ts
Normal file
152
servers/nextjs/app/custom-layout/hooks/useFontManagement.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
49
servers/nextjs/app/custom-layout/hooks/useHtmlEdit.ts
Normal file
49
servers/nextjs/app/custom-layout/hooks/useHtmlEdit.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
149
servers/nextjs/app/custom-layout/hooks/useLayoutSaving.ts
Normal file
149
servers/nextjs/app/custom-layout/hooks/useLayoutSaving.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
222
servers/nextjs/app/custom-layout/hooks/useSlideEdit.ts
Normal file
222
servers/nextjs/app/custom-layout/hooks/useSlideEdit.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
198
servers/nextjs/app/custom-layout/hooks/useSlideProcessing.ts
Normal file
198
servers/nextjs/app/custom-layout/hooks/useSlideProcessing.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
108
servers/nextjs/app/custom-layout/types/index.ts
Normal file
108
servers/nextjs/app/custom-layout/types/index.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
],
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue