Merge pull request #93 from presenton/feat/slide_editing
feat/slide editing
This commit is contained in:
commit
6428315115
26 changed files with 3950 additions and 661 deletions
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
365
servers/nextjs/components/ui/chart.tsx
Normal file
365
servers/nextjs/components/ui/chart.tsx
Normal 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,
|
||||
}
|
||||
2
servers/nextjs/package-lock.json
generated
2
servers/nextjs/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"id": "analytics",
|
||||
"name": "Analytics",
|
||||
"description": "Data-focused layouts with glass visual style and modern typography",
|
||||
"ordered": true,
|
||||
"isDefault": false
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue