Merge pull request #93 from presenton/feat/slide_editing

feat/slide editing
This commit is contained in:
Shiva Raj Badu 2025-07-18 20:53:58 +05:45 committed by GitHub
commit 6428315115
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 3950 additions and 661 deletions

View file

@ -28,6 +28,7 @@ interface IconsEditorProps {
isWhite?: boolean;
className?: string;
icon_prompt?: string[] | null;
onClose?: () => void;
}
const IconsEditor = ({
@ -39,6 +40,7 @@ const IconsEditor = ({
slideIndex,
elementId,
icon_prompt,
onClose,
}: IconsEditorProps) => {
const dispatch = useDispatch();
@ -97,124 +99,83 @@ const IconsEditor = ({
};
return (
<>
<div
style={{ background: hasBg ? backgroundColor : "transparent" }}
onClick={handleIconClick}
className={cn(
"relative overflow-hidden w-[34px] h-[34px] md:w-[64px] max-md:pointer-events-none md:h-[64px] flex items-center justify-center cursor-pointer group",
hasBg && ` rounded-[50%]`,
className
)}
data-slide-element
data-slide-index={slideIndex}
data-element-type={hasBg ? "filledbox" : "emptybox"}
data-element-id={`${elementId}-container`}
<Sheet open={true} onOpenChange={() => onClose?.()}>
<SheetContent
side="right"
className="w-[400px]"
onOpenAutoFocus={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
>
{icon ? (
<img
src={getStaticFileUrl(icon)}
alt="slide icon"
className={`object-contain w-[16px] h-[16px] md:w-[32px] md:h-[32px] ${hasBg ? "brightness-0 invert" : ""
}`}
data-slide-element
style={{
filter: hasBg
? "brightness(0) invert"
: "sepia(100%) hue-rotate(190deg) saturate(500%)",
<SheetHeader>
<SheetTitle>Choose Icon</SheetTitle>
</SheetHeader>
<div className="mt-6 space-y-4">
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
handleIconSearch();
}}
data-slide-index={slideIndex}
data-element-type="picture"
data-is-icon
data-element-id={`${elementId}-image`}
data-is-network={false}
data-image-path={icon}
/>
) : (
<div className="w-[32px] h-[32px] relative">
<Skeleton className="w-[32px] h-[32px] bg-gray-100 " />
{initialIcon !== undefined && (
<p className="absolute top-1/2 left-1/2 -translate-x-[30%] -translate-y-1/2 w-full text-center text-sm text-[#51459e]">
<PlusIcon className="w-5 h-5" />
</p>
>
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 w-4 h-4" />
<Input
placeholder="Search icons..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onClick={(e) => e.stopPropagation()}
className="pl-10"
/>
</div>
<Button
type="submit"
variant="outline"
className="w-full text-semibold text-[#51459e]"
onClick={(e) => e.stopPropagation()}
>
Search
</Button>
</form>
{/* Icons grid */}
<div className="max-h-[80vh] hide-scrollbar overflow-y-auto p-1">
{loading ? (
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: 40 }).map((_, index) => (
<Skeleton key={index} className="w-16 h-16 rounded-lg" />
))}
</div>
) : icons.length > 0 ? (
<div className="grid grid-cols-4 gap-4">
{icons.map((iconSrc, idx) => (
<div
key={idx}
onClick={(e) => {
e.stopPropagation();
handleIconChange(iconSrc);
}}
className="w-12 h-12 cursor-pointer group relative rounded-lg overflow-hidden hover:bg-gray-100 p-2"
>
<img
src={getStaticFileUrl(iconSrc)}
alt={`Icon ${idx + 1}`}
className="w-full h-full object-contain "
/>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center w-full h-[60vh] text-center text-gray-500 space-y-4">
<Search className="w-12 h-12 text-gray-400" />
<p className="text-sm">No icons found for your search.</p>
<p className="text-xs">Try refining your search query.</p>
</div>
)}
</div>
)}
{/* Hover overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-all duration-200" />
</div>
<Sheet open={isEditorOpen} onOpenChange={setIsEditorOpen}>
<SheetContent
side="right"
className="w-[400px]"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<SheetHeader>
<SheetTitle>Choose Icon</SheetTitle>
</SheetHeader>
<div className="mt-6 space-y-4">
<form
onSubmit={(e) => {
e.preventDefault();
handleIconSearch();
}}
>
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 w-4 h-4" />
<Input
placeholder="Search icons..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Button
type="submit"
variant="outline"
className="w-full text-semibold text-[#51459e]"
>
Search
</Button>
</form>
{/* Icons grid */}
<div className="max-h-[80vh] hide-scrollbar overflow-y-auto p-1">
{loading ? (
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: 40 }).map((_, index) => (
<Skeleton key={index} className="w-16 h-16 rounded-lg" />
))}
</div>
) : icons.length > 0 ? (
<div className="grid grid-cols-4 gap-4">
{icons.map((iconSrc, idx) => (
<div
key={idx}
onClick={() => handleIconChange(iconSrc)}
className="w-12 h-12 cursor-pointer group relative rounded-lg overflow-hidden hover:bg-gray-100 p-2"
>
<img
src={getStaticFileUrl(iconSrc)}
alt={`Icon ${idx + 1}`}
className="w-full h-full object-contain "
/>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center w-full h-[60vh] text-center text-gray-500 space-y-4">
<Search className="w-12 h-12 text-gray-400" />
<p className="text-sm">No icons found for your search.</p>
<p className="text-xs">Try refining your search query.</p>
</div>
)}
</div>
</div>
</SheetContent>
</Sheet>
</>
</div>
</SheetContent>
</Sheet>
);
};

View file

@ -12,9 +12,8 @@ import { Textarea } from "@/components/ui/textarea";
import {
Wand2,
Upload,
Edit,
Move,
Maximize,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useDispatch, useSelector } from "react-redux";
@ -28,34 +27,27 @@ import {
} from "@/store/slices/presentationGeneration";
import { getStaticFileUrl, ThemeImagePrompt } from "../utils/others";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import ToolTip from "@/components/ToolTip";
interface ImageEditorProps {
initialImage: string | null;
imageIdx?: number;
title: string;
slideIndex: number;
elementId: string;
className?: string;
promptContent?: string;
properties?: null | any;
onClose?: () => void;
}
const ImageEditor = ({
initialImage,
imageIdx = 0,
className,
title,
slideIndex,
elementId,
promptContent,
properties,
onClose,
}: ImageEditorProps) => {
const dispatch = useDispatch();
@ -64,9 +56,6 @@ const ImageEditor = ({
const searchParams = useSearchParams();
const [image, setImage] = useState(initialImage);
const [previewImages, setPreviewImages] = useState([initialImage]);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [isToolbarOpen, setIsToolbarOpen] = useState(false);
const [prompt, setPrompt] = useState<string>("");
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -108,7 +97,7 @@ const ImageEditor = ({
!toolbarRef.current.contains(event.target as Node) &&
!popoverContentRef.current
) {
setIsToolbarOpen(false);
if (isFocusPointMode) {
// saveFocusPoint(); // Save focus point before closing
saveImageProperties(objectFit, focusPoint);
@ -123,16 +112,7 @@ const ImageEditor = ({
};
}, [isFocusPointMode, focusPoint]);
const handleImageClick = () => {
if (!isFocusPointMode) {
setIsToolbarOpen(true);
}
};
const handleOpenEditor = () => {
setIsToolbarOpen(false);
setIsEditorOpen(true);
};
const handleImageChange = (newImage: string) => {
setImage(newImage);
@ -143,7 +123,6 @@ const ImageEditor = ({
image: newImage,
})
);
setIsEditorOpen(false);
};
const handleFocusPointClick = (e: React.MouseEvent) => {
@ -282,383 +261,344 @@ const ImageEditor = ({
}
};
// Helper function to determine image URL
const getImageUrl = (src: string | null) => {
if (!src) return "";
return getStaticFileUrl(src) || "";
};
return (
<>
<div
ref={imageContainerRef}
className={cn(
"relative group max-md:h-[200px] max-lg:h-[300px] max-md:pointer-events-none lg:aspect-[4/4] w-full cursor-pointer rounded-lg overflow-hidden",
isFocusPointMode ? "cursor-crosshair" : "",
className
)}
data-slide-element
data-slide-index={slideIndex}
data-element-type="picture"
data-element-id={elementId}
onClick={(e) => {
if (initialImage !== undefined) {
if (isFocusPointMode) {
handleFocusPointClick(e);
} else {
handleImageClick();
}
}
}}
<Sheet open={true} onOpenChange={() => onClose?.()}>
<SheetContent
side="right"
className="w-[600px]"
onOpenAutoFocus={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
>
{image ? (
<img
ref={imageRef}
src={getImageUrl(image)}
alt={title}
className="w-full h-full transition-all duration-200 "
style={{
objectFit: objectFit,
objectPosition: `${focusPoint.x}% ${focusPoint.y}%`,
}}
data-slide-index={slideIndex}
data-element-type="picture"
data-is-image
data-object-fit={objectFit}
data-focial-point-x={focusPoint.x}
data-focial-point-y={focusPoint.y}
data-element-id={`${elementId}-image`}
data-is-network={image && image.startsWith("http")}
data-image-path={image}
/>
) : (
<div className="w-full h-full relative">
<Skeleton className="w-full h-full bg-gray-300 animate-pulse" />
{
<p className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full text-center text-sm text-gray-500">
{initialImage !== undefined
? "Click to add image"
: "Loading..."}
</p>
}
</div>
)}
<SheetHeader>
<SheetTitle>Update Image</SheetTitle>
</SheetHeader>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-all duration-200 rounded-lg" />
<div className="mt-6">
<Tabs defaultValue="edit" className="w-full">
<TabsList className="grid bg-blue-100 border border-blue-300 w-full grid-cols-3 mx-auto ">
<TabsTrigger className="font-medium" value="edit">
Edit
</TabsTrigger>
<TabsTrigger className="font-medium" value="generate">
AI Generate
</TabsTrigger>
<TabsTrigger className="font-medium" value="upload">
Upload
</TabsTrigger>
</TabsList>
{isFocusPointMode && (
<div className="absolute inset-0 bg-black/20 flex items-center justify-center">
<div className="text-white text-center p-2 bg-black/50 rounded">
<p className="text-sm font-medium">
Click anywhere to set focus point
</p>
<button
className="mt-2 px-3 py-1 bg-blue-500 text-white text-xs rounded hover:bg-blue-600"
onClick={(e) => {
e.stopPropagation();
toggleFocusPointMode();
}}
>
Done
</button>
</div>
{/* Focus point marker */}
<div
className="absolute w-8 h-8 border-2 border-white rounded-full transform -translate-x-1/2 -translate-y-1/2 pointer-events-none"
style={{
left: `${focusPoint.x}%`,
top: `${focusPoint.y}%`,
boxShadow: "0 0 0 2px rgba(0,0,0,0.5)",
}}
>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-2 h-2 bg-white rounded-full"></div>
</div>
<div className="absolute w-16 h-0.5 bg-white/70 left-1/2 -translate-x-1/2"></div>
<div className="absolute w-0.5 h-16 bg-white/70 top-1/2 -translate-y-1/2"></div>
</div>
</div>
)}
{/* Image Toolbar */}
{isToolbarOpen && !isFocusPointMode && (
<div
ref={toolbarRef}
className="absolute bottom-2 left-1/2 transform -translate-x-1/2 bg-white rounded-full shadow-lg z-10 toolbar-popover"
>
<div className="flex items-center p-1 space-x-1">
<ToolTip content="Edit">
<button
className="p-2 hover:bg-gray-100 rounded-full transition-colors "
onClick={handleOpenEditor}
title="Edit Image"
>
<Edit className="w-4 h-4 text-gray-700" />
</button>
</ToolTip>
<ToolTip content="Focus Point">
<button
className="p-2 hover:bg-gray-100 rounded-full transition-colors "
onClick={toggleFocusPointMode}
title="Set Focus Point"
>
<Move className="w-4 h-4 text-gray-700" />
</button>
</ToolTip>
<Popover>
<PopoverTrigger asChild>
<button
className="p-2 hover:bg-gray-100 rounded-full transition-colors "
title="Fit Options"
>
<Maximize className="w-4 h-4 text-gray-700" />
</button>
</PopoverTrigger>
<PopoverContent className="w-36 p-2" ref={popoverContentRef}>
<div className="flex flex-col space-y-1">
<button
className={cn(
"text-left px-2 py-1 text-sm rounded flex items-center",
objectFit === "cover"
? "bg-blue-100 text-blue-800"
: "hover:bg-gray-100"
)}
onClick={(e) => {
e.preventDefault();
handleFitChange("cover");
}}
>
<div className="w-4 h-4 mr-2 border border-current rounded overflow-hidden relative">
<div className="absolute inset-0 bg-current opacity-20"></div>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-2 h-3 bg-current rounded-sm"></div>
</div>
</div>
Cover
</button>
<button
className={cn(
"text-left px-2 py-1 text-sm rounded flex items-center",
objectFit === "contain"
? "bg-blue-100 text-blue-800"
: "hover:bg-gray-100"
)}
onClick={(e) => {
e.preventDefault();
handleFitChange("contain");
}}
>
<div className="w-4 h-4 mr-2 border border-current rounded overflow-hidden relative">
<div className="absolute inset-0 bg-current opacity-20"></div>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-3 h-2 bg-current rounded-sm"></div>
</div>
</div>
Contain
</button>
<button
className={cn(
"text-left px-2 py-1 text-sm rounded flex items-center",
objectFit === "fill"
? "bg-blue-100 text-blue-800"
: "hover:bg-gray-100"
)}
onClick={(e) => {
e.preventDefault();
handleFitChange("fill");
}}
>
<div className="w-4 h-4 mr-2 border border-current rounded overflow-hidden relative">
<div className="absolute inset-0 bg-current opacity-20"></div>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-3 h-3 bg-current rounded-sm"></div>
</div>
</div>
Fill
</button>
</div>
</PopoverContent>
</Popover>
</div>
</div>
)}
</div>
<Sheet open={isEditorOpen} onOpenChange={setIsEditorOpen}>
<SheetContent
side="right"
className="w-[600px]"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<SheetHeader>
<SheetTitle>Update Image</SheetTitle>
</SheetHeader>
<div className="mt-6">
<Tabs defaultValue="generate" className="w-full">
<TabsList className="grid bg-blue-100 border border-blue-300 w-full grid-cols-2 mx-auto ">
<TabsTrigger className="font-medium" value="generate">
AI Generate
</TabsTrigger>
<TabsTrigger className="font-medium" value="upload">
Upload
</TabsTrigger>
</TabsList>
<TabsContent value="generate" className="mt-4 space-y-4">
<div></div>
<div className="space-y-4">
<div className="">
<h3 className="text-sm font-medium mb-1">Current Prompt</h3>
<p className="text-sm text-gray-500">{promptContent}</p>
</div>
<div>
<h3 className="text-base font-medium mb-2">
Image Description
</h3>
<Textarea
placeholder="Describe the image you want to generate..."
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[100px]"
/>
</div>
<Button
onClick={handleGenerateImage}
className="w-full"
disabled={!prompt || isGenerating}
>
<Wand2 className="w-4 h-4 mr-2" />
{isGenerating ? "Generating..." : "Generate Image"}
</Button>
{error && <p className="text-red-500 text-sm">{error}</p>}
<div className="grid grid-cols-2 gap-4">
{isGenerating || previewImages.length === 0
? Array.from({ length: 4 }).map((_, index) => (
<Skeleton
key={index}
className="aspect-[4/3] w-full rounded-lg"
/>
))
: previewImages.map((image, index) => (
<div
key={index}
onClick={() => handleImageChange(image as string)}
className="aspect-[4/3] w-full overflow-hidden rounded-lg border cursor-pointer"
>
<img
src={
image
? getStaticFileUrl(image)
: ""
}
alt={`Preview ${index + 1}`}
className="w-full h-full object-cover"
/>
</div>
))}
</div>
</div>
</TabsContent>
<TabsContent value="upload" className="mt-4 space-y-4">
<div className="space-y-4">
<TabsContent value="edit" className="mt-4 space-y-4">
<div className="space-y-4">
{/* Current Image Preview */}
<div className="space-y-2">
<h3 className="text-base font-medium">Current Image</h3>
<div
className={cn(
"border-2 border-dashed rounded-lg p-8 text-center transition-colors",
isUploading
? "border-gray-400 bg-gray-50"
: "border-gray-300"
)}
ref={imageContainerRef}
className="relative aspect-[4/3] w-full overflow-hidden rounded-lg border bg-gray-100"
>
<input
type="file"
id="file-upload"
className="hidden"
accept="image/*"
onChange={handleFileUpload}
disabled={isUploading}
/>
<label
htmlFor="file-upload"
className={cn(
"flex flex-col items-center",
isUploading ? "cursor-wait" : "cursor-pointer"
)}
>
{isUploading ? (
<div className="w-8 h-8 border-2 border-gray-400 border-t-transparent rounded-full animate-spin mb-2" />
) : (
<Upload className="w-8 h-8 text-gray-500 mb-2" />
)}
<span className="text-sm text-gray-600">
{isUploading
? "Uploading your image..."
: "Click to upload an image"}
</span>
<span className="text-xs text-gray-500 mt-1">
Maximum file size: 5MB
</span>
</label>
</div>
{uploadError && (
<p className="text-red-500 text-sm text-center">
{uploadError}
</p>
)}
{(uploadedImageUrl || isUploading) && (
<div className="mt-4">
<h3 className="text-sm font-medium mb-2">
Uploaded Image Preview
</h3>
<div className="aspect-[4/3] relative rounded-lg overflow-hidden border border-gray-200">
{isUploading ? (
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
<div className="flex flex-col items-center">
<div className="w-8 h-8 border-2 border-gray-400 border-t-transparent rounded-full animate-spin mb-2" />
<span className="text-sm text-gray-500">
Processing...
</span>
</div>
</div>
) : (
uploadedImageUrl && (
<div
onClick={() =>
handleImageChange(uploadedImageUrl)
}
className="cursor-pointer group w-full h-full"
>
<img
src={getStaticFileUrl(uploadedImageUrl)}
alt="Uploaded preview"
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-200" />
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium">
Click to use this image
</span>
</div>
</div>
)
)}
{image ? (
<img
ref={imageRef}
src={image}
alt="Current image"
className="w-full h-full object-cover cursor-pointer"
style={{
objectFit: objectFit,
objectPosition: `${focusPoint.x}% ${focusPoint.y}%`,
}}
onClick={(e) => {
e.stopPropagation();
handleFocusPointClick(e);
}}
onError={(e) => {
console.error('Image failed to load:', image);
e.currentTarget.src = '/placeholder-image.png';
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
<div className="text-center">
<Upload className="w-8 h-8 mx-auto mb-2" />
<p className="text-sm">No image selected</p>
</div>
</div>
)}
{/* Focus Point Indicator */}
{isFocusPointMode && image && (
<div
className="absolute w-4 h-4 bg-blue-500 border-2 border-white rounded-full transform -translate-x-1/2 -translate-y-1/2 pointer-events-none shadow-lg"
style={{
left: `${focusPoint.x}%`,
top: `${focusPoint.y}%`,
}}
/>
)}
</div>
{/* Debug info */}
{image && (
<div className="text-xs text-gray-500 space-y-1">
<p><strong>Image Path:</strong> {image}</p>
<p><strong>Resolved URL:</strong> {image}</p>
<p><strong>Focus Point:</strong> {focusPoint.x.toFixed(1)}%, {focusPoint.y.toFixed(1)}%</p>
<p><strong>Object Fit:</strong> {objectFit}</p>
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
</SheetContent>
</Sheet>
</>
{/* Editing Controls */}
<div className="space-y-4">
{/* Focus Point Controls */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">Focus Point</h4>
<Button
variant={isFocusPointMode ? "default" : "outline"}
size="sm"
onClick={(e) => {
e.stopPropagation();
toggleFocusPointMode();
}}
disabled={!image}
>
<Move className="w-4 h-4 mr-2" />
{isFocusPointMode ? "Done" : "Adjust"}
</Button>
</div>
{isFocusPointMode && (
<p className="text-xs text-gray-500">
Click on the image above to set the focus point
</p>
)}
</div>
{/* Object Fit Controls */}
<div className="space-y-2">
<h4 className="text-sm font-medium">Image Fit</h4>
<div className="flex gap-2">
<Button
variant={objectFit === "cover" ? "default" : "outline"}
size="sm"
onClick={(e) => {
e.stopPropagation();
handleFitChange("cover");
}}
className="flex-1"
disabled={!image}
>
Cover
</Button>
<Button
variant={objectFit === "contain" ? "default" : "outline"}
size="sm"
onClick={(e) => {
e.stopPropagation();
handleFitChange("contain");
}}
className="flex-1"
disabled={!image}
>
Contain
</Button>
<Button
variant={objectFit === "fill" ? "default" : "outline"}
size="sm"
onClick={(e) => {
e.stopPropagation();
handleFitChange("fill");
}}
className="flex-1"
disabled={!image}
>
Fill
</Button>
</div>
<div className="text-xs text-gray-500 space-y-1">
<p><strong>Cover:</strong> Fill container, may crop image</p>
<p><strong>Contain:</strong> Fit entire image, may show empty space</p>
<p><strong>Fill:</strong> Stretch to fill container exactly</p>
</div>
</div>
{/* Quick Actions */}
<div className="pt-2 border-t">
<h4 className="text-sm font-medium mb-2">Quick Actions</h4>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
setFocusPoint({ x: 50, y: 50 });
setObjectFit("cover");
saveImageProperties("cover", { x: 50, y: 50 });
if (imageRef.current) {
imageRef.current.style.objectFit = "cover";
imageRef.current.style.objectPosition = "50% 50%";
}
}}
className="flex-1"
disabled={!image}
>
Reset to Default
</Button>
</div>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="generate" className="mt-4 space-y-4">
<div></div>
<div className="space-y-4">
<div className="">
<h3 className="text-sm font-medium mb-1">Current Prompt</h3>
<p className="text-sm text-gray-500">{promptContent}</p>
</div>
<div>
<h3 className="text-base font-medium mb-2">
Image Description
</h3>
<Textarea
placeholder="Describe the image you want to generate..."
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[100px]"
/>
</div>
<Button
onClick={handleGenerateImage}
className="w-full"
disabled={!prompt || isGenerating}
>
<Wand2 className="w-4 h-4 mr-2" />
{isGenerating ? "Generating..." : "Generate Image"}
</Button>
{error && <p className="text-red-500 text-sm">{error}</p>}
<div className="grid grid-cols-2 gap-4">
{isGenerating || previewImages.length === 0
? Array.from({ length: 4 }).map((_, index) => (
<Skeleton
key={index}
className="aspect-[4/3] w-full rounded-lg"
/>
))
: previewImages.map((image, index) => (
<div
key={index}
onClick={() => handleImageChange(image as string)}
className="aspect-[4/3] w-full overflow-hidden rounded-lg border cursor-pointer"
>
<img
src={
image
? getStaticFileUrl(image)
: ""
}
alt={`Preview ${index + 1}`}
className="w-full h-full object-cover"
/>
</div>
))}
</div>
</div>
</TabsContent>
<TabsContent value="upload" className="mt-4 space-y-4">
<div className="space-y-4">
<div
className={cn(
"border-2 border-dashed rounded-lg p-8 text-center transition-colors",
isUploading
? "border-gray-400 bg-gray-50"
: "border-gray-300"
)}
>
<input
type="file"
id="file-upload"
className="hidden"
accept="image/*"
onChange={handleFileUpload}
disabled={isUploading}
/>
<label
htmlFor="file-upload"
className={cn(
"flex flex-col items-center",
isUploading ? "cursor-wait" : "cursor-pointer"
)}
>
{isUploading ? (
<div className="w-8 h-8 border-2 border-gray-400 border-t-transparent rounded-full animate-spin mb-2" />
) : (
<Upload className="w-8 h-8 text-gray-500 mb-2" />
)}
<span className="text-sm text-gray-600">
{isUploading
? "Uploading your image..."
: "Click to upload an image"}
</span>
<span className="text-xs text-gray-500 mt-1">
Maximum file size: 5MB
</span>
</label>
</div>
{uploadError && (
<p className="text-red-500 text-sm text-center">
{uploadError}
</p>
)}
{(uploadedImageUrl || isUploading) && (
<div className="mt-4">
<h3 className="text-sm font-medium mb-2">
Uploaded Image Preview
</h3>
<div className="aspect-[4/3] relative rounded-lg overflow-hidden border border-gray-200">
{isUploading ? (
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
<div className="flex flex-col items-center">
<div className="w-8 h-8 border-2 border-gray-400 border-t-transparent rounded-full animate-spin mb-2" />
<span className="text-sm text-gray-500">
Processing...
</span>
</div>
</div>
) : (
uploadedImageUrl && (
<div
onClick={() =>
handleImageChange(uploadedImageUrl)
}
className="cursor-pointer group w-full h-full"
>
<img
src={uploadedImageUrl}
alt="Uploaded preview"
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-200" />
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium">
Click to use this image
</span>
</div>
</div>
)
)}
</div>
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
</SheetContent>
</Sheet>
);
};

View file

@ -0,0 +1,306 @@
"use client";
import React, { createContext, useContext, useRef, useEffect, ReactNode, useState } from 'react';
import ReactDOM from 'react-dom';
import ImageEditor from './ImageEditor';
import IconsEditor from './IconsEditor';
interface EditableElement {
type: 'image' | 'icon';
element: HTMLElement;
dataPath?: string;
props: any;
}
interface SmartEditableContextType {
slideIndex: number;
slideId: string;
isEditMode: boolean;
slideData: any;
}
interface SmartEditableProviderProps {
children: ReactNode;
slideIndex: number;
slideId: string;
slideData: any;
isEditMode?: boolean;
}
const SmartEditableContext = createContext<SmartEditableContextType | null>(null);
export const useSmartEditable = () => {
const context = useContext(SmartEditableContext);
if (!context) {
throw new Error('useSmartEditable must be used within SmartEditableProvider');
}
return context;
};
export const SmartEditableProvider: React.FC<SmartEditableProviderProps> = ({
children,
slideIndex,
slideId,
slideData,
isEditMode = true,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [editableElements, setEditableElements] = useState<EditableElement[]>([]);
const [activeEditor, setActiveEditor] = useState<{
type: 'image' | 'icon';
element: HTMLElement;
props: any;
rect: DOMRect;
} | null>(null);
useEffect(() => {
if (!isEditMode || !containerRef.current || !slideData) return;
const container = containerRef.current;
const findEditableElements = () => {
const elements: EditableElement[] = [];
console.log('🔍 Starting smart detection with slideData:', slideData);
// Detect Images and Icons only (text is now handled by SmartText components)
const detectEditableElementsFromData = (data: any, path: string = '') => {
if (!data || typeof data !== 'object') return;
// Check for __image_url__ pattern
if (data.__image_url__) {
console.log(`📸 Found __image_url__ at ${path}:`, data.__image_url__);
const imgElement = findDOMElementByImageUrl(container, data.__image_url__);
if (imgElement) {
elements.push({
type: 'image',
element: imgElement,
dataPath: path,
props: {
slideIndex,
initialImage: data.__image_url__,
promptContent: data.__image_prompt__ || '',
imageIdx: elements.filter(e => e.type === 'image').length
}
});
console.log(`✅ Matched image to DOM element:`, imgElement);
}
}
// Check for __icon_url__ pattern
if (data.__icon_url__) {
console.log(`🎯 Found __icon_url__ at ${path}:`, data.__icon_url__);
const imgElement = findDOMElementByImageUrl(container, data.__icon_url__);
if (imgElement) {
elements.push({
type: 'icon',
element: imgElement,
dataPath: path,
props: {
slideIndex,
elementId: `icon-${path.replace(/[^\w]/g, '-')}`,
icon: data.__icon_url__,
index: elements.filter(e => e.type === 'icon').length,
backgroundColor: '#3B82F6',
hasBg: false,
icon_prompt: data.__icon_query__ ? [data.__icon_query__] : []
}
});
console.log(`✅ Matched icon to DOM element:`, imgElement);
}
}
// Recursively scan nested objects and arrays
Object.keys(data).forEach(key => {
const value = data[key];
const newPath = path ? `${path}.${key}` : key;
if (Array.isArray(value)) {
value.forEach((item, index) => {
detectEditableElementsFromData(item, `${newPath}[${index}]`);
});
} else if (value && typeof value === 'object') {
detectEditableElementsFromData(value, newPath);
}
});
};
detectEditableElementsFromData(slideData);
console.log('🎉 Final detected elements:', elements);
setEditableElements(elements);
};
const findDOMElementByImageUrl = (container: HTMLElement, targetUrl: string): HTMLImageElement | null => {
const allImages = Array.from(container.getElementsByTagName('img'));
for (const img of allImages) {
if (isMatchingImageUrl(img.src, targetUrl)) {
return img;
}
}
return null;
};
const isMatchingImageUrl = (domSrc: string, dataSrc: string): boolean => {
// Direct match
if (domSrc === dataSrc) return true;
// Handle app_data paths
if (dataSrc.includes('/app_data/images/') || domSrc.includes('/app_data/images/')) {
const getFilename = (path: string) => path.split('/').pop() || '';
return getFilename(domSrc) === getFilename(dataSrc);
}
// Handle placeholder URLs
if (dataSrc.includes('placeholder') || domSrc.includes('placeholder')) {
return true;
}
// Extract and compare filenames
const getFilename = (path: string) => path.split('/').pop() || '';
return getFilename(domSrc) === getFilename(dataSrc) && getFilename(domSrc) !== '';
};
// Set up event listeners after elements are found
const timer = setTimeout(() => {
findEditableElements();
}, 500);
return () => {
clearTimeout(timer);
};
}, [slideIndex, slideId, slideData, isEditMode]); // Removed editableElements from dependency array
// Set up event listeners when editableElements change
useEffect(() => {
if (!containerRef.current || editableElements.length === 0) return;
const container = containerRef.current;
const handleClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
// Handle image/icon clicks only
if (target.tagName === 'IMG') {
const imgElement = target as HTMLImageElement;
const editableElement = editableElements.find(el => el.element === imgElement);
if (editableElement && (editableElement.type === 'image' || editableElement.type === 'icon')) {
event.preventDefault();
event.stopPropagation();
const rect = imgElement.getBoundingClientRect();
setActiveEditor({
type: editableElement.type,
element: imgElement,
props: editableElement.props,
rect
});
}
}
};
const handleMouseEnter = (event: MouseEvent) => {
const target = event.target as HTMLElement;
// Handle image/icon hover only
if (target.tagName === 'IMG') {
const imgElement = target as HTMLImageElement;
const isEditable = editableElements.some(el => el.element === imgElement);
if (isEditable) {
imgElement.style.cursor = 'pointer';
imgElement.style.filter = 'brightness(0.9)';
imgElement.style.transition = 'filter 0.2s ease';
}
}
};
const handleMouseLeave = (event: MouseEvent) => {
const target = event.target as HTMLElement;
// Handle image/icon hover only
if (target.tagName === 'IMG') {
const imgElement = target as HTMLImageElement;
const isEditable = editableElements.some(el => el.element === imgElement);
if (isEditable) {
imgElement.style.filter = '';
}
}
};
container.addEventListener('click', handleClick);
container.addEventListener('mouseenter', handleMouseEnter, true);
container.addEventListener('mouseleave', handleMouseLeave, true);
return () => {
container.removeEventListener('click', handleClick);
container.removeEventListener('mouseenter', handleMouseEnter, true);
container.removeEventListener('mouseleave', handleMouseLeave, true);
};
}, [editableElements]);
return (
<SmartEditableContext.Provider value={{ slideIndex, slideId, isEditMode, slideData }}>
<div ref={containerRef} className="smart-editable-container">
{children}
</div>
{/* Render active editor as a modal/overlay */}
{activeEditor && (
<EditorOverlay
activeEditor={activeEditor}
onClose={() => setActiveEditor(null)}
/>
)}
</SmartEditableContext.Provider>
);
};
// Simple overlay component for editors
const EditorOverlay: React.FC<{
activeEditor: {
type: 'image' | 'icon';
element: HTMLElement;
props: any;
rect: DOMRect;
};
onClose: () => void;
}> = ({ activeEditor, onClose }) => {
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const handleClickOutside = (e: MouseEvent) => {
// Close if clicked outside the editor
const target = e.target as HTMLElement;
if (!target.closest('.editor-modal')) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('click', handleClickOutside);
};
}, [onClose]);
// Handle image/icon editing in modal
const EditorComponent = activeEditor.type === 'image' ? ImageEditor : IconsEditor;
return ReactDOM.createPortal(
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div className="editor-modal">
<EditorComponent
{...activeEditor.props}
onClose={onClose}
/>
</div>
</div>,
document.body
);
};

View file

@ -4,16 +4,6 @@ import { useEditor, EditorContent, BubbleMenu } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "tiptap-markdown";
import Underline from "@tiptap/extension-underline";
import { useDispatch, useSelector } from "react-redux";
import {
updateInfographicsDescription,
updateInfographicsTitle,
updateSlideBodyDescription,
updateSlideBodyHeading,
updateSlideBodyString,
updateSlideDescription,
updateSlideTitle,
} from "@/store/slices/presentationGeneration";
import {
Bold,
Italic,
@ -21,129 +11,24 @@ import {
Strikethrough,
Code,
} from "lucide-react";
import { RootState } from "@/store/store";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
fontSize: {
/**
* Set the font size
*/
setFontSize: (size: string) => ReturnType;
/**
* Unset the font size
*/
unsetFontSize: () => ReturnType;
};
}
}
const TipTapEditor = ({
content,
isAlingCenter,
bodyIdx,
slideIndex,
elementId,
type,
}: {
content: string;
isAlingCenter: boolean;
bodyIdx: number;
slideIndex: number;
elementId: string;
type: string;
}) => {
const dispatch = useDispatch();
const { currentColors } = useSelector((state: RootState) => state.theme);
const getTextStyle = () => {
const baseStyle = "outline-none transition-all duration-200 ";
switch (type) {
case "title":
return `${baseStyle} slide-title text-xl sm:text-2xl lg:text-[40px] leading-[36px] lg:leading-[48px] font-bold `;
case "heading":
case "info-heading":
return `${baseStyle} slide-heading text-base sm:text-lg lg:text-[24px] leading-[26px] lg:leading-[32px] font-bold`;
case "description":
case "info-description":
case "description-body":
case "heading-description":
return `${baseStyle} slide-description text-sm sm:text-base lg:text-[20px] leading-[20px] lg:leading-[30px] font-normal`;
default:
return `${baseStyle} slide-description text-sm sm:text-base lg:text-[20px] leading-[20px] lg:leading-[30px] font-normal`;
}
};
const updateSlide = (type: string, value: string) => {
switch (type) {
case "title": {
dispatch(updateSlideTitle({ index: slideIndex, title: value }));
break;
}
case "heading": {
dispatch(
updateSlideBodyHeading({
index: slideIndex,
bodyIdx: bodyIdx,
heading: value,
})
);
break;
}
case "description": {
dispatch(
updateSlideDescription({ index: slideIndex, description: value })
);
break;
}
case "heading-description": {
dispatch(
updateSlideBodyDescription({
index: slideIndex,
bodyIdx: bodyIdx,
description: value,
})
);
break;
}
case "description-body": {
dispatch(updateSlideBodyString({ index: slideIndex, body: value }));
break;
}
case "info-heading": {
dispatch(
updateInfographicsTitle({
slideIndex: slideIndex,
itemIdx: bodyIdx,
title: value,
})
);
break;
}
case "info-description": {
dispatch(
updateInfographicsDescription({
slideIndex: slideIndex,
itemIdx: bodyIdx,
description: value,
})
);
break;
}
default:
break;
}
};
const editor = useEditor({
extensions: [StarterKit, Markdown, Underline],
content: content,
editorProps: {
attributes: {
class: "outline-none transition-all duration-200",
},
},
immediatelyRender: false,
});
@ -190,18 +75,13 @@ const TipTapEditor = ({
</BubbleMenu>
<EditorContent
className={`min-w-[100px] w-full max-md:pointer-events-none ${getTextStyle()} ${editor?.getText() ? "" : `hover:outline hover:outline-gray-400`
className={`min-w-[100px] w-full max-md:pointer-events-none ${editor?.getText() ? "" : `hover:outline hover:outline-gray-400`
} `}
onBlur={() => {
const markdown = editor?.storage.markdown.getMarkdown();
updateSlide(type, markdown || "");
console.log("🔍 markdown", markdown);
}}
data-slide-element
data-text-content={editor?.storage.markdown.getMarkdown()}
data-is-align={isAlingCenter}
data-slide-index={slideIndex}
data-element-type="text"
data-element-id={elementId}
editor={editor}
/>
</div>

View file

@ -0,0 +1,129 @@
"use client";
import React, { useEffect } from 'react';
import { useEditor, EditorContent, BubbleMenu } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "tiptap-markdown";
import Underline from "@tiptap/extension-underline";
import {
Bold,
Italic,
Underline as UnderlinedIcon,
Strikethrough,
Code,
} from "lucide-react";
interface TiptapTextProps {
content: string;
onContentChange?: (content: string) => void;
className?: string;
placeholder?: string;
tag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span' | 'div';
disabled?: boolean;
}
const TiptapText: React.FC<TiptapTextProps> = ({
content,
onContentChange,
className = "",
placeholder = "Enter text...",
tag = 'div',
disabled = false
}) => {
const editor = useEditor({
extensions: [StarterKit, Markdown, Underline],
content: content || placeholder,
editorProps: {
attributes: {
class: `outline-none focus:outline-none transition-all duration-200 ${className}`,
'data-placeholder': placeholder,
},
},
onBlur: ({ editor }) => {
const text = editor.getText();
if (onContentChange) {
onContentChange(text);
}
},
editable: !disabled,
immediatelyRender: false,
});
// Update editor content when content prop changes
useEffect(() => {
if (editor && content !== editor.getText()) {
editor.commands.setContent(content || placeholder);
}
}, [content, editor, placeholder]);
if (!editor) {
return <div className={className}>{content || placeholder}</div>;
}
return (
<div className="relative w-full">
{!disabled && (
<BubbleMenu editor={editor} tippyOptions={{ duration: 100 }}>
<div className="flex bg-white rounded-lg shadow-lg p-2 gap-1 border border-gray-200 z-50">
<button
onClick={() => editor?.chain().focus().toggleBold().run()}
className={`p-1 rounded hover:bg-gray-100 transition-colors ${editor?.isActive("bold") ? "bg-blue-100 text-blue-600" : ""
}`}
title="Bold"
>
<Bold className="h-4 w-4" />
</button>
<button
onClick={() => editor?.chain().focus().toggleItalic().run()}
className={`p-1 rounded hover:bg-gray-100 transition-colors ${editor?.isActive("italic") ? "bg-blue-100 text-blue-600" : ""
}`}
title="Italic"
>
<Italic className="h-4 w-4" />
</button>
<button
onClick={() => editor?.chain().focus().toggleUnderline().run()}
className={`p-1 rounded hover:bg-gray-100 transition-colors ${editor?.isActive("underline") ? "bg-blue-100 text-blue-600" : ""
}`}
title="Underline"
>
<UnderlinedIcon className="h-4 w-4" />
</button>
<button
onClick={() => editor?.chain().focus().toggleStrike().run()}
className={`p-1 rounded hover:bg-gray-100 transition-colors ${editor?.isActive("strike") ? "bg-blue-100 text-blue-600" : ""
}`}
title="Strikethrough"
>
<Strikethrough className="h-4 w-4" />
</button>
<button
onClick={() => editor?.chain().focus().toggleCode().run()}
className={`p-1 rounded hover:bg-gray-100 transition-colors ${editor?.isActive("code") ? "bg-blue-100 text-blue-600" : ""
}`}
title="Code"
>
<Code className="h-4 w-4" />
</button>
</div>
</BubbleMenu>
)}
<EditorContent
editor={editor}
className={`tiptap-text-editor w-full ${!disabled ? 'min-h-[1.5em]' : ''}`}
style={{
// Ensure the editor maintains the same visual appearance
lineHeight: 'inherit',
fontSize: 'inherit',
fontWeight: 'inherit',
fontFamily: 'inherit',
color: 'inherit',
textAlign: 'inherit',
}}
/>
</div>
);
};
export default TiptapText;

View file

@ -0,0 +1,220 @@
"use client";
import { renderToStaticMarkup } from 'react-dom/server';
import React, { useRef, useEffect, useState, ReactNode } from 'react';
import ReactDOM from 'react-dom/client';
import TiptapText from './TiptapText';
interface TiptapTextReplacerProps {
layout: React.ComponentType<{
data: any;
}>;
children: ReactNode;
slideData?: any;
onContentChange?: (content: string, path: string) => void;
isEditMode?: boolean;
}
const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
children,
slideData,
layout,
onContentChange = () => { },
isEditMode = true
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [processedElements, setProcessedElements] = useState(new Set<HTMLElement>());
useEffect(() => {
if (!isEditMode || !containerRef.current) return;
const container = containerRef.current;
const replaceTextElements = () => {
// Get all elements in the container
const allElements = container.querySelectorAll('*');
allElements.forEach((element) => {
const htmlElement = element as HTMLElement;
// Skip if already processed
if (processedElements.has(htmlElement) ||
htmlElement.classList.contains('tiptap-text-editor') ||
htmlElement.closest('.tiptap-text-editor')) {
return;
}
// Get direct text content (not from child elements)
const directTextContent = getDirectTextContent(htmlElement);
const trimmedText = directTextContent.trim();
// Check if element has meaningful text content
if (!trimmedText || trimmedText.length < 2) return;
// Skip elements that contain other elements with text (to avoid double processing)
if (hasTextChildren(htmlElement)) return;
// Skip certain element types that shouldn't be editable
if (shouldSkipElement(htmlElement)) return;
console.log('Making element editable:', trimmedText, htmlElement);
// Get all computed styles to preserve them
const computedStyles = window.getComputedStyle(htmlElement);
const preservedStyles = {
fontSize: computedStyles.fontSize,
fontWeight: computedStyles.fontWeight,
fontFamily: computedStyles.fontFamily,
color: computedStyles.color,
lineHeight: computedStyles.lineHeight,
textAlign: computedStyles.textAlign,
marginTop: computedStyles.marginTop,
marginBottom: computedStyles.marginBottom,
marginLeft: computedStyles.marginLeft,
marginRight: computedStyles.marginRight,
paddingTop: computedStyles.paddingTop,
paddingBottom: computedStyles.paddingBottom,
paddingLeft: computedStyles.paddingLeft,
paddingRight: computedStyles.paddingRight,
};
// Try to find matching data path
const dataPath = findDataPath(slideData, trimmedText);
// Create a container for the TiptapText
const tiptapContainer = document.createElement('div');
tiptapContainer.className = htmlElement.className;
// Apply preserved styles
Object.entries(preservedStyles).forEach(([property, value]) => {
if (value && value !== 'auto') {
tiptapContainer.style.setProperty(
property.replace(/([A-Z])/g, '-$1').toLowerCase(),
value
);
}
});
// Replace the element
htmlElement.parentNode?.replaceChild(tiptapContainer, htmlElement);
// Mark as processed
setProcessedElements(prev => new Set(prev).add(htmlElement));
// Render TiptapText
const root = ReactDOM.createRoot(tiptapContainer);
root.render(
<TiptapText
content={trimmedText}
onContentChange={(content: string) => {
if (dataPath && onContentChange) {
onContentChange(content, dataPath);
}
}}
placeholder="Enter text..."
disabled={!isEditMode}
/>
);
});
};
// Helper function to get only direct text content (not from children)
const getDirectTextContent = (element: HTMLElement): string => {
let text = '';
const childNodes = Array.from(element.childNodes);
for (const node of childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent || '';
}
}
return text;
};
// Helper function to check if element has child elements with text
const hasTextChildren = (element: HTMLElement): boolean => {
const children = Array.from(element.children) as HTMLElement[];
return children.some(child => {
const childText = getDirectTextContent(child).trim();
return childText.length > 1;
});
};
// Helper function to determine if element should be skipped
const shouldSkipElement = (element: HTMLElement): boolean => {
// Skip form elements
if (['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes(element.tagName)) {
return true;
}
// Skip elements with certain roles or types
if (element.hasAttribute('role') ||
element.hasAttribute('aria-label') ||
element.hasAttribute('data-testid')) {
return true;
}
// Skip elements that contain interactive content
if (element.querySelector('img, svg, button, input, textarea, select, a[href]')) {
return true;
}
// Skip container elements (elements that primarily serve as layout containers)
const containerClasses = ['grid', 'flex', 'space-', 'gap-', 'container', 'wrapper'];
const hasContainerClass = containerClasses.some(cls =>
element.className.includes(cls)
);
if (hasContainerClass) return true;
// Skip very short text that might be UI elements
const text = getDirectTextContent(element).trim();
if (text.length < 2) return true;
// Skip elements that look like numbers or single characters (might be icons/UI)
if (/^[0-9]+$/.test(text) || text.length === 1) return true;
return false;
};
// Helper function to find data path for text content
const findDataPath = (data: any, targetText: string, path = ''): string => {
if (!data || typeof data !== 'object') return '';
for (const [key, value] of Object.entries(data)) {
const currentPath = path ? `${path}.${key}` : key;
if (typeof value === 'string' && value.trim() === targetText.trim()) {
return currentPath;
}
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const result = findDataPath(value[i], targetText, `${currentPath}[${i}]`);
if (result) return result;
}
} else if (typeof value === 'object' && value !== null) {
const result = findDataPath(value, targetText, currentPath);
if (result) return result;
}
}
return '';
};
// Replace text elements after a short delay to ensure DOM is ready
const timer = setTimeout(replaceTextElements, 500);
return () => {
clearTimeout(timer);
};
}, [slideData, isEditMode]);
return (
<div ref={containerRef} className="tiptap-text-replacer">
{children}
</div>
);
};
export default TiptapTextReplacer;

View file

@ -1,6 +1,8 @@
'use client'
import React, { useMemo } from 'react';
import { useLayout } from '../context/LayoutContext';
import { SmartEditableProvider } from '../components/SmartEditableWrapper';
import TiptapTextReplacer from '../components/TiptapTextReplacer';
export const useGroupLayouts = () => {
const {
@ -28,9 +30,9 @@ export const useGroupLayouts = () => {
};
}, [getLayoutsByGroup]);
// Render slide content with group validation
// Render slide content with group validation and automatic Tiptap text editing
const renderSlideContent = useMemo(() => {
return (slide: any) => {
return (slide: any, isEditMode: boolean = true) => {
const Layout = getGroupLayout(slide.layout, slide.layout_group);
if (!Layout) {
return (
@ -41,6 +43,29 @@ export const useGroupLayouts = () => {
</div>
);
}
if (isEditMode) {
return (
<SmartEditableProvider
slideIndex={slide.index}
slideId={slide.id || `slide-${slide.index}`}
slideData={slide.content}
isEditMode={isEditMode}
>
<TiptapTextReplacer
slideData={slide.content}
isEditMode={isEditMode}
layout={Layout}
onContentChange={(content: string, dataPath: string) => {
console.log(`Text content changed at ${dataPath}:`, content);
}}
>
<Layout data={slide.content} />
</TiptapTextReplacer>
</SmartEditableProvider>
);
}
return <Layout data={slide.content} />;
};
}, [getGroupLayout]);

View file

@ -274,7 +274,7 @@ const SidePanel = ({
<div className=" bg-white relative overflow-hidden aspect-video">
<div className="absolute bg-gray-100/5 z-40 top-0 left-0 w-full h-full" />
<div className="transform scale-[0.2] flex justify-center items-center origin-top-left w-[500%] h-[500%]">
{renderSlideContent(slide)}
{renderSlideContent(slide, false)}
</div>
</div>
</div>
@ -294,7 +294,7 @@ const SidePanel = ({
index={index}
selectedSlide={selectedSlide}
onSlideClick={onSlideClick}
renderSlideContent={renderSlideContent}
renderSlideContent={(slide) => renderSlideContent(slide, false)}
/>
))}
</SortableContext>

View file

@ -39,22 +39,7 @@ const SlideContent = ({
);
// Use the centralized group layouts hook
const { getGroupLayout, loading } = useGroupLayouts();
// Memoized layout component to prevent re-renders
const LayoutComponent = useMemo(() => {
const Layout = getGroupLayout(slide.layout, slide.layout_group);
if (!Layout) {
return () => (
<div className="flex flex-col items-center justify-center h-full bg-gray-100 rounded-lg">
<p className="text-gray-600 text-center">
Layout "{slide.layout}" not found in current group
</p>
</div>
);
}
return Layout;
}, [slide.layout, getGroupLayout]);
const { renderSlideContent, loading } = useGroupLayouts();
const handleSubmit = async () => {
const element = document.getElementById(
@ -142,8 +127,8 @@ const SlideContent = ({
// Memoized slide content rendering to prevent unnecessary re-renders
const slideContent = useMemo(() => {
return <LayoutComponent data={slide.content} />;
}, [LayoutComponent, slide.content]);
return renderSlideContent(slide, isStreaming ? false : true); // Enable edit mode for main content
}, [renderSlideContent, slide, isStreaming]);
return (
<>

View file

@ -8,7 +8,7 @@ interface SortableSlideProps {
index: number;
selectedSlide: number;
onSlideClick: (index: any) => void;
renderSlideContent: (slide: any) => React.ReactElement;
renderSlideContent: (slide: any, isEditMode?: boolean) => React.ReactElement;
}
export function SortableSlide({ slide, index, selectedSlide, onSlideClick, renderSlideContent }: SortableSlideProps) {
@ -57,7 +57,7 @@ export function SortableSlide({ slide, index, selectedSlide, onSlideClick, rende
<div className=" slide-box relative overflow-hidden aspect-video">
<div className="absolute bg-transparent z-40 top-0 left-0 w-full h-full" />
<div className="transform scale-[0.2] flex justify-center items-center origin-top-left w-[500%] h-[500%]">
{renderSlideContent(slide)}
{renderSlideContent(slide, false)}
</div>
</div>
</div>

View file

@ -1,6 +1,5 @@
import { useCallback } from "react";
import { useDispatch } from "react-redux";
import { useRouter } from "next/navigation";
import { toast } from "@/hooks/use-toast";
import { DashboardApi } from "@/app/dashboard/api/dashboard";
import { PresentationGenerationApi } from "../../services/api/presentation-generation";
@ -12,11 +11,12 @@ export const usePresentationData = (
setError: (error: boolean) => void
) => {
const dispatch = useDispatch();
const router = useRouter();
const fetchUserSlides = useCallback(async () => {
try {
const data = await DashboardApi.getPresentation(presentationId);
console.log('Presentation Data',data);
if (data) {
dispatch(setPresentationData(data));
setLoading(false);

View file

@ -35,7 +35,7 @@ export async function POST(request: NextRequest) {
// Return the relative path that can be used in the frontend
return NextResponse.json({
success: true,
filePath: `/app/user_data/uploads/${filename}`
filePath: `${userDataDir}/uploads/${filename}`
});
} catch (error) {
console.error("Error saving image:", error);

View file

@ -0,0 +1,365 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View file

@ -49,7 +49,7 @@
"react": "^18",
"react-dom": "^18",
"react-redux": "^9.1.2",
"recharts": "^2.15.0",
"recharts": "^2.15.4",
"sonner": "^2.0.6",
"tailwind-merge": "^2.5.3",
"tailwind-scrollbar-hide": "^2.0.0",

View file

@ -52,7 +52,7 @@
"react": "^18",
"react-dom": "^18",
"react-redux": "^9.1.2",
"recharts": "^2.15.0",
"recharts": "^2.15.4",
"sonner": "^2.0.6",
"tailwind-merge": "^2.5.3",
"tailwind-scrollbar-hide": "^2.0.0",

View file

@ -0,0 +1,343 @@
import React from 'react'
import * as z from "zod";
import { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent } from "@/components/ui/chart";
import { BarChart, Bar, LineChart, Line, PieChart, Pie, AreaChart, Area, ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Cell, ResponsiveContainer } from "recharts";
export const layoutId = 'chart-table-slide'
export const layoutName = 'Chart + Table Slide'
export const layoutDescription = 'A layout for displaying data visualization alongside detailed tabular data for comprehensive analysis.'
const chartDataSchema = z.object({
name: z.string().meta({ description: "Data point name" }),
value: z.number().meta({ description: "Data point value" }),
category: z.string().optional().meta({ description: "Category for grouping" }),
x: z.number().optional().meta({ description: "X coordinate for scatter plots" }),
y: z.number().optional().meta({ description: "Y coordinate for scatter plots" }),
});
const tableRowSchema = z.object({
metric: z.string().meta({ description: "Metric name" }),
value: z.string().meta({ description: "Metric value" }),
change: z.string().optional().meta({ description: "Change percentage or indicator" }),
status: z.string().optional().meta({ description: "Status or category" }),
});
const chartTableSlideSchema = z.object({
title: z.string().min(3).max(100).default('Revenue Analysis & Breakdown').meta({
description: "Main title of the slide",
}),
description: z.string().min(10).max(300).default('This comprehensive analysis combines visual trends with detailed metrics, providing both high-level insights and granular data for informed decision-making.').meta({
description: "Bottom description text",
}),
chartType: z.enum(['bar', 'line', 'pie', 'area', 'scatter']).default('bar').meta({
description: "Type of chart to display",
}),
chartData: z.array(chartDataSchema).min(2).max(10).default([
{ name: 'Q1', value: 4000 },
{ name: 'Q2', value: 3000 },
{ name: 'Q3', value: 5000 },
{ name: 'Q4', value: 4500 },
]).meta({
description: "Chart data points",
}),
tableData: z.array(tableRowSchema).min(3).max(15).default([
{ metric: 'Total Revenue', value: '$16.5M', change: '+12.5%', status: 'Growth' },
{ metric: 'Q1 Revenue', value: '$4.0M', change: '+8.2%', status: 'Stable' },
{ metric: 'Q2 Revenue', value: '$3.0M', change: '-15.3%', status: 'Decline' },
{ metric: 'Q3 Revenue', value: '$5.0M', change: '+25.8%', status: 'Growth' },
{ metric: 'Q4 Revenue', value: '$4.5M', change: '+18.4%', status: 'Growth' },
{ metric: 'Average Deal Size', value: '$125K', change: '+5.2%', status: 'Stable' },
{ metric: 'Customer Count', value: '132', change: '+22.1%', status: 'Growth' },
{ metric: 'Market Share', value: '18.3%', change: '+3.1%', status: 'Growth' },
{ metric: 'Conversion Rate', value: '24.7%', change: '+1.8%', status: 'Stable' },
{ metric: 'Customer Churn', value: '5.2%', change: '-2.1%', status: 'Improvement' },
]).meta({
description: "Table data rows",
}),
chartColor: z.string().default('#3b82f6').meta({
description: "Primary color for chart elements",
}),
dataKey: z.string().default('value').meta({
description: "Key field for chart values",
}),
categoryKey: z.string().default('name').meta({
description: "Key field for chart categories",
}),
showLegend: z.boolean().default(false).meta({
description: "Whether to show chart legend",
}),
showTooltip: z.boolean().default(true).meta({
description: "Whether to show chart tooltip",
}),
})
export const Schema = chartTableSlideSchema
export type ChartTableSlideData = z.infer<typeof chartTableSlideSchema>
interface ChartTableSlideLayoutProps {
data?: Partial<ChartTableSlideData>
}
const chartConfig = {
value: {
label: "Value",
},
name: {
label: "Name",
},
};
const CHART_COLORS = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1'
];
const ChartTableSlideLayout: React.FC<ChartTableSlideLayoutProps> = ({ data: slideData }) => {
const chartData = slideData?.chartData || [];
const tableData = slideData?.tableData || [];
const chartType = slideData?.chartType || 'bar';
const chartColor = slideData?.chartColor || '#3b82f6';
const dataKey = slideData?.dataKey || 'value';
const categoryKey = slideData?.categoryKey || 'name';
const showLegend = slideData?.showLegend || false;
const showTooltip = slideData?.showTooltip || true;
const renderChart = () => {
const commonProps = {
data: chartData,
margin: { top: 20, right: 20, left: 20, bottom: 40 },
};
switch (chartType) {
case 'bar':
return (
<BarChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={categoryKey} />
<YAxis />
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Bar dataKey={dataKey} fill={chartColor} radius={[4, 4, 0, 0]} />
</BarChart>
);
case 'line':
return (
<LineChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={categoryKey} />
<YAxis />
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Line
type="monotone"
dataKey={dataKey}
stroke={chartColor}
strokeWidth={3}
dot={{ fill: chartColor, strokeWidth: 2, r: 4 }}
/>
</LineChart>
);
case 'area':
return (
<AreaChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={categoryKey} />
<YAxis />
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Area
type="monotone"
dataKey={dataKey}
stroke={chartColor}
fill={chartColor}
fillOpacity={0.6}
/>
</AreaChart>
);
case 'pie':
return (
<PieChart margin={{ top: 20, right: 20, left: 20, bottom: 40 }}>
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Pie
data={chartData}
cx="50%"
cy="45%"
outerRadius={70}
fill={chartColor}
dataKey={dataKey}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
</Pie>
</PieChart>
);
case 'scatter':
return (
<ScatterChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="x" type="number" />
<YAxis dataKey="y" type="number" />
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Scatter dataKey="value" fill={chartColor} />
</ScatterChart>
);
default:
return <div>Unsupported chart type</div>;
}
};
const getStatusColor = (status: string) => {
switch (status?.toLowerCase()) {
case 'growth':
return 'text-green-600';
case 'decline':
return 'text-red-600';
case 'stable':
return 'text-blue-600';
case 'improvement':
return 'text-emerald-600';
default:
return 'text-gray-600';
}
};
return (
<>
{/* Import Google Fonts */}
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Nunito:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<div
className="w-full rounded-sm max-w-[1280px] shadow-md h-[720px] flex flex-col aspect-video bg-stone-100 relative z-20 mx-auto overflow-hidden"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{/* Glass overlay background */}
<div className="absolute inset-0 bg-white/20 backdrop-blur-sm rounded-sm border border-slate-200"></div>
<div className="relative z-10 flex flex-col w-full h-full p-4 sm:p-6 lg:p-8">
{/* Header section */}
<div className="flex-shrink-0 h-12 sm:h-16 flex items-center justify-center">
<h1
className="text-3xl font-bold text-gray-900 leading-tight text-center"
style={{
fontFamily: 'Space Grotesk, sans-serif'
}}
>
{slideData?.title || 'Revenue Analysis & Breakdown'}
</h1>
</div>
{/* Chart and Table section */}
<div className="flex-1 w-full overflow-hidden">
<div className="flex gap-6 h-full">
{/* Chart section - Left side */}
<div className="w-1/2 h-full">
<div className="bg-white/30 backdrop-blur-sm rounded-xl border border-slate-200 shadow-md p-4 sm:p-6 h-full overflow-hidden">
<ChartContainer config={chartConfig} className="h-full w-full max-h-[400px] sm:max-h-[420px] lg:max-h-[440px]">
{renderChart()}
</ChartContainer>
</div>
</div>
{/* Table section - Right side */}
<div className="w-1/2 h-full">
<div className="bg-white/30 backdrop-blur-sm rounded-xl border border-slate-200 shadow-md p-4 sm:p-6 h-full overflow-hidden">
<div className="overflow-y-auto max-h-[360px] sm:max-h-[420px] lg:max-h-[440px]">
<table className="w-full">
<thead className="border-t border-b border-gray-300">
<tr>
<th
className="text-left py-3 px-2 text-sm font-semibold text-gray-900"
style={{ fontFamily: 'Space Grotesk, sans-serif' }}
>
Metric
</th>
<th
className="text-right py-3 px-2 text-sm font-semibold text-gray-900"
style={{ fontFamily: 'Space Grotesk, sans-serif' }}
>
Value
</th>
<th
className="text-right py-3 px-2 text-sm font-semibold text-gray-900"
style={{ fontFamily: 'Space Grotesk, sans-serif' }}
>
Change
</th>
<th
className="text-center py-3 px-2 text-sm font-semibold text-gray-900"
style={{ fontFamily: 'Space Grotesk, sans-serif' }}
>
Status
</th>
</tr>
</thead>
<tbody>
{tableData.map((row, index) => (
<tr key={index} className="border-b border-gray-200 hover:bg-white/20">
<td
className="py-2 px-2 text-sm text-gray-700"
style={{ fontFamily: 'Nunito, sans-serif' }}
>
{row.metric}
</td>
<td
className="py-2 px-2 text-sm text-gray-900 text-right font-medium"
style={{ fontFamily: 'Nunito, sans-serif' }}
>
{row.value}
</td>
<td
className="py-2 px-2 text-sm text-right"
style={{ fontFamily: 'Nunito, sans-serif' }}
>
{row.change}
</td>
<td
className={`py-2 px-2 text-sm text-center font-medium ${getStatusColor(row.status || '')}`}
style={{ fontFamily: 'Nunito, sans-serif' }}
>
{row.status}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{/* Bottom Description section */}
<div className="flex-shrink-0 h-16 sm:h-20 flex items-center justify-center">
<p
className="text-sm sm:text-base text-center text-gray-700 leading-relaxed max-w-4xl px-4"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{slideData?.description || 'This comprehensive analysis combines visual trends with detailed metrics, providing both high-level insights and granular data for informed decision-making.'}
</p>
</div>
</div>
</div>
</>
)
}
export default ChartTableSlideLayout

View file

@ -0,0 +1,270 @@
import React from 'react'
import * as z from "zod";
import { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent } from "@/components/ui/chart";
import { BarChart, Bar, LineChart, Line, PieChart, Pie, AreaChart, Area, ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Cell, ResponsiveContainer } from "recharts";
export const layoutId = 'chart-with-summary-slide'
export const layoutName = 'Chart with Summary Slide'
export const layoutDescription = 'A layout for displaying data visualization with interpretation and summary points.'
const chartDataSchema = z.object({
name: z.string().meta({ description: "Data point name" }),
value: z.number().meta({ description: "Data point value" }),
category: z.string().optional().meta({ description: "Category for grouping" }),
x: z.number().optional().meta({ description: "X coordinate for scatter plots" }),
y: z.number().optional().meta({ description: "Y coordinate for scatter plots" }),
});
const summaryPointSchema = z.object({
point: z.string().min(5).max(200).meta({ description: "Summary point or insight" }),
});
const chartWithSummarySlideSchema = z.object({
title: z.string().min(3).max(100).default('Sales Performance Analysis').meta({
description: "Main title of the slide",
}),
chartType: z.enum(['bar', 'line', 'pie', 'area', 'scatter']).default('bar').meta({
description: "Type of chart to display",
}),
data: z.array(chartDataSchema).min(2).max(10).default([
{ name: 'Q1', value: 4000 },
{ name: 'Q2', value: 3000 },
{ name: 'Q3', value: 5000 },
{ name: 'Q4', value: 4500 },
]).meta({
description: "Chart data points",
}),
summaryPoints: z.array(summaryPointSchema).min(2).max(5).default([
{ point: 'Q3 showed the highest performance with 25% growth' },
{ point: 'Q2 experienced a temporary dip due to market conditions' },
{ point: 'Overall annual growth trend remains positive' },
{ point: 'Target exceeded by 12% for the fiscal year' },
]).meta({
description: "Key insights and summary points",
}),
dataKey: z.string().default('value').meta({
description: "Key field for chart values",
}),
categoryKey: z.string().default('name').meta({
description: "Key field for chart categories",
}),
color: z.string().default('#3b82f6').meta({
description: "Primary color for chart elements",
}),
showLegend: z.boolean().default(false).meta({
description: "Whether to show chart legend",
}),
showTooltip: z.boolean().default(true).meta({
description: "Whether to show chart tooltip",
}),
})
export const Schema = chartWithSummarySlideSchema
export type ChartWithSummarySlideData = z.infer<typeof chartWithSummarySlideSchema>
interface ChartWithSummarySlideLayoutProps {
data?: Partial<ChartWithSummarySlideData>
}
const chartConfig = {
value: {
label: "Value",
},
name: {
label: "Name",
},
};
const CHART_COLORS = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1'
];
const ChartWithSummarySlideLayout: React.FC<ChartWithSummarySlideLayoutProps> = ({ data: slideData }) => {
const chartData = slideData?.data || [];
const chartType = slideData?.chartType || 'bar';
const color = slideData?.color || '#3b82f6';
const dataKey = slideData?.dataKey || 'value';
const categoryKey = slideData?.categoryKey || 'name';
const showLegend = slideData?.showLegend || false;
const showTooltip = slideData?.showTooltip || true;
const summaryPoints = slideData?.summaryPoints || [];
const renderChart = () => {
const commonProps = {
data: chartData,
margin: { top: 20, right: 30, left: 40, bottom: 80 },
};
switch (chartType) {
case 'bar':
return (
<BarChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={categoryKey} />
<YAxis />
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Bar dataKey={dataKey} fill={color} radius={[4, 4, 0, 0]} />
</BarChart>
);
case 'line':
return (
<LineChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={categoryKey} />
<YAxis />
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Line
type="monotone"
dataKey={dataKey}
stroke={color}
strokeWidth={3}
dot={{ fill: color, strokeWidth: 2, r: 4 }}
/>
</LineChart>
);
case 'area':
return (
<AreaChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={categoryKey} />
<YAxis />
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Area
type="monotone"
dataKey={dataKey}
stroke={color}
fill={color}
fillOpacity={0.6}
/>
</AreaChart>
);
case 'pie':
return (
<PieChart margin={{ top: 20, right: 30, left: 40, bottom: 80 }}>
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Pie
data={chartData}
cx="50%"
cy="40%"
outerRadius={80}
fill={color}
dataKey={dataKey}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
</Pie>
</PieChart>
);
case 'scatter':
return (
<ScatterChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="x" type="number" />
<YAxis dataKey="y" type="number" />
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Scatter dataKey="value" fill={color} />
</ScatterChart>
);
default:
return <div>Unsupported chart type</div>;
}
};
return (
<>
{/* Import Google Fonts */}
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Nunito:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<div
className="w-full rounded-sm max-w-[1280px] shadow-md h-[720px] flex flex-col aspect-video bg-stone-100 relative z-20 mx-auto overflow-hidden"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{/* Glass overlay background */}
<div className="absolute inset-0 bg-white/20 backdrop-blur-sm rounded-sm border border-slate-200"></div>
<div className="relative z-10 flex flex-col w-full h-full p-4 sm:p-6 lg:p-8">
{/* Header section */}
<div className="flex-shrink-0 h-16 sm:h-20 flex items-center">
<h1
className="text-3xl sm:text-3xl lg:text-4xl font-bold text-gray-900 leading-tight text-left"
style={{
fontFamily: 'Space Grotesk, sans-serif'
}}
>
{slideData?.title || 'Sales Performance Analysis'}
</h1>
</div>
{/* Chart and Summary section */}
<div className="flex-1 w-full overflow-hidden">
<div className="flex gap-6 h-full items-center">
{/* Chart section - smaller flex */}
<div className="flex-[5] h-full">
<div className="bg-white/30 backdrop-blur-sm rounded-xl border border-slate-200 shadow-md p-4 sm:p-6 h-full overflow-hidden">
<ChartContainer config={chartConfig} className="h-full w-full max-h-[500px] sm:max-h-[520px] lg:max-h-[540px]">
{renderChart()}
</ChartContainer>
</div>
</div>
{/* Summary section - larger flex */}
<div className="flex-[4] h-full flex items-center">
<div className="bg-white/30 backdrop-blur-sm rounded-xl border border-slate-200 shadow-md p-4 sm:p-6 h-full overflow-hidden">
<div className="flex flex-col justify-center h-full">
<h2
className="text-xl sm:text-2xl font-bold text-gray-900 mb-4"
style={{
fontFamily: 'Space Grotesk, sans-serif'
}}
>
Key Insights
</h2>
<ul className="space-y-3">
{summaryPoints.map((item, index) => (
<li
key={index}
className="flex items-start gap-3"
>
<div className="w-2 h-2 bg-blue-500 rounded-full mt-2 flex-shrink-0"></div>
<span
className="text-sm sm:text-base text-gray-700 leading-relaxed"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{item.point}
</span>
</li>
))}
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</>
)
}
export default ChartWithSummarySlideLayout

View file

@ -0,0 +1,149 @@
import React from 'react'
import * as z from "zod";
import { ImageSchema } from '@/presentation-layouts/defaultSchemes';
export const layoutId = 'image-background-text-slide'
export const layoutName = 'Image Background + Text Slide'
export const layoutDescription = 'A layout for quotes, key messages, and mood slides with full-slide background image and overlaid text.'
const imageBackgroundTextSlideSchema = z.object({
title: z.string().min(3).max(200).default('Success is not final, failure is not fatal: it is the courage to continue that counts.').meta({
description: "Main quote or message text",
}),
subtitle: z.string().optional().default('Winston Churchill').meta({
description: "Optional subtitle, author, or attribution",
}),
backgroundImage: ImageSchema.default({
__image_url__: 'https://cdn.pixabay.com/photo/2016/12/02/02/10/idea-1876659_1280.jpg',
__image_prompt__: 'Inspirational workspace with lightbulb and motivation'
}).meta({
description: "Background image for the slide",
}),
overlayOpacity: z.number().min(0.3).max(0.8).default(0.5).meta({
description: "Dark overlay opacity (0.3-0.8)",
}),
textAlignment: z.enum(['left', 'center', 'right']).default('center').meta({
description: "Text alignment",
}),
textSize: z.enum(['large', 'extra-large', 'huge']).default('large').meta({
description: "Text size variant",
}),
textColor: z.enum(['white', 'light-gray', 'yellow', 'blue']).default('white').meta({
description: "Text color theme",
}),
})
export const Schema = imageBackgroundTextSlideSchema
export type ImageBackgroundTextSlideData = z.infer<typeof imageBackgroundTextSlideSchema>
interface ImageBackgroundTextSlideLayoutProps {
data?: Partial<ImageBackgroundTextSlideData>
}
const ImageBackgroundTextSlideLayout: React.FC<ImageBackgroundTextSlideLayoutProps> = ({ data: slideData }) => {
const getTextAlignment = () => {
switch (slideData?.textAlignment) {
case 'left':
return 'text-left items-start justify-start pl-8 sm:pl-16 lg:pl-24';
case 'right':
return 'text-right items-end justify-end pr-8 sm:pr-16 lg:pr-24';
default:
return 'text-center items-center justify-center';
}
};
const getTextSize = () => {
switch (slideData?.textSize) {
case 'extra-large':
return 'text-3xl sm:text-4xl lg:text-5xl xl:text-6xl';
case 'huge':
return 'text-4xl sm:text-5xl lg:text-6xl xl:text-7xl';
default:
return 'text-2xl sm:text-3xl lg:text-4xl xl:text-5xl';
}
};
const getTextColor = () => {
switch (slideData?.textColor) {
case 'light-gray':
return 'text-gray-100';
case 'yellow':
return 'text-yellow-300';
case 'blue':
return 'text-blue-300';
default:
return 'text-white';
}
};
const getOverlayOpacity = () => {
const opacity = slideData?.overlayOpacity || 0.5;
return `rgba(0, 0, 0, ${opacity})`;
};
return (
<>
{/* Import Google Fonts */}
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Nunito:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<div
className="w-full rounded-sm max-w-[1280px] shadow-md h-[720px] flex flex-col aspect-video relative z-20 mx-auto overflow-hidden"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{/* Background Image */}
<div className="absolute inset-0">
<img
src={slideData?.backgroundImage?.__image_url__ || ''}
alt={slideData?.backgroundImage?.__image_prompt__ || 'Background image'}
className="w-full h-full object-cover"
/>
</div>
{/* Dark Overlay */}
<div
className="absolute inset-0 z-10"
style={{
backgroundColor: getOverlayOpacity()
}}
/>
{/* Text Overlay */}
<div className={`absolute inset-0 z-20 flex flex-col ${getTextAlignment()} p-8 sm:p-12 lg:p-16`}>
<div className="max-w-4xl">
{/* Main Title/Quote */}
<h1
className={`${getTextSize()} ${getTextColor()} font-bold leading-tight mb-4 sm:mb-6 lg:mb-8`}
style={{
fontFamily: 'Space Grotesk, sans-serif',
textShadow: '2px 2px 4px rgba(0,0,0,0.3)'
}}
>
{slideData?.title || 'Success is not final, failure is not fatal: it is the courage to continue that counts.'}
</h1>
{/* Subtitle/Attribution */}
{slideData?.subtitle && (
<p
className={`text-lg sm:text-xl lg:text-2xl ${getTextColor()} font-medium opacity-90`}
style={{
fontFamily: 'Nunito, sans-serif',
textShadow: '1px 1px 2px rgba(0,0,0,0.3)'
}}
>
{slideData.subtitle}
</p>
)}
</div>
</div>
</div>
</>
)
}
export default ImageBackgroundTextSlideLayout

View file

@ -0,0 +1,138 @@
import React from 'react'
import * as z from "zod";
import { ImageSchema } from '@/presentation-layouts/defaultSchemes';
export const layoutId = 'intro-slide'
export const layoutName = 'Intro Slide'
export const layoutDescription = 'A 2-1 split layout featuring title, description, and presenter info on the left (2/3), and full-height image on the right (1/3).'
const introSlideSchema = z.object({
title: z.string().min(3).max(100).default('Analytics Dashboard').meta({
description: "Main title of the slide",
}),
description: z.string().min(10).max(500).default('Welcome to our comprehensive analytics overview. This dashboard provides key insights and metrics to help you make data-driven decisions for your business growth.').meta({
description: "Main description text",
}),
presenterName: z.string().min(2).max(50).default('Suraj Jha').meta({
description: "Name of the presenter",
}),
presentationDate: z.string().min(2).max(50).default('December 2024').meta({
description: "Date of the presentation",
}),
image: ImageSchema.default({
__image_url__: 'https://cdn.pixabay.com/photo/2016/11/27/21/42/stock-1863880_1280.jpg',
__image_prompt__: 'Analytics dashboard with charts and graphs'
}).meta({
description: "Main slide image",
})
})
export const Schema = introSlideSchema
export type IntroSlideData = z.infer<typeof introSlideSchema>
interface IntroSlideLayoutProps {
data?: Partial<IntroSlideData>
}
const IntroSlideLayout: React.FC<IntroSlideLayoutProps> = ({ data: slideData }) => {
// Generate initials from presenter name
const getInitials = (name: string) => {
return name.split(' ').map(word => word.charAt(0).toUpperCase()).join('');
};
const presenterInitials = getInitials(slideData?.presenterName || 'Suraj Jha');
return (
<>
{/* Import Google Fonts */}
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Nunito:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<div
className="w-full rounded-sm max-w-[1280px] shadow-md max-h-[720px] flex items-center aspect-video bg-stone-100 relative z-20 mx-auto"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{/* Glass overlay background */}
<div className="absolute inset-0 bg-white/20 backdrop-blur-sm rounded-sm border border-slate-200"></div>
<div className="relative z-10 flex w-full h-full">
{/* Left section - 2/3 width */}
<div className="w-2/3 flex flex-col justify-center space-y-1 md:space-y-2 lg:space-y-6 pl-8 sm:pl-16 lg:pl-24 py-[10px] sm:py-[40px] lg:py-[86px] pr-6 lg:pr-12">
{/* Title */}
<h1
className="text-3xl sm:text-3xl lg:text-4xl xl:text-5xl font-bold text-gray-900 leading-tight"
style={{
fontFamily: 'Space Grotesk, sans-serif'
}}
>
{slideData?.title || 'Analytics Dashboard'}
</h1>
{/* Description */}
<p
className="text-base sm:text-lg lg:text-xl text-gray-700 leading-relaxed"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{slideData?.description || 'Welcome to our comprehensive analytics overview. This dashboard provides key insights and metrics to help you make data-driven decisions for your business growth.'}
</p>
{/* Presenter Box */}
<div className="bg-white/30 backdrop-blur-sm rounded-lg p-4 lg:p-6 border border-slate-200 shadow-md">
<div className="flex items-center gap-4">
{/* Custom Initials Icon */}
<div className="w-10 h-10 lg:w-12 lg:h-12 bg-blue-500 rounded-full flex items-center justify-center">
<span
className="text-white font-bold text-sm lg:text-base"
style={{
fontFamily: 'Space Grotesk, sans-serif'
}}
>
{presenterInitials}
</span>
</div>
{/* Presenter Info */}
<div className="flex flex-col">
<span
className="text-xl lg:text-2xl font-bold text-gray-900"
style={{
fontFamily: 'Space Grotesk, sans-serif'
}}
>
{slideData?.presenterName || 'Suraj Jha'}
</span>
<span
className="text-sm lg:text-base text-gray-600 font-medium"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{slideData?.presentationDate || 'December 2024'}
</span>
</div>
</div>
</div>
</div>
{/* Right section - 1/3 width */}
<div className="w-1/3 h-full">
<img
src={slideData?.image?.__image_url__ || ''}
alt={slideData?.image?.__image_prompt__ || slideData?.title || ''}
className="w-full h-full object-cover"
/>
</div>
</div>
</div>
</>
)
}
export default IntroSlideLayout

View file

@ -0,0 +1,262 @@
import React from 'react'
import * as z from "zod";
export const layoutId = 'kpi-summary-grid-slide'
export const layoutName = 'KPI Summary Grid Slide'
export const layoutDescription = 'A layout for displaying key performance indicators with big numbers, labels, and trend indicators in a grid format.'
const kpiItemSchema = z.object({
value: z.string().min(1).max(20).meta({ description: "KPI value (e.g., '$2.4M', '95%', '1,234')" }),
label: z.string().min(2).max(50).meta({ description: "KPI label" }),
trend: z.enum(['up', 'down', 'flat', 'none']).default('none').meta({ description: "Trend direction" }),
trendValue: z.string().optional().meta({ description: "Trend percentage or value" }),
sparklineData: z.array(z.number()).optional().meta({ description: "Mini sparkline data points" }),
});
const kpiSummaryGridSlideSchema = z.object({
title: z.string().min(3).max(100).default('Key Performance Indicators').meta({
description: "Main title of the slide",
}),
description: z.string().min(10).max(300).default('These key metrics provide a comprehensive overview of business performance, tracking essential indicators that drive strategic decision-making and operational excellence.').meta({
description: "Bottom description text",
}),
kpis: z.array(kpiItemSchema).min(2).max(6).default([
{
value: '$2.4M',
label: 'Total Revenue',
trend: 'up',
trendValue: '+12.5%',
sparklineData: [10, 15, 12, 18, 22, 25, 28, 24, 30, 35]
},
{
value: '18.3%',
label: 'Growth Rate',
trend: 'up',
trendValue: '+3.2%',
sparklineData: [8, 12, 10, 15, 18, 20, 22, 19, 25, 28]
},
{
value: '24.7%',
label: 'Conversion Rate',
trend: 'down',
trendValue: '-1.8%',
sparklineData: [30, 28, 25, 22, 26, 24, 20, 18, 22, 25]
},
{
value: '1,234',
label: 'Active Users',
trend: 'up',
trendValue: '+8.9%',
sparklineData: [100, 120, 110, 140, 160, 180, 170, 200, 220, 240]
},
{
value: '95.2%',
label: 'Customer Satisfaction',
trend: 'flat',
trendValue: '+0.1%',
sparklineData: [90, 92, 91, 93, 94, 95, 94, 96, 95, 95]
},
{
value: '47s',
label: 'Avg Response Time',
trend: 'down',
trendValue: '-12.3%',
sparklineData: [60, 58, 55, 52, 50, 48, 45, 47, 46, 47]
},
]).meta({
description: "Array of KPI items (2-6 items)",
}),
})
export const Schema = kpiSummaryGridSlideSchema
export type KPISummaryGridSlideData = z.infer<typeof kpiSummaryGridSlideSchema>
interface KPISummaryGridSlideLayoutProps {
data?: Partial<KPISummaryGridSlideData>
}
const KPISummaryGridSlideLayout: React.FC<KPISummaryGridSlideLayoutProps> = ({ data: slideData }) => {
const kpis = slideData?.kpis || [];
// Determine grid layout based on KPI count
const getGridLayout = (count: number) => {
switch (count) {
case 2:
return 'grid-cols-2';
case 3:
return 'grid-cols-3';
case 4:
return 'grid-cols-2 md:grid-cols-4';
case 5:
case 6:
return 'grid-cols-2 md:grid-cols-3';
default:
return 'grid-cols-3';
}
};
// Render trend arrow
const renderTrendArrow = (trend: string) => {
switch (trend) {
case 'up':
return (
<svg className="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3.293 9.707a1 1 0 010-1.414l6-6a1 1 0 011.414 0l6 6a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L4.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
);
case 'down':
return (
<svg className="w-4 h-4 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 10.293a1 1 0 010 1.414l-6 6a1 1 0 01-1.414 0l-6-6a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l4.293-4.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
);
case 'flat':
return (
<svg className="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
</svg>
);
default:
return null;
}
};
// Render mini sparkline
const renderSparkline = (data: number[] | undefined) => {
if (!data || data.length === 0) return null;
const max = Math.max(...data);
const min = Math.min(...data);
const range = max - min;
const points = data.map((value, index) => {
const x = (index / (data.length - 1)) * 80;
const y = 20 - ((value - min) / range) * 20;
return `${x},${y}`;
}).join(' ');
return (
<svg className="w-20 h-5" viewBox="0 0 80 20">
<polyline
points={points}
fill="none"
stroke="#3b82f6"
strokeWidth="1.5"
className="opacity-70"
/>
</svg>
);
};
const getTrendColor = (trend: string) => {
switch (trend) {
case 'up':
return 'text-green-600';
case 'down':
return 'text-red-600';
case 'flat':
return 'text-gray-600';
default:
return 'text-gray-600';
}
};
return (
<>
{/* Import Google Fonts */}
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Nunito:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<div
className="w-full rounded-sm max-w-[1280px] shadow-md h-[720px] flex flex-col aspect-video bg-stone-100 relative z-20 mx-auto overflow-hidden"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{/* Glass overlay background */}
<div className="absolute inset-0 bg-white/20 backdrop-blur-sm rounded-sm border border-slate-200"></div>
<div className="relative z-10 flex flex-col w-full h-full p-4 sm:p-6 lg:p-8">
{/* Header section */}
<div className="flex-shrink-0 h-12 sm:h-16 flex items-center justify-center">
<h1
className="text-3xl font-bold text-gray-900 leading-tight text-center"
style={{
fontFamily: 'Space Grotesk, sans-serif'
}}
>
{slideData?.title || 'Key Performance Indicators'}
</h1>
</div>
{/* KPI Grid section */}
<div className="flex-1 w-full overflow-hidden">
<div className={`grid ${getGridLayout(kpis.length)} gap-6 h-full content-center`}>
{kpis.map((kpi, index) => (
<div key={index} className="bg-white/30 backdrop-blur-sm rounded-xl border border-slate-200 shadow-md p-4 sm:p-6 flex flex-col items-center justify-center text-center">
{/* Big Number */}
<div
className="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 mb-2"
style={{
fontFamily: 'Space Grotesk, sans-serif'
}}
>
{kpi.value}
</div>
{/* Label */}
<div
className="text-sm uppercase text-gray-600 font-medium mb-3"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{kpi.label}
</div>
{/* Trend and Sparkline */}
<div className="flex items-center gap-2">
{/* Trend Arrow and Value */}
{kpi.trend !== 'none' && (
<div className="flex items-center gap-1">
{renderTrendArrow(kpi.trend)}
<span
className={`text-xs font-medium ${getTrendColor(kpi.trend)}`}
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{kpi.trendValue}
</span>
</div>
)}
{/* Mini Sparkline */}
{kpi.sparklineData && renderSparkline(kpi.sparklineData)}
</div>
</div>
))}
</div>
</div>
{/* Bottom Description section */}
<div className="flex-shrink-0 h-16 sm:h-20 flex items-center justify-center">
<p
className="text-sm sm:text-base text-center text-gray-700 leading-relaxed max-w-4xl px-4"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{slideData?.description || 'These key metrics provide a comprehensive overview of business performance, tracking essential indicators that drive strategic decision-making and operational excellence.'}
</p>
</div>
</div>
</div>
</>
)
}
export default KPISummaryGridSlideLayout

View file

@ -0,0 +1,304 @@
import React from 'react'
import * as z from "zod";
import { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent } from "@/components/ui/chart";
import { BarChart, Bar, LineChart, Line, PieChart, Pie, AreaChart, Area, ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Cell, ResponsiveContainer } from "recharts";
export const layoutId = 'multi-chart-grid-slide'
export const layoutName = 'Multi-Chart Grid Slide'
export const layoutDescription = 'A layout for displaying 2-4 charts in a grid format for small multiples and trend comparisons.'
const chartDataSchema = z.object({
name: z.string().meta({ description: "Data point name" }),
value: z.number().meta({ description: "Data point value" }),
category: z.string().optional().meta({ description: "Category for grouping" }),
x: z.number().optional().meta({ description: "X coordinate for scatter plots" }),
y: z.number().optional().meta({ description: "Y coordinate for scatter plots" }),
});
const chartItemSchema = z.object({
type: z.enum(['bar', 'line', 'pie', 'area', 'scatter']).meta({ description: "Chart type" }),
data: z.array(chartDataSchema).min(2).max(8).meta({ description: "Chart data points" }),
title: z.string().min(2).max(50).meta({ description: "Chart title/caption" }),
color: z.string().meta({ description: "Chart color" }),
});
const multiChartGridSlideSchema = z.object({
title: z.string().min(3).max(100).default('Market Performance Dashboard').meta({
description: "Main title of the slide",
}),
description: z.string().min(10).max(300).default('This dashboard provides a comprehensive view of key performance indicators across multiple business segments, enabling quick identification of trends and comparative analysis.').meta({
description: "Bottom description text",
}),
charts: z.array(chartItemSchema).min(2).max(4).default([
{
type: 'bar',
data: [
{ name: 'Q1', value: 4000 },
{ name: 'Q2', value: 3000 },
{ name: 'Q3', value: 5000 },
{ name: 'Q4', value: 4500 },
],
title: 'Revenue Trends',
color: '#3b82f6'
},
{
type: 'line',
data: [
{ name: 'Q1', value: 2400 },
{ name: 'Q2', value: 2210 },
{ name: 'Q3', value: 2290 },
{ name: 'Q4', value: 2000 },
],
title: 'Customer Growth',
color: '#10b981'
},
{
type: 'area',
data: [
{ name: 'Q1', value: 1800 },
{ name: 'Q2', value: 1950 },
{ name: 'Q3', value: 2100 },
{ name: 'Q4', value: 2300 },
],
title: 'Market Share',
color: '#f59e0b'
},
{
type: 'bar',
data: [
{ name: 'Q1', value: 800 },
{ name: 'Q2', value: 967 },
{ name: 'Q3', value: 1200 },
{ name: 'Q4', value: 1400 },
],
title: 'Cost Efficiency',
color: '#ef4444'
},
]).meta({
description: "Array of charts (2-4 charts)",
}),
dataKey: z.string().default('value').meta({
description: "Key field for chart values",
}),
categoryKey: z.string().default('name').meta({
description: "Key field for chart categories",
}),
showLegend: z.boolean().default(false).meta({
description: "Whether to show chart legends",
}),
showTooltip: z.boolean().default(true).meta({
description: "Whether to show chart tooltips",
}),
})
export const Schema = multiChartGridSlideSchema
export type MultiChartGridSlideData = z.infer<typeof multiChartGridSlideSchema>
interface MultiChartGridSlideLayoutProps {
data?: Partial<MultiChartGridSlideData>
}
const chartConfig = {
value: {
label: "Value",
},
name: {
label: "Name",
},
};
const CHART_COLORS = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1'
];
const MultiChartGridSlideLayout: React.FC<MultiChartGridSlideLayoutProps> = ({ data: slideData }) => {
const charts = slideData?.charts || [];
const dataKey = slideData?.dataKey || 'value';
const categoryKey = slideData?.categoryKey || 'name';
const showLegend = slideData?.showLegend || false;
const showTooltip = slideData?.showTooltip || true;
// Determine grid layout based on chart count
const getGridLayout = (count: number) => {
switch (count) {
case 2:
return 'grid-cols-2 grid-rows-1';
case 3:
return 'grid-cols-2 grid-rows-2';
case 4:
return 'grid-cols-2 grid-rows-2';
default:
return 'grid-cols-2 grid-rows-2';
}
};
const renderChart = (chartType: string, data: any[], color: string) => {
const commonProps = {
data: data,
margin: { top: 5, right: 5, left: 5, bottom: 20 },
};
switch (chartType) {
case 'bar':
return (
<BarChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={categoryKey} fontSize={10} />
<YAxis fontSize={10} />
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Bar dataKey={dataKey} fill={color} radius={[2, 2, 0, 0]} />
</BarChart>
);
case 'line':
return (
<LineChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={categoryKey} fontSize={10} />
<YAxis fontSize={10} />
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Line
type="monotone"
dataKey={dataKey}
stroke={color}
strokeWidth={2}
dot={{ fill: color, strokeWidth: 1, r: 2 }}
/>
</LineChart>
);
case 'area':
return (
<AreaChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={categoryKey} fontSize={10} />
<YAxis fontSize={10} />
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Area
type="monotone"
dataKey={dataKey}
stroke={color}
fill={color}
fillOpacity={0.6}
/>
</AreaChart>
);
case 'pie':
return (
<PieChart margin={{ top: 5, right: 5, left: 5, bottom: 20 }}>
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Pie
data={data}
cx="50%"
cy="45%"
outerRadius={30}
fill={color}
dataKey={dataKey}
label={({ percent }) => `${(percent * 100).toFixed(0)}%`}
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
</Pie>
</PieChart>
);
case 'scatter':
return (
<ScatterChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="x" type="number" fontSize={10} />
<YAxis dataKey="y" type="number" fontSize={10} />
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Scatter dataKey="value" fill={color} />
</ScatterChart>
);
default:
return <div className="text-xs text-center">Unsupported chart type</div>;
}
};
return (
<>
{/* Import Google Fonts */}
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Nunito:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<div
className="w-full rounded-sm max-w-[1280px] shadow-md h-[720px] flex flex-col aspect-video bg-stone-100 relative z-20 mx-auto overflow-hidden"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{/* Glass overlay background */}
<div className="absolute inset-0 bg-white/20 backdrop-blur-sm rounded-sm border border-slate-200"></div>
<div className="relative z-10 flex flex-col w-full h-full p-4 sm:p-6 lg:p-8">
{/* Header section */}
<div className="flex-shrink-0 h-12 sm:h-16 flex items-center justify-center">
<h1
className="text-3xl font-bold text-gray-900 leading-tight text-center"
style={{
fontFamily: 'Space Grotesk, sans-serif'
}}
>
{slideData?.title || 'Market Performance Dashboard'}
</h1>
</div>
{/* Charts Grid section */}
<div className="flex-1 w-full overflow-hidden">
<div className={`grid ${getGridLayout(charts.length)} gap-4 h-full`}>
{charts.map((chart, index) => (
<div key={index} className="flex flex-col h-full">
<div className="bg-white/30 backdrop-blur-sm rounded-xl border border-slate-200 shadow-md p-2 sm:p-3 flex-1 overflow-hidden flex flex-col justify-end">
<div className="h-40 w-full">
<ChartContainer config={chartConfig} className="h-full w-full">
{renderChart(chart.type, chart.data, chart.color)}
</ChartContainer>
</div>
</div>
<div className="mt-1">
<p
className="text-xs text-center text-gray-700 font-medium"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{chart.title}
</p>
</div>
</div>
))}
</div>
</div>
{/* Bottom Description section */}
<div className="flex-shrink-0 h-16 sm:h-20 flex items-center justify-center">
<p
className="text-sm sm:text-base text-center text-gray-700 leading-relaxed max-w-4xl px-4"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{slideData?.description || 'This dashboard provides a comprehensive view of key performance indicators across multiple business segments, enabling quick identification of trends and comparative analysis.'}
</p>
</div>
</div>
</div>
</>
)
}
export default MultiChartGridSlideLayout

View file

@ -0,0 +1,235 @@
import React from 'react'
import * as z from "zod";
import { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent } from "@/components/ui/chart";
import { BarChart, Bar, LineChart, Line, PieChart, Pie, AreaChart, Area, ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Cell, ResponsiveContainer } from "recharts";
export const layoutId = 'single-chart-slide'
export const layoutName = 'Single Chart Slide'
export const layoutDescription = 'A layout for displaying one key metric or data trend with title and interactive chart.'
const chartDataSchema = z.object({
name: z.string().meta({ description: "Data point name" }),
value: z.number().meta({ description: "Data point value" }),
category: z.string().optional().meta({ description: "Category for grouping" }),
x: z.number().optional().meta({ description: "X coordinate for scatter plots" }),
y: z.number().optional().meta({ description: "Y coordinate for scatter plots" }),
});
const singleChartSlideSchema = z.object({
title: z.string().min(3).max(100).default('Revenue Growth Analysis').meta({
description: "Main title of the slide",
}),
description: z.string().min(10).max(300).default('This analysis reveals key performance trends across quarterly periods, identifying growth patterns and areas requiring strategic focus for sustained business success.').meta({
description: "Bottom description text",
}),
chartType: z.enum(['bar', 'line', 'pie', 'area', 'scatter']).default('bar').meta({
description: "Type of chart to display",
}),
data: z.array(chartDataSchema).min(2).max(10).default([
{ name: 'Jan', value: 4000 },
{ name: 'Feb', value: 3000 },
{ name: 'Mar', value: 2000 },
{ name: 'Apr', value: 2780 },
{ name: 'May', value: 1890 },
{ name: 'Jun', value: 2390 },
]).meta({
description: "Chart data points",
}),
dataKey: z.string().default('value').meta({
description: "Key field for chart values",
}),
categoryKey: z.string().default('name').meta({
description: "Key field for chart categories",
}),
color: z.string().default('#3b82f6').meta({
description: "Primary color for chart elements",
}),
showLegend: z.boolean().default(false).meta({
description: "Whether to show chart legend",
}),
showTooltip: z.boolean().default(true).meta({
description: "Whether to show chart tooltip",
}),
})
export const Schema = singleChartSlideSchema
export type SingleChartSlideData = z.infer<typeof singleChartSlideSchema>
interface SingleChartSlideLayoutProps {
data?: Partial<SingleChartSlideData>
}
const chartConfig = {
value: {
label: "Value",
},
name: {
label: "Name",
},
};
const CHART_COLORS = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1'
];
const SingleChartSlideLayout: React.FC<SingleChartSlideLayoutProps> = ({ data: slideData }) => {
const chartData = slideData?.data || [];
const chartType = slideData?.chartType || 'bar';
const color = slideData?.color || '#3b82f6';
const dataKey = slideData?.dataKey || 'value';
const categoryKey = slideData?.categoryKey || 'name';
const showLegend = slideData?.showLegend || false;
const showTooltip = slideData?.showTooltip || true;
const renderChart = () => {
const commonProps = {
data: chartData,
margin: { top: 20, right: 30, left: 40, bottom: 60 },
};
switch (chartType) {
case 'bar':
return (
<BarChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={categoryKey} />
<YAxis />
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Bar dataKey={dataKey} fill={color} radius={[4, 4, 0, 0]} />
</BarChart>
);
case 'line':
return (
<LineChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={categoryKey} />
<YAxis />
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Line
type="monotone"
dataKey={dataKey}
stroke={color}
strokeWidth={3}
dot={{ fill: color, strokeWidth: 2, r: 4 }}
/>
</LineChart>
);
case 'area':
return (
<AreaChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={categoryKey} />
<YAxis />
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Area
type="monotone"
dataKey={dataKey}
stroke={color}
fill={color}
fillOpacity={0.6}
/>
</AreaChart>
);
case 'pie':
return (
<PieChart margin={{ top: 20, right: 30, left: 40, bottom: 60 }}>
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Pie
data={chartData}
cx="50%"
cy="40%"
outerRadius={70}
fill={color}
dataKey={dataKey}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
</Pie>
</PieChart>
);
case 'scatter':
return (
<ScatterChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="x" type="number" />
<YAxis dataKey="y" type="number" />
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Scatter dataKey="value" fill={color} />
</ScatterChart>
);
default:
return <div>Unsupported chart type</div>;
}
};
return (
<>
{/* Import Google Fonts */}
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Nunito:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<div
className="w-full rounded-sm max-w-[1280px] shadow-md h-[720px] flex flex-col aspect-video bg-stone-100 relative z-20 mx-auto overflow-hidden"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{/* Glass overlay background */}
<div className="absolute inset-0 bg-white/20 backdrop-blur-sm rounded-sm border border-slate-200"></div>
<div className="relative z-10 flex flex-col w-full h-full p-4 sm:p-6 lg:p-8">
{/* Header section */}
<div className="flex-shrink-0 h-16 sm:h-20 flex items-center justify-center">
<h1
className="text-3xl sm:text-3xl lg:text-4xl font-bold text-gray-900 leading-tight text-center"
style={{
fontFamily: 'Space Grotesk, sans-serif'
}}
>
{slideData?.title || 'Revenue Growth Analysis'}
</h1>
</div>
{/* Chart section */}
<div className="flex-1 w-full overflow-hidden">
<div className="bg-white/30 backdrop-blur-sm rounded-xl border border-slate-200 shadow-md p-4 sm:p-6 h-full overflow-hidden">
<ChartContainer config={chartConfig} className="h-full w-full max-h-[400px] sm:max-h-[420px] lg:max-h-[440px]">
{renderChart()}
</ChartContainer>
</div>
</div>
{/* Bottom Description section */}
<div className="flex-shrink-0 h-20 sm:h-24 flex items-center justify-center">
<p
className="text-sm sm:text-base text-center text-gray-700 leading-relaxed max-w-4xl px-4"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{slideData?.description || 'This analysis reveals key performance trends across quarterly periods, identifying growth patterns and areas requiring strategic focus for sustained business success.'}
</p>
</div>
</div>
</div>
</>
)
}
export default SingleChartSlideLayout

View file

@ -0,0 +1,219 @@
import React from 'react'
import * as z from "zod";
export const layoutId = 'text-icon-list-slide'
export const layoutName = 'Text + Icon List Slide'
export const layoutDescription = 'A layout for displaying informational content like features, checklists, and steps with icons and descriptions.'
const listItemSchema = z.object({
icon: z.string().min(1).max(10).meta({ description: "Icon (emoji or simple text)" }),
heading: z.string().min(2).max(60).meta({ description: "Item heading" }),
description: z.string().min(10).max(200).meta({ description: "Item description" }),
status: z.enum(['default', 'completed', 'important', 'warning']).default('default').meta({ description: "Item status for styling" }),
});
const textIconListSlideSchema = z.object({
title: z.string().min(3).max(100).default('Key Features & Benefits').meta({
description: "Main title of the slide",
}),
description: z.string().min(10).max(300).default('These essential features and capabilities provide comprehensive solutions to meet your business objectives and drive operational excellence.').meta({
description: "Bottom description text",
}),
items: z.array(listItemSchema).min(2).max(8).default([
{
icon: '🚀',
heading: 'Fast Performance',
description: 'Optimized for speed with advanced caching and efficient algorithms to deliver exceptional user experience.',
status: 'important'
},
{
icon: '🔒',
heading: 'Enterprise Security',
description: 'Bank-level security with end-to-end encryption and compliance with industry standards.',
status: 'completed'
},
{
icon: '📊',
heading: 'Advanced Analytics',
description: 'Real-time insights and comprehensive reporting to track performance and make data-driven decisions.',
status: 'default'
},
{
icon: '🔧',
heading: 'Easy Integration',
description: 'Seamless integration with existing systems through RESTful APIs and pre-built connectors.',
status: 'default'
},
{
icon: '📱',
heading: 'Mobile Responsive',
description: 'Fully responsive design that works flawlessly across all devices and screen sizes.',
status: 'default'
},
{
icon: '⚡',
heading: '24/7 Support',
description: 'Round-the-clock technical support and dedicated account management for enterprise clients.',
status: 'important'
},
]).meta({
description: "List of items with icons (2-8 items)",
}),
layout: z.enum(['grid', 'single-column']).default('grid').meta({
description: "Layout style - grid for 2 columns, single-column for vertical list",
}),
})
export const Schema = textIconListSlideSchema
export type TextIconListSlideData = z.infer<typeof textIconListSlideSchema>
interface TextIconListSlideLayoutProps {
data?: Partial<TextIconListSlideData>
}
const TextIconListSlideLayout: React.FC<TextIconListSlideLayoutProps> = ({ data: slideData }) => {
const items = slideData?.items || [];
const layout = slideData?.layout || 'grid';
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'text-green-600';
case 'important':
return 'text-blue-600';
case 'warning':
return 'text-amber-600';
default:
return 'text-gray-900';
}
};
const getBackgroundColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-50 border-green-200';
case 'important':
return 'bg-blue-50 border-blue-200';
case 'warning':
return 'bg-amber-50 border-amber-200';
default:
return 'bg-white/30 border-slate-200';
}
};
const getGridLayout = () => {
if (layout === 'single-column') {
return 'grid-cols-1';
}
return 'grid-cols-1 md:grid-cols-2';
};
const renderSVGIcon = (iconText: string) => {
// If it's an emoji, return as is
if (/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(iconText)) {
return <span className="text-2xl">{iconText}</span>;
}
// For non-emoji, create a simple circle with text
return (
<div className="w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-sm font-bold">
{iconText.charAt(0).toUpperCase()}
</div>
);
};
return (
<>
{/* Import Google Fonts */}
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Nunito:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<div
className="w-full rounded-sm max-w-[1280px] shadow-md h-[720px] flex flex-col aspect-video bg-stone-100 relative z-20 mx-auto overflow-hidden"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{/* Glass overlay background */}
<div className="absolute inset-0 bg-white/20 backdrop-blur-sm rounded-sm border border-slate-200"></div>
<div className="relative z-10 flex flex-col w-full h-full p-4 sm:p-6 lg:p-8">
{/* Header section */}
<div className="flex-shrink-0 h-12 sm:h-16 flex items-center justify-center">
<h1
className="text-3xl font-bold text-gray-900 leading-tight text-center"
style={{
fontFamily: 'Space Grotesk, sans-serif'
}}
>
{slideData?.title || 'Key Features & Benefits'}
</h1>
</div>
{/* Items Grid section */}
<div className="flex-1 w-full overflow-hidden">
<div className="h-full flex items-center justify-center">
<div className="w-full max-w-5xl">
<div className={`grid ${getGridLayout()} gap-6 content-center`}>
{items.map((item, index) => (
<div
key={index}
className={`backdrop-blur-sm rounded-xl border shadow-md p-4 sm:p-6 ${getBackgroundColor(item.status)}`}
>
<div className="flex items-start gap-4">
{/* Icon */}
<div className="flex-shrink-0 mt-1">
{renderSVGIcon(item.icon)}
</div>
{/* Content */}
<div className="flex-1">
{/* Heading */}
<h3
className={`text-lg font-semibold leading-tight mb-2 ${getStatusColor(item.status)}`}
style={{
fontFamily: 'Space Grotesk, sans-serif'
}}
>
{item.heading}
</h3>
{/* Description */}
<p
className="text-sm text-gray-500 leading-relaxed"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{item.description}
</p>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* Bottom Description section */}
<div className="flex-shrink-0 h-16 sm:h-20 flex items-center justify-center">
<p
className="text-sm sm:text-base text-center text-gray-700 leading-relaxed max-w-4xl px-4"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{slideData?.description || 'These essential features and capabilities provide comprehensive solutions to meet your business objectives and drive operational excellence.'}
</p>
</div>
</div>
</div>
</>
)
}
export default TextIconListSlideLayout

View file

@ -0,0 +1,254 @@
import React from 'react'
import * as z from "zod";
export const layoutId = 'timeline-trend-slide'
export const layoutName = 'Timeline / Trend Slide'
export const layoutDescription = 'A layout for displaying time-based milestones with connecting lines and responsive timeline visualization.'
const timelineItemSchema = z.object({
title: z.string().min(2).max(50).meta({ description: "Milestone title" }),
date: z.string().min(2).max(30).meta({ description: "Date or time period" }),
status: z.enum(['completed', 'current', 'upcoming', 'delayed']).default('upcoming').meta({ description: "Milestone status" }),
icon: z.string().optional().meta({ description: "Icon identifier (optional)" }),
description: z.string().optional().meta({ description: "Optional description" }),
});
const timelineTrendSlideSchema = z.object({
title: z.string().min(3).max(100).default('Project Timeline & Milestones').meta({
description: "Main title of the slide",
}),
description: z.string().min(10).max(300).default('This timeline tracks key project milestones and deliverables, providing a clear view of progress and upcoming objectives across the project lifecycle.').meta({
description: "Bottom description text",
}),
timeline: z.array(timelineItemSchema).min(3).max(8).default([
{
title: 'Project Kickoff',
date: 'Jan 2024',
status: 'completed',
description: 'Initial planning and team setup'
},
{
title: 'Research Phase',
date: 'Feb 2024',
status: 'completed',
description: 'Market research and requirements gathering'
},
{
title: 'Design Phase',
date: 'Mar 2024',
status: 'completed',
description: 'UI/UX design and prototyping'
},
{
title: 'Development',
date: 'Apr-Jun 2024',
status: 'current',
description: 'Core development and testing'
},
{
title: 'Beta Testing',
date: 'Jul 2024',
status: 'upcoming',
description: 'User testing and feedback collection'
},
{
title: 'Launch',
date: 'Aug 2024',
status: 'upcoming',
description: 'Product launch and go-to-market'
},
]).meta({
description: "Timeline milestone items (3-8 items)",
}),
showConnectingLines: z.boolean().default(true).meta({
description: "Whether to show connecting lines between milestones",
}),
lineStyle: z.enum(['solid', 'dotted', 'dashed']).default('solid').meta({
description: "Style of connecting lines",
}),
})
export const Schema = timelineTrendSlideSchema
export type TimelineTrendSlideData = z.infer<typeof timelineTrendSlideSchema>
interface TimelineTrendSlideLayoutProps {
data?: Partial<TimelineTrendSlideData>
}
const TimelineTrendSlideLayout: React.FC<TimelineTrendSlideLayoutProps> = ({ data: slideData }) => {
const timeline = slideData?.timeline || [];
const showConnectingLines = slideData?.showConnectingLines ?? true;
const lineStyle = slideData?.lineStyle || 'solid';
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-500 border-green-500';
case 'current':
return 'bg-blue-500 border-blue-500';
case 'upcoming':
return 'bg-gray-300 border-gray-300';
case 'delayed':
return 'bg-red-500 border-red-500';
default:
return 'bg-gray-300 border-gray-300';
}
};
const getTextColor = (status: string) => {
switch (status) {
case 'completed':
return 'text-green-600';
case 'current':
return 'text-blue-600';
case 'upcoming':
return 'text-gray-600';
case 'delayed':
return 'text-red-600';
default:
return 'text-gray-600';
}
};
const getLineStyle = () => {
switch (lineStyle) {
case 'dotted':
return 'border-dotted';
case 'dashed':
return 'border-dashed';
default:
return 'border-solid';
}
};
const renderIcon = (status: string) => {
switch (status) {
case 'completed':
return (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
);
case 'current':
return (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" />
</svg>
);
case 'delayed':
return (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
);
default:
return null;
}
};
return (
<>
{/* Import Google Fonts */}
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Nunito:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<div
className="w-full rounded-sm max-w-[1280px] shadow-md h-[720px] flex flex-col aspect-video bg-white relative z-20 mx-auto overflow-hidden"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{/* Glass overlay background */}
<div className="absolute inset-0 bg-white/20 backdrop-blur-sm rounded-sm border border-slate-200"></div>
<div className="relative z-10 flex flex-col w-full h-full p-4 sm:p-6 lg:p-8">
{/* Header section */}
<div className="flex-shrink-0 h-12 sm:h-16 flex items-center justify-center">
<h1
className="text-3xl font-bold text-gray-900 leading-tight text-center"
style={{
fontFamily: 'Space Grotesk, sans-serif'
}}
>
{slideData?.title || 'Project Timeline & Milestones'}
</h1>
</div>
{/* Timeline section */}
<div className="flex-1 w-full overflow-hidden">
<div className="h-full flex items-center justify-center">
<div className="w-full max-w-6xl">
{/* Timeline container */}
<div className="relative">
{/* Timeline items */}
<div className="flex justify-between items-center relative">
{timeline.map((item, index) => (
<div key={index} className="flex flex-col items-center relative z-10">
{/* Circle/Icon */}
<div className={`w-12 h-12 rounded-full border-4 ${getStatusColor(item.status)} flex items-center justify-center shadow-lg bg-white/10 backdrop-blur-sm`}>
{renderIcon(item.status)}
</div>
{/* Label */}
<div className="mt-3 text-center max-w-20">
<div
className={`text-xs font-semibold ${getTextColor(item.status)} mb-1`}
style={{
fontFamily: 'Space Grotesk, sans-serif'
}}
>
{item.title}
</div>
<div
className="text-xs text-gray-600"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{item.date}
</div>
{item.description && (
<div
className="text-xs text-gray-500 mt-1 leading-tight"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{item.description}
</div>
)}
</div>
</div>
))}
{/* Connecting line */}
{showConnectingLines && (
<div className={`absolute top-6 left-6 right-6 h-0 border-t-2 border-gray-400 ${getLineStyle()} -z-10`} />
)}
</div>
</div>
</div>
</div>
</div>
{/* Bottom Description section */}
<div className="flex-shrink-0 h-16 sm:h-20 flex items-center justify-center">
<p
className="text-sm sm:text-base text-center text-gray-700 leading-relaxed max-w-4xl px-4"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{slideData?.description || 'This timeline tracks key project milestones and deliverables, providing a clear view of progress and upcoming objectives across the project lifecycle.'}
</p>
</div>
</div>
</div>
</>
)
}
export default TimelineTrendSlideLayout

View file

@ -0,0 +1,297 @@
import React from 'react'
import * as z from "zod";
import { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent } from "@/components/ui/chart";
import { BarChart, Bar, LineChart, Line, PieChart, Pie, AreaChart, Area, ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Cell, ResponsiveContainer } from "recharts";
export const layoutId = 'two-charts-slide'
export const layoutName = 'Two Charts Slide'
export const layoutDescription = 'A layout for comparing two data visualizations side-by-side (e.g., Plan vs Actual).'
const chartDataSchema = z.object({
name: z.string().meta({ description: "Data point name" }),
value: z.number().meta({ description: "Data point value" }),
category: z.string().optional().meta({ description: "Category for grouping" }),
x: z.number().optional().meta({ description: "X coordinate for scatter plots" }),
y: z.number().optional().meta({ description: "Y coordinate for scatter plots" }),
});
const twoChartsSlideSchema = z.object({
title: z.string().min(3).max(100).default('Plan vs Actual Comparison').meta({
description: "Main title of the slide",
}),
description: z.string().min(10).max(300).default('This comparison shows the variance between planned targets and actual performance across quarterly metrics, highlighting areas of success and opportunities for improvement.').meta({
description: "Bottom description text",
}),
// Chart A (Left)
chartAType: z.enum(['bar', 'line', 'pie', 'area', 'scatter']).default('bar').meta({
description: "Type of chart A to display",
}),
chartAData: z.array(chartDataSchema).min(2).max(10).default([
{ name: 'Q1', value: 4000 },
{ name: 'Q2', value: 3500 },
{ name: 'Q3', value: 5000 },
{ name: 'Q4', value: 4500 },
]).meta({
description: "Chart A data points",
}),
chartATitle: z.string().min(2).max(50).default('Planned Revenue').meta({
description: "Title/caption for chart A",
}),
chartAColor: z.string().default('#3b82f6').meta({
description: "Primary color for chart A elements",
}),
// Chart B (Right)
chartBType: z.enum(['bar', 'line', 'pie', 'area', 'scatter']).default('bar').meta({
description: "Type of chart B to display",
}),
chartBData: z.array(chartDataSchema).min(2).max(10).default([
{ name: 'Q1', value: 3800 },
{ name: 'Q2', value: 3200 },
{ name: 'Q3', value: 5200 },
{ name: 'Q4', value: 4800 },
]).meta({
description: "Chart B data points",
}),
chartBTitle: z.string().min(2).max(50).default('Actual Revenue').meta({
description: "Title/caption for chart B",
}),
chartBColor: z.string().default('#10b981').meta({
description: "Primary color for chart B elements",
}),
// Common settings
dataKey: z.string().default('value').meta({
description: "Key field for chart values",
}),
categoryKey: z.string().default('name').meta({
description: "Key field for chart categories",
}),
showLegend: z.boolean().default(false).meta({
description: "Whether to show chart legends",
}),
showTooltip: z.boolean().default(true).meta({
description: "Whether to show chart tooltips",
}),
})
export const Schema = twoChartsSlideSchema
export type TwoChartsSlideData = z.infer<typeof twoChartsSlideSchema>
interface TwoChartsSlideLayoutProps {
data?: Partial<TwoChartsSlideData>
}
const chartConfig = {
value: {
label: "Value",
},
name: {
label: "Name",
},
};
const CHART_COLORS = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1'
];
const TwoChartsSlideLayout: React.FC<TwoChartsSlideLayoutProps> = ({ data: slideData }) => {
const chartAData = slideData?.chartAData || [];
const chartBData = slideData?.chartBData || [];
const chartAType = slideData?.chartAType || 'bar';
const chartBType = slideData?.chartBType || 'bar';
const chartAColor = slideData?.chartAColor || '#3b82f6';
const chartBColor = slideData?.chartBColor || '#10b981';
const dataKey = slideData?.dataKey || 'value';
const categoryKey = slideData?.categoryKey || 'name';
const showLegend = slideData?.showLegend || false;
const showTooltip = slideData?.showTooltip || true;
const renderChart = (chartType: string, data: any[], color: string) => {
const commonProps = {
data: data,
margin: { top: 15, right: 15, left: 15, bottom: 35 },
};
switch (chartType) {
case 'bar':
return (
<BarChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={categoryKey} />
<YAxis />
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Bar dataKey={dataKey} fill={color} radius={[4, 4, 0, 0]} />
</BarChart>
);
case 'line':
return (
<LineChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={categoryKey} />
<YAxis />
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Line
type="monotone"
dataKey={dataKey}
stroke={color}
strokeWidth={3}
dot={{ fill: color, strokeWidth: 2, r: 4 }}
/>
</LineChart>
);
case 'area':
return (
<AreaChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={categoryKey} />
<YAxis />
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Area
type="monotone"
dataKey={dataKey}
stroke={color}
fill={color}
fillOpacity={0.6}
/>
</AreaChart>
);
case 'pie':
return (
<PieChart margin={{ top: 15, right: 15, left: 15, bottom: 35 }}>
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Pie
data={data}
cx="50%"
cy="40%"
outerRadius={50}
fill={color}
dataKey={dataKey}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
</Pie>
</PieChart>
);
case 'scatter':
return (
<ScatterChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="x" type="number" />
<YAxis dataKey="y" type="number" />
{showTooltip && <ChartTooltip content={<ChartTooltipContent />} />}
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
<Scatter dataKey="value" fill={color} />
</ScatterChart>
);
default:
return <div>Unsupported chart type</div>;
}
};
return (
<>
{/* Import Google Fonts */}
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Nunito:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<div
className="w-full rounded-sm max-w-[1280px] shadow-md h-[720px] flex flex-col aspect-video bg-white relative z-20 mx-auto overflow-hidden"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{/* Glass overlay background */}
<div className="absolute inset-0 bg-white/20 backdrop-blur-sm rounded-sm border border-slate-200"></div>
<div className="relative z-10 flex flex-col w-full h-full p-4 sm:p-6 lg:p-8">
{/* Header section */}
<div className="flex-shrink-0 h-16 sm:h-20 flex items-center justify-center">
<h1
className="text-3xl sm:text-3xl lg:text-4xl font-bold text-gray-900 leading-tight text-center"
style={{
fontFamily: 'Space Grotesk, sans-serif'
}}
>
{slideData?.title || 'Plan vs Actual Comparison'}
</h1>
</div>
{/* Two Charts section */}
<div className="flex-1 w-full overflow-hidden">
<div className="flex gap-6 h-full items-center">
{/* Chart A - Left side */}
<div className="w-1/2 h-full">
<div className="bg-white/30 backdrop-blur-sm rounded-xl border border-slate-200 shadow-md p-4 sm:p-6 h-full overflow-hidden flex flex-col">
<div className="flex-1">
<ChartContainer config={chartConfig} className="h-full w-full max-h-[360px] sm:max-h-[380px] lg:max-h-[400px]">
{renderChart(chartAType, chartAData, chartAColor)}
</ChartContainer>
</div>
<div className="mt-2">
<p
className="text-sm text-center text-gray-700 font-medium"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{slideData?.chartATitle || 'Planned Revenue'}
</p>
</div>
</div>
</div>
{/* Chart B - Right side */}
<div className="w-1/2 h-full">
<div className="bg-white/30 backdrop-blur-sm rounded-xl border border-slate-200 shadow-md p-4 sm:p-6 h-full overflow-hidden flex flex-col">
<div className="flex-1">
<ChartContainer config={chartConfig} className="h-full w-full max-h-[360px] sm:max-h-[380px] lg:max-h-[400px]">
{renderChart(chartBType, chartBData, chartBColor)}
</ChartContainer>
</div>
<div className="mt-2">
<p
className="text-sm text-center text-gray-700 font-medium"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{slideData?.chartBTitle || 'Actual Revenue'}
</p>
</div>
</div>
</div>
</div>
</div>
{/* Bottom Description section */}
<div className="flex-shrink-0 h-20 sm:h-24 flex items-center justify-center">
<p
className="text-sm sm:text-base text-center text-gray-700 leading-relaxed max-w-4xl px-4"
style={{
fontFamily: 'Nunito, sans-serif'
}}
>
{slideData?.description || 'This comparison shows the variance between planned targets and actual performance across quarterly metrics, highlighting areas of success and opportunities for improvement.'}
</p>
</div>
</div>
</div>
</>
)
}
export default TwoChartsSlideLayout

View file

@ -0,0 +1,7 @@
{
"id": "analytics",
"name": "Analytics",
"description": "Data-focused layouts with glass visual style and modern typography",
"ordered": true,
"isDefault": false
}