feat(Nextjs): Custom Layout Load, Preview & presentation generation added

This commit is contained in:
shiva raj badu 2025-08-02 01:13:47 +05:45
parent 0014c57605
commit bef5afb32a
No known key found for this signature in database
8 changed files with 902 additions and 822 deletions

View file

@ -1,286 +1,344 @@
"use client";
import React, { useRef, useEffect, useState, ReactNode } from 'react';
import ReactDOM from 'react-dom/client';
import TiptapText from './TiptapText';
import React, { useRef, useEffect, useState, ReactNode } from "react";
import ReactDOM from "react-dom/client";
import TiptapText from "./TiptapText";
interface TiptapTextReplacerProps {
children: ReactNode;
slideData?: any;
slideIndex?: number;
onContentChange?: (content: string, path: string, slideIndex?: number) => void;
children: ReactNode;
slideData?: any;
slideIndex?: number;
onContentChange?: (
content: string,
path: string,
slideIndex?: number
) => void;
}
const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
children,
slideData,
slideIndex,
onContentChange = () => { },
children,
slideData,
slideIndex,
onContentChange = () => {},
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [processedElements, setProcessedElements] = useState(
new Set<HTMLElement>()
);
useEffect(() => {
if (!containerRef.current) return;
const containerRef = useRef<HTMLDivElement>(null);
const [processedElements, setProcessedElements] = useState(new Set<HTMLElement>());
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
const container = containerRef.current;
const replaceTextElements = () => {
// Get all elements in the container
const allElements = container.querySelectorAll("*");
const replaceTextElements = () => {
// Get all elements in the container
const allElements = container.querySelectorAll('*');
allElements.forEach((element) => {
const htmlElement = element as HTMLElement;
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;
}
// Skip if already processed
if (processedElements.has(htmlElement) ||
htmlElement.classList.contains('tiptap-text-editor') ||
htmlElement.closest('.tiptap-text-editor')) {
return;
}
// Skip if element is inside an ignored element tree
if (isInIgnoredElementTree(htmlElement)) return;
// Skip if element is inside an ignored element tree
if (isInIgnoredElementTree(htmlElement)) return;
// Get direct text content (not from child elements)
const directTextContent = getDirectTextContent(htmlElement);
const trimmedText = directTextContent.trim();
// 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;
// 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 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;
// Skip certain element types that shouldn't be editable
if (shouldSkipElement(htmlElement)) return;
// 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,
borderRadius: computedStyles.borderRadius,
border: computedStyles.border,
backgroundColor: computedStyles.backgroundColor,
opacity: computedStyles.opacity,
zIndex: computedStyles.zIndex,
cursor: computedStyles.cursor,
boxShadow: computedStyles.boxShadow,
textShadow: computedStyles.textShadow,
textDecoration: computedStyles.textDecoration,
textTransform: computedStyles.textTransform,
letterSpacing: computedStyles.letterSpacing,
wordSpacing: computedStyles.wordSpacing,
textOverflow: computedStyles.textOverflow,
whiteSpace: computedStyles.whiteSpace,
wordBreak: computedStyles.wordBreak,
overflow: computedStyles.overflow,
textAlignLast: computedStyles.textAlignLast,
};
// 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.path, slideIndex);
}
}}
placeholder="Enter text..."
/>
);
});
// 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,
borderRadius: computedStyles.borderRadius,
border: computedStyles.border,
backgroundColor: computedStyles.backgroundColor,
opacity: computedStyles.opacity,
zIndex: computedStyles.zIndex,
cursor: computedStyles.cursor,
boxShadow: computedStyles.boxShadow,
textShadow: computedStyles.textShadow,
textDecoration: computedStyles.textDecoration,
textTransform: computedStyles.textTransform,
letterSpacing: computedStyles.letterSpacing,
wordSpacing: computedStyles.wordSpacing,
textOverflow: computedStyles.textOverflow,
whiteSpace: computedStyles.whiteSpace,
wordBreak: computedStyles.wordBreak,
overflow: computedStyles.overflow,
textAlignLast: computedStyles.textAlignLast,
};
// 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;
// Function to check if element is inside an ignored element tree
const isInIgnoredElementTree = (element: HTMLElement): boolean => {
// List of element types that should be ignored entirely with all their children
const ignoredElementTypes = [
'TABLE', 'TBODY', 'THEAD', 'TFOOT', 'TR', 'TD', 'TH', // Table elements
'SVG', 'G', 'PATH', 'CIRCLE', 'RECT', 'LINE', // SVG elements
'CANVAS', // Canvas element
'VIDEO', 'AUDIO', // Media elements
'IFRAME', 'EMBED', 'OBJECT', // Embedded content
'SELECT', 'OPTION', 'OPTGROUP', // Select dropdown elements
'SCRIPT', 'STYLE', 'NOSCRIPT', // Script/style elements
];
// List of class patterns that indicate ignored element trees
const ignoredClassPatterns = [
'chart', 'graph', 'visualization', // Chart/graph components
'menu', 'dropdown', 'tooltip', // UI components
'editor', 'wysiwyg', // Editor components
'calendar', 'datepicker', // Date picker components
'slider', 'carousel', 'flowchart', 'mermaid', 'diagram',
];
// Check if current element or any parent is in ignored list
let currentElement: HTMLElement | null = element;
while (currentElement) {
// Check element type
if (ignoredElementTypes.includes(currentElement.tagName)) {
return true;
}
// Check class patterns
const className = currentElement.className.length > 0 ? currentElement.className.toLowerCase() : '';
if (ignoredClassPatterns.some(pattern => className.includes(pattern))) {
return true;
}
if (currentElement.id.includes('mermaid')) {
return true;
}
// Check for specific attributes that indicate non-text content
if (currentElement.hasAttribute('contenteditable') ||
currentElement.hasAttribute('data-chart') ||
currentElement.hasAttribute('data-visualization') ||
currentElement.hasAttribute('data-interactive')) {
return true;
}
currentElement = currentElement.parentElement;
}
return false;
};
// 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 (simplified since we now use isInIgnoredElementTree)
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.length > 0 ? element.className.includes(cls) : false
// Apply preserved styles
Object.entries(preservedStyles).forEach(([property, value]) => {
if (value && value !== "auto") {
tiptapContainer.style.setProperty(
property.replace(/([A-Z])/g, "-$1").toLowerCase(),
value
);
if (hasContainerClass) return true;
}
});
// 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.path, slideIndex);
}
}}
placeholder="Enter text..."
/>
);
});
};
// Skip very short text that might be UI elements
const text = getDirectTextContent(element).trim();
if (text.length < 2) return true;
// Function to check if element is inside an ignored element tree
const isInIgnoredElementTree = (element: HTMLElement): boolean => {
// List of element types that should be ignored entirely with all their children
const ignoredElementTypes = [
"TABLE",
"TBODY",
"THEAD",
"TFOOT",
"TR",
"TD",
"TH", // Table elements
"SVG",
"G",
"PATH",
"CIRCLE",
"RECT",
"LINE", // SVG elements
"CANVAS", // Canvas element
"VIDEO",
"AUDIO", // Media elements
"IFRAME",
"EMBED",
"OBJECT", // Embedded content
"SELECT",
"OPTION",
"OPTGROUP", // Select dropdown elements
"SCRIPT",
"STYLE",
"NOSCRIPT", // Script/style elements
];
// Skip elements that look like numbers or single characters (might be icons/UI)
if (/^[0-9]+$/.test(text) || text.length === 1) return true;
// List of class patterns that indicate ignored element trees
const ignoredClassPatterns = [
"chart",
"graph",
"visualization", // Chart/graph components
"menu",
"dropdown",
"tooltip", // UI components
"editor",
"wysiwyg", // Editor components
"calendar",
"datepicker", // Date picker components
"slider",
"carousel",
"flowchart",
"mermaid",
"diagram",
];
return false;
};
// Check if current element or any parent is in ignored list
let currentElement: HTMLElement | null = element;
while (currentElement) {
// Check element type
if (ignoredElementTypes.includes(currentElement.tagName)) {
return true;
}
// Helper function to find data path for text content
const findDataPath = (data: any, targetText: string, path = ''): {
path: string;
originalText: string;
} => {
if (!data || typeof data !== 'object') return { path: '', originalText: '' };
// Check class patterns
const className =
currentElement.className.length > 0
? currentElement.className.toLowerCase()
: "";
if (
ignoredClassPatterns.some((pattern) => className.includes(pattern))
) {
return true;
}
if (currentElement.id.includes("mermaid")) {
return true;
}
for (const [key, value] of Object.entries(data)) {
const currentPath = path ? `${path}.${key}` : key;
// Check for specific attributes that indicate non-text content
if (
currentElement.hasAttribute("contenteditable") ||
currentElement.hasAttribute("data-chart") ||
currentElement.hasAttribute("data-visualization") ||
currentElement.hasAttribute("data-interactive")
) {
return true;
}
if (typeof value === 'string' && value.trim() === targetText.trim()) {
return { path: currentPath, originalText: value };
}
currentElement = currentElement.parentElement;
}
return false;
};
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const result = findDataPath(value[i], targetText, `${currentPath}[${i}]`);
if (result.path) return result;
}
} else if (typeof value === 'object' && value !== null) {
const result = findDataPath(value, targetText, currentPath);
if (result.path) return result;
}
}
return { path: '', originalText: '' };
};
// 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;
};
// Replace text elements after a short delay to ensure DOM is ready
const timer = setTimeout(replaceTextElements, 500);
// 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;
});
};
return () => {
clearTimeout(timer);
};
}, [slideData, slideIndex]);
// 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;
}
return (
<div ref={containerRef} className="tiptap-text-replacer">
{children}
</div>
);
// 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 (simplified since we now use isInIgnoredElementTree)
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.length > 0 ? element.className.includes(cls) : false
);
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 = ""
): {
path: string;
originalText: string;
} => {
if (!data || typeof data !== "object")
return { path: "", originalText: "" };
for (const [key, value] of Object.entries(data)) {
const currentPath = path ? `${path}.${key}` : key;
if (typeof value === "string" && value.trim() === targetText.trim()) {
return { path: currentPath, originalText: value };
}
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const result = findDataPath(
value[i],
targetText,
`${currentPath}[${i}]`
);
if (result.path) return result;
}
} else if (typeof value === "object" && value !== null) {
const result = findDataPath(value, targetText, currentPath);
if (result.path) return result;
}
}
return { path: "", originalText: "" };
};
// Replace text elements after a short delay to ensure DOM is ready
const timer = setTimeout(replaceTextElements, 500);
return () => {
clearTimeout(timer);
};
}, [slideData, slideIndex]);
return (
<div ref={containerRef} className="tiptap-text-replacer">
{children}
</div>
);
};
export default TiptapTextReplacer;
export default TiptapTextReplacer;

View file

@ -69,211 +69,11 @@ const createCacheKey = (groupName: string, fileName: string): string =>
// Extract Babel compilation logic into a utility function
const compileCustomLayout = (layoutCode: string, React: any, z: any) => {
const jsxCode = `
const ImageSchema = z.object({
__image_url__: z.url().meta({
description: "URL to image",
}),
__image_prompt__: z.string().meta({
description: "Prompt used to generate the image",
}).min(10).max(50),
})
const cleanCode = layoutCode
.replace(/import\s+React\s+from\s+'react';?/g, "")
.replace(/import\s*{\s*z\s*}\s*from\s+'zod';?/g, "");
const layoutId = 'title-slide-with-decorative-elements'
const layoutName = 'TitleSlideWithDecorativeElements'
const layoutDescription = 'A title slide layout with company name, main title, subtitle, author text, and decorative curved shapes with images.'
const titleSlideWithDecorativeElementsSchema = z.object({
companyName: z.string().min(5).max(30).default('AROWWAI INDUSTRIES').meta({
description: "Company or organization name",
}),
mainTitle: z.string().min(5).max(50).default('STRATEGY DECK').meta({
description: "Main title of the presentation (can include line breaks)",
}),
subtitle: z.string().min(10).max(80).default('STRATEGIES FOR GROWTH AND INNOVATION').meta({
description: "Subtitle describing the presentation topic",
}),
authorText: z.string().min(5).max(30).default('BY GROUP 1').meta({
description: "Author or presenter information",
}),
logo: ImageSchema.default({
__image_url__: 'https://images.pexels.com/photos/31995895/pexels-photo-31995895/free-photo-of-turkish-coffee-with-scenic-bursa-view.jpeg',
__image_prompt__: 'Company logo or brand icon'
}).meta({
description: "Company logo or brand icon",
}),
leftDecorativeImage: ImageSchema.default({
__image_url__: 'https://images.pexels.com/photos/31995895/pexels-photo-31995895/free-photo-of-turkish-coffee-with-scenic-bursa-view.jpeg',
__image_prompt__: 'Turkish coffee with scenic Bursa view'
}).meta({
description: "Left decorative curved shape background image",
}),
rightDecorativeImage: ImageSchema.default({
__image_url__: 'https://images.pexels.com/photos/31995895/pexels-photo-31995895/free-photo-of-turkish-coffee-with-scenic-bursa-view.jpeg',
__image_prompt__: 'Turkish coffee with scenic Bursa view'
}).meta({
description: "Right decorative curved shape background image",
})
})
const Schema = titleSlideWithDecorativeElementsSchema
const TitleSlideWithDecorativeElementsLayout = ({ data: slideData }) => {
// Split main title by newlines for proper rendering
const titleLines = (slideData?.mainTitle || 'STRATEGY DECK').split('\\n')
return (
<>
{/* Import Google Fonts */}
<link
href="https://fonts.googleapis.com/css2?family=League+Spartan:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Futura:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<div
className=" w-full rounded-sm max-w-[1280px] shadow-lg max-h-[720px] aspect-video bg-white relative z-20 mx-auto overflow-hidden"
style={{ backgroundColor: '#8d7b68' }}
>
{/* Bottom horizontal line */}
<div
className="absolute bottom-0 left-0 w-full h-0.5 bg-yellow-50"
style={{ backgroundColor: '#fdf7e4' }}
></div>
{/* Upper horizontal line */}
<div
className="absolute top-20 left-48 w-80 h-0.5 bg-yellow-50"
style={{ backgroundColor: '#fdf7e4' }}
></div>
{/* Right vertical line */}
<div
className="absolute top-0 right-8 w-0.5 h-full bg-yellow-50"
style={{ backgroundColor: '#fdf7e4' }}
></div>
{/* Upper left circular element */}
<div
className="absolute top-8 left-24 w-20 h-20 rounded-full"
style={{ backgroundColor: '#a4907c' }}
></div>
{/* Lower right circular element */}
<div
className="absolute bottom-20 right-28 w-48 h-48 rounded-full"
style={{ backgroundColor: '#a4907c' }}
></div>
{/* Left decorative curved shape */}
<div className="absolute top-20 left-0 w-64 h-80 overflow-hidden">
<svg viewBox="0 0 660 996" className="w-full h-full">
<path
d="M220.252 19.07C254 7.556 292.6 0 330.378 0C368.157 0 404.509 6.476 438.009 17.99C438.723 18.35 439.435 18.35 440.148 18.71C565.955 64.765 658.618 186.379 660.4 332.57L660.4 995.919L0 995.919L0 333.062C1.782 185.66 93.019 64.045 220.252 19.07Z"
fill="url(#leftImage)"
/>
<defs>
<pattern id="leftImage" patternUnits="objectBoundingBox" width="1" height="1">
<image
href={slideData?.leftDecorativeImage?.__image_url__ || 'https://images.pexels.com/photos/31995895/pexels-photo-31995895/free-photo-of-turkish-coffee-with-scenic-bursa-view.jpeg'}
x="0"
y="0"
width="1"
height="1"
preserveAspectRatio="xMidYMid slice"
/>
</pattern>
</defs>
</svg>
</div>
{/* Right decorative curved shape */}
<div className="absolute top-32 right-0 w-80 h-96 overflow-hidden">
<svg viewBox="0 0 660 996" className="w-full h-full">
<path
d="M220.252 19.07C254 7.556 292.6 0 330.378 0C368.157 0 404.509 6.476 438.009 17.99C438.723 18.35 439.435 18.35 440.148 18.71C565.955 64.765 658.618 186.379 660.4 332.57L660.4 995.919L0 995.919L0 333.062C1.782 185.66 93.019 64.045 220.252 19.07Z"
fill="url(#rightImage)"
/>
<defs>
<pattern id="rightImage" patternUnits="objectBoundingBox" width="1" height="1">
<image
href={slideData?.rightDecorativeImage?.__image_url__ || 'https://images.pexels.com/photos/31995895/pexels-photo-31995895/free-photo-of-turkish-coffee-with-scenic-bursa-view.jpeg'}
x="0"
y="0"
width="1"
height="1"
preserveAspectRatio="xMidYMid slice"
/>
</pattern>
</defs>
</svg>
</div>
{/* Small icon/logo near company name */}
<div className="absolute top-16 right-56 w-16 h-12 overflow-hidden">
<img
src={slideData?.logo?.__image_url__ || 'https://images.pexels.com/photos/31995895/pexels-photo-31995895/free-photo-of-turkish-coffee-with-scenic-bursa-view.jpeg'}
alt={slideData?.logo?.__image_prompt__ || 'Company logo'}
className="w-full h-full object-cover"
/>
</div>
{/* Company name */}
<div className="absolute top-14 right-8 text-right">
<h2
className="text-yellow-50 text-lg font-normal tracking-wider"
style={{ fontFamily: "'Futura', sans-serif", color: '#fdf7e4' }}
>
{slideData?.companyName || 'AROWWAI INDUSTRIES'}
</h2>
</div>
{/* Main title */}
<div className="absolute top-64 left-64 right-8 text-right">
<h1
className="text-yellow-50 text-8xl font-normal tracking-wider leading-tight"
style={{ fontFamily: "'League Spartan', sans-serif", color: '#fdf7e4' }}
>
{titleLines.map((line, index) => (
<React.Fragment key={index}>
{line}
{index < titleLines.length - 1 && <br />}
</React.Fragment>
))}
</h1>
</div>
{/* Subtitle */}
<div className="absolute bottom-24 left-64 right-8 text-right">
<h3
className="text-yellow-50 text-xl font-normal tracking-wide"
style={{ fontFamily: "'Futura', sans-serif", color: '#fdf7e4' }}
>
{slideData?.subtitle || 'STRATEGIES FOR GROWTH AND INNOVATION'}
</h3>
</div>
{/* Bottom left text */}
<div className="absolute bottom-4 left-8">
<p
className="text-yellow-50 text-2xl font-normal tracking-wide"
style={{ fontFamily: "'Futura', sans-serif", color: '#fdf7e4' }}
>
{slideData?.authorText || 'BY GROUP 1'}
</p>
</div>
</div>
</>
)
}
// Return the component
`;
const compiled = Babel.transform(jsxCode, {
const compiled = Babel.transform(cleanCode, {
presets: [
["react", { runtime: "classic" }],
["typescript", { isTSX: true, allExtensions: true }],
@ -290,7 +90,7 @@ const TitleSlideWithDecorativeElementsLayout = ({ data: slideData }) => {
/* everything declared in the string is in scope here */
return {
__esModule: true,
default: TitleSlideWithDecorativeElementsLayout,
default: dynamicSlideLayout,
layoutName,
layoutId,
layoutDescription,
@ -304,19 +104,13 @@ const TitleSlideWithDecorativeElementsLayout = ({ data: slideData }) => {
export const LayoutProvider: React.FC<{
children: ReactNode;
presentationId?: string;
}> = ({
children,
presentationId = "6038f1cb-80cb-448c-83cc-f6cb96081943", // default value
}) => {
}> = ({ children }) => {
const [layoutData, setLayoutData] = useState<LayoutData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isPreloading, setIsPreloading] = useState(false);
const dispatch = useDispatch();
console.log("🔍 layoutData", layoutData);
const buildData = async (groupedLayoutsData: GroupedLayoutsResponse[]) => {
const layouts: LayoutInfo[] = [];
@ -353,7 +147,6 @@ export const LayoutProvider: React.FC<{
const module = await import(
`@/presentation-layouts/${groupData.groupName}/${file}`
);
console.log("🔍 module", module);
if (!module.default) {
toast.error(`${file} has no default export`, {
@ -420,8 +213,8 @@ export const LayoutProvider: React.FC<{
// Cache grouped layouts
groupedLayouts.set(groupData.groupName, groupLayouts);
}
} finally {
setIsPreloading(false);
} catch (err: any) {
console.error("Compilation error:", err);
}
return {
@ -458,7 +251,8 @@ export const LayoutProvider: React.FC<{
}
const data = await buildData(groupedLayoutsData);
const customLayouts = await LoadCustomLayouts(presentationId);
const customLayouts = await LoadCustomLayouts();
setIsPreloading(false);
const combinedData = {
layoutsById: mergeMaps(data.layoutsById, customLayouts.layoutsById),
layoutsByGroup: mergeMaps(
@ -499,7 +293,7 @@ export const LayoutProvider: React.FC<{
return merged;
}
const LoadCustomLayouts = async (presentationId: string) => {
const LoadCustomLayouts = async () => {
const layouts: LayoutInfo[] = [];
const layoutsById = new Map<string, LayoutInfo>();
@ -508,89 +302,103 @@ export const LayoutProvider: React.FC<{
const fileMap = new Map<string, { fileName: string; groupName: string }>();
const groupedLayouts = new Map<string, LayoutInfo[]>();
const customLayoutResponse = await fetch(
`/api/v1/ppt/layout-management/get-layouts/${presentationId}`
);
const customLayoutsData = await customLayoutResponse.json();
const allLayout = customLayoutsData.layouts;
const settings = {
description: `Custom presentation layouts`,
ordered: false,
default: false,
};
groupSettingsMap.set(`custom-${presentationId}`, settings);
const groupLayouts: LayoutInfo[] = [];
const groupName = `custom-${presentationId}`;
if (!layoutsByGroup.has(groupName)) {
layoutsByGroup.set(groupName, new Set());
}
for (const i of allLayout) {
try {
/* ---------- 1. compile JSX to plain script ------------------ */
const module = compileCustomLayout(i.layout_code, React, z);
if (!module.default) {
toast.error(`Custom Layout has no default export`, {
description:
"Please ensure the layout file exports a default component",
});
console.warn(`❌ Custom Layout has no default export`);
continue;
try {
const customGroupResponse = await fetch(
"/api/v1/ppt/layout-management/summary"
);
const customGroupData = await customGroupResponse.json();
console.log("🔍 customGroupData", customGroupData);
const customGroup = customGroupData.presentations;
console.log("🔍 customGroup", customGroup);
for (const group of customGroup) {
const groupName = `custom-${group.presentation_id}`;
if (!layoutsByGroup.has(groupName)) {
layoutsByGroup.set(groupName, new Set());
}
if (!module.Schema) {
toast.error(`Custom Layout has no Schema export`, {
description: "Please ensure the layout file exports a Schema",
});
console.warn(`❌ Custom Layout has no Schema export`);
continue;
}
const cacheKey = createCacheKey(
`custom-${presentationId}`,
i.layout_name
const presentationId = group.presentation_id;
const customLayoutResponse = await fetch(
`/api/v1/ppt/layout-management/get-layouts/${presentationId}`
);
if (!layoutCache.has(cacheKey)) {
layoutCache.set(cacheKey, module.default);
}
const customLayoutsData = await customLayoutResponse.json();
const allLayout = customLayoutsData.layouts;
const originalLayoutId =
module.layoutId || i.layout_name.toLowerCase().replace(/layout$/, "");
const uniqueKey = `${`custom-${presentationId}`}:${originalLayoutId}`;
const layoutName =
module.layoutName || i.layout_name.replace(/([A-Z])/g, " $1").trim();
const layoutDescription =
module.layoutDescription || `${layoutName} layout for presentations`;
const jsonSchema = z.toJSONSchema(module.Schema, {
override: (ctx) => {
delete ctx.jsonSchema.default;
},
});
const layout: LayoutInfo = {
id: uniqueKey,
name: layoutName,
description: layoutDescription,
json_schema: jsonSchema,
groupName: groupName,
const settings = {
description: `Custom presentation layouts`,
ordered: false,
default: false,
};
layoutsById.set(uniqueKey, layout);
layoutsByGroup.get(groupName)!.add(uniqueKey);
fileMap.set(uniqueKey, {
fileName: i.layout_name,
groupName: groupName,
});
groupLayouts.push(layout);
layouts.push(layout);
} catch (err: any) {
console.error("Compilation error:", err);
groupSettingsMap.set(`custom-${presentationId}`, settings);
const groupLayouts: LayoutInfo[] = [];
for (const i of allLayout) {
/* ---------- 1. compile JSX to plain script ------------------ */
const module = compileCustomLayout(i.layout_code, React, z);
if (!module.default) {
toast.error(`Custom Layout has no default export`, {
description:
"Please ensure the layout file exports a default component",
});
console.warn(`❌ Custom Layout has no default export`);
continue;
}
if (!module.Schema) {
toast.error(`Custom Layout has no Schema export`, {
description: "Please ensure the layout file exports a Schema",
});
console.warn(`❌ Custom Layout has no Schema export`);
continue;
}
const cacheKey = createCacheKey(
`custom-${presentationId}`,
i.layout_name
);
if (!layoutCache.has(cacheKey)) {
layoutCache.set(cacheKey, module.default);
}
const originalLayoutId =
module.layoutId ||
i.layout_name.toLowerCase().replace(/layout$/, "");
const uniqueKey = `${`custom-${presentationId}`}:${originalLayoutId}`;
const layoutName =
module.layoutName ||
i.layout_name.replace(/([A-Z])/g, " $1").trim();
const layoutDescription =
module.layoutDescription ||
`${layoutName} layout for presentations`;
const jsonSchema = z.toJSONSchema(module.Schema, {
override: (ctx) => {
delete ctx.jsonSchema.default;
},
});
const layout: LayoutInfo = {
id: uniqueKey,
name: layoutName,
description: layoutDescription,
json_schema: jsonSchema,
groupName: groupName,
};
layoutsById.set(uniqueKey, layout);
layoutsByGroup.get(groupName)!.add(uniqueKey);
fileMap.set(uniqueKey, {
fileName: i.layout_name,
groupName: groupName,
});
groupLayouts.push(layout);
layouts.push(layout);
}
// Cache grouped layouts
groupedLayouts.set(groupName, groupLayouts);
}
} catch (err: any) {
console.error("Compilation error:", err);
}
// Cache grouped layouts
groupedLayouts.set(groupName, groupLayouts);
return {
layoutsById,
@ -684,7 +492,7 @@ export const LayoutProvider: React.FC<{
// Load layouts on mount
useEffect(() => {
loadLayouts();
}, [presentationId]); // Add presentationId to dependency array
}, []); // Add presentationId to dependency array
const contextValue: LayoutContextType = {
getLayoutById,

View file

@ -1,89 +1,97 @@
'use client'
import React, { useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { useLayout } from '../context/LayoutContext';
import EditableLayoutWrapper from '../components/EditableLayoutWrapper';
import TiptapTextReplacer from '../components/TiptapTextReplacer';
import { updateSlideContent } from '../../../store/slices/presentationGeneration';
"use client";
import React, { useMemo } from "react";
import { useDispatch } from "react-redux";
import { useLayout } from "../context/LayoutContext";
import EditableLayoutWrapper from "../components/EditableLayoutWrapper";
import TiptapTextReplacer from "../components/TiptapTextReplacer";
import { updateSlideContent } from "../../../store/slices/presentationGeneration";
import { Loader2 } from "lucide-react";
export const useGroupLayouts = () => {
const dispatch = useDispatch();
const {
getLayoutByIdAndGroup,
getLayoutsByGroup,
getLayout,
loading
} = useLayout();
const dispatch = useDispatch();
const { getLayoutByIdAndGroup, getLayoutsByGroup, getLayout, loading } =
useLayout();
const getGroupLayout = useMemo(() => {
return (layoutId: string, groupName: string) => {
const layout = getLayoutByIdAndGroup(layoutId, groupName);
if (layout) {
return getLayout(layoutId);
}
console.warn(`Layout ${layoutId} not found in group ${groupName}`);
return null;
};
}, [getLayoutByIdAndGroup, getLayout]);
const getGroupLayouts = useMemo(() => {
return (groupName: string) => {
return getLayoutsByGroup(groupName);
};
}, [getLayoutsByGroup]);
// Render slide content with group validation, automatic Tiptap text editing, and editable images/icons
const renderSlideContent = useMemo(() => {
return (slide: any, isEditMode: boolean) => {
const Layout = getGroupLayout(slide.layout, slide.layout_group);
if (!Layout) {
return (
<div className="flex flex-col items-center justify-center aspect-video h-full bg-gray-100 rounded-lg">
<p className="text-gray-600 text-center text-base">
Layout &quot;{slide.layout}&quot; not found in &quot;{slide.layout_group}&quot; group
</p>
</div>
);
}
if (isEditMode) {
return (
<EditableLayoutWrapper
slideIndex={slide.index}
slideData={slide.content}
properties={slide.properties}
>
<TiptapTextReplacer
key={slide.id}
slideData={slide.content}
slideIndex={slide.index}
onContentChange={(content: string, dataPath: string, slideIndex?: number) => {
// Dispatch Redux action to update slide content
if (dataPath && slideIndex !== undefined) {
dispatch(updateSlideContent({
slideIndex: slideIndex,
dataPath: dataPath,
content: content
}));
}
}}
>
<Layout data={slide.content} />
</TiptapTextReplacer>
</EditableLayoutWrapper>
);
}
return <Layout data={slide.content} />;
};
}, [getGroupLayout, dispatch]);
return {
getGroupLayout,
getGroupLayouts,
renderSlideContent,
loading
const getGroupLayout = useMemo(() => {
return (layoutId: string, groupName: string) => {
const layout = getLayoutByIdAndGroup(layoutId, groupName);
if (layout) {
return getLayout(layoutId);
}
console.warn(`Layout ${layoutId} not found in group ${groupName}`);
return null;
};
};
}, [getLayoutByIdAndGroup, getLayout]);
const getGroupLayouts = useMemo(() => {
return (groupName: string) => {
return getLayoutsByGroup(groupName);
};
}, [getLayoutsByGroup]);
// Render slide content with group validation, automatic Tiptap text editing, and editable images/icons
const renderSlideContent = useMemo(() => {
return (slide: any, isEditMode: boolean) => {
const Layout = getGroupLayout(slide.layout, slide.layout_group);
if (loading) {
return (
<div className="flex flex-col items-center justify-center aspect-video h-full bg-gray-100 rounded-lg">
<Loader2 className="w-8 h-8 animate-spin text-blue-800" />
</div>
);
}
if (!Layout) {
return (
<div className="flex flex-col items-center justify-center aspect-video h-full bg-gray-100 rounded-lg">
<p className="text-gray-600 text-center text-base">
Layout &quot;{slide.layout}&quot; not found in &quot;
{slide.layout_group}&quot; group
</p>
</div>
);
}
if (isEditMode) {
return (
<EditableLayoutWrapper
slideIndex={slide.index}
slideData={slide.content}
properties={slide.properties}
>
<TiptapTextReplacer
key={slide.id}
slideData={slide.content}
slideIndex={slide.index}
onContentChange={(
content: string,
dataPath: string,
slideIndex?: number
) => {
// Dispatch Redux action to update slide content
if (dataPath && slideIndex !== undefined) {
dispatch(
updateSlideContent({
slideIndex: slideIndex,
dataPath: dataPath,
content: content,
})
);
}
}}
>
<Layout data={slide.content} />
</TiptapTextReplacer>
</EditableLayoutWrapper>
);
}
return <Layout data={slide.content} />;
};
}, [getGroupLayout, dispatch]);
return {
getGroupLayout,
getGroupLayouts,
renderSlideContent,
loading,
};
};

View file

@ -1,5 +1,4 @@
import React, { useEffect, useState, useMemo } from "react";
import { Slide } from "../../types/slide";
import { Loader2, PlusIcon, Trash2, WandSparkles } from "lucide-react";
import {
Popover,
@ -13,7 +12,10 @@ import { PresentationGenerationApi } from "../../services/api/presentation-gener
import ToolTip from "@/components/ToolTip";
import { RootState } from "@/store/store";
import { useDispatch, useSelector } from "react-redux";
import { deletePresentationSlide, updateSlide } from "@/store/slices/presentationGeneration";
import {
deletePresentationSlide,
updateSlide,
} from "@/store/slices/presentationGeneration";
import { useGroupLayouts } from "../../hooks/useGroupLayouts";
import NewSlide from "../../components/NewSlide";
@ -23,12 +25,7 @@ interface SlideContentProps {
presentationId: string;
}
const SlideContent = ({
slide,
index,
presentationId,
}: SlideContentProps) => {
const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
const dispatch = useDispatch();
const [isUpdating, setIsUpdating] = useState(false);
const [showNewSlideSelection, setShowNewSlideSelection] = useState(false);
@ -89,7 +86,9 @@ const SlideContent = ({
) {
// Scroll to the last slide (newly generated during streaming)
const lastSlideIndex = presentationData.slides.length - 1;
const slideElement = document.getElementById(`slide-${presentationData.slides[lastSlideIndex].index}`);
const slideElement = document.getElementById(
`slide-${presentationData.slides[lastSlideIndex].index}`
);
if (slideElement) {
slideElement.scrollIntoView({
behavior: "smooth",
@ -104,6 +103,23 @@ const SlideContent = ({
return renderSlideContent(slide, isStreaming ? false : true); // Enable edit mode for main content
}, [renderSlideContent, slide, isStreaming]);
useEffect(() => {
if (isStreaming || loading) {
return;
}
if (slide) {
const existingScript = document.querySelector(
'script[src*="tailwindcss.com"]'
);
if (!existingScript) {
const script = document.createElement("script");
script.src = "https://cdn.tailwindcss.com";
script.async = true;
document.head.appendChild(script);
}
}
}, [slide, isStreaming, loading]);
return (
<>
<div
@ -113,11 +129,19 @@ const SlideContent = ({
{isStreaming && (
<Loader2 className="w-8 h-8 absolute right-2 top-2 z-30 text-blue-800 animate-spin" />
)}
<div data-layout={slide.layout} data-group={slide.layout_group} className={` w-full group `}>
<div
data-layout={slide.layout}
data-group={slide.layout_group}
className={` w-full group `}
>
{/* render slides */}
{loading ? <div className="flex flex-col bg-white aspect-video items-center justify-center h-full">
<Loader2 className="w-8 h-8 animate-spin" />
</div> : slideContent}
{loading ? (
<div className="flex flex-col bg-white aspect-video items-center justify-center h-full">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
) : (
slideContent
)}
{!showNewSlideSelection && (
<div className="group-hover:opacity-100 hidden md:block opacity-0 transition-opacity my-4 duration-300">
@ -194,8 +218,9 @@ const SlideContent = ({
<button
disabled={isUpdating}
type="submit"
className={`bg-gradient-to-r from-[#9034EA] to-[#5146E5] rounded-[32px] px-4 py-2 text-white flex items-center justify-end gap-2 ml-auto ${isUpdating ? "opacity-70 cursor-not-allowed" : ""
}`}
className={`bg-gradient-to-r from-[#9034EA] to-[#5146E5] rounded-[32px] px-4 py-2 text-white flex items-center justify-end gap-2 ml-auto ${
isUpdating ? "opacity-70 cursor-not-allowed" : ""
}`}
>
{isUpdating ? "Updating..." : "Update"}
<SendHorizontal className="w-4 sm:w-5 h-4 sm:h-5" />

View file

@ -24,12 +24,14 @@ const EachSlide = ({
retrySlide,
setSlides,
onSlideUpdate,
isProcessingPptx,
}: {
slide: any;
index: number;
retrySlide: (index: number) => void;
setSlides: React.Dispatch<React.SetStateAction<any[]>>;
onSlideUpdate?: (updatedSlideData: any) => void;
isProcessingPptx: boolean;
}) => {
const [isUpdating, setIsUpdating] = useState(false);
const [prompt, setPrompt] = useState("");
@ -411,33 +413,35 @@ const EachSlide = ({
)}
</div>
<div className="flex gap-6">
{slide.processed && slide.html && !isEditMode && (
<div className=" ">
<ToolTip content="Edit slide">
{!isProcessingPptx && (
<div className="flex gap-6">
{slide.processed && slide.html && !isEditMode && (
<div className=" ">
<ToolTip content="Edit slide">
<button
onClick={handleEditClick}
className={`px-6 py-2 flex gap-2 text-sm items-center group-hover:scale-105 rounded-lg bg-[#5141e5] hover:shadow-md transition-all duration-300 cursor-pointer shadow-md `}
>
<Edit className="w-4 sm:w-5 h-4 sm:h-5 text-white" />
<span className="text-white">Edit Slide</span>
</button>
</ToolTip>
</div>
)}
<div>
<ToolTip content="Retry fetch">
<button
onClick={handleEditClick}
className={`px-6 py-2 flex gap-2 text-sm items-center group-hover:scale-105 rounded-lg bg-[#5141e5] hover:shadow-md transition-all duration-300 cursor-pointer shadow-md `}
onClick={() => retrySlide(index)}
disabled={slide.processing}
className="px-6 py-2 flex gap-2 text-sm items-center group-hover:scale-105 rounded-lg bg-[#5141e5] hover:shadow-md transition-all duration-300 cursor-pointer shadow-md"
>
<Edit className="w-4 sm:w-5 h-4 sm:h-5 text-white" />
<span className="text-white">Edit Slide</span>
<Repeat2 className="w-4 sm:w-5 h-4 sm:h-5 text-white" />
<span className="text-white">Retry Fetch</span>
</button>
</ToolTip>
</div>
)}
<div>
<ToolTip content="Retry fetch">
<button
onClick={() => retrySlide(index)}
disabled={slide.processing}
className="px-6 py-2 flex gap-2 text-sm items-center group-hover:scale-105 rounded-lg bg-[#5141e5] hover:shadow-md transition-all duration-300 cursor-pointer shadow-md"
>
<Repeat2 className="w-4 sm:w-5 h-4 sm:h-5 text-white" />
<span className="text-white">Retry Fetch</span>
</button>
</ToolTip>
</div>
</div>
)}
</div>
</CardTitle>
</CardHeader>

View file

@ -18,7 +18,6 @@ import { ApiResponseHandler } from "@/app/(presentation-generator)/services/api/
import { v4 as uuidv4 } from "uuid";
// Types
import EachSlide from "./components/EachSlide";
import { firstSlide, processData, slide2, slide3, slide4 } from "./data";
interface SlideData {
slide_number: number;
screenshot_url: string;
@ -67,7 +66,7 @@ const CustomLayoutPage = () => {
const reactComponents = [];
const presentationId = uuidv4();
for (let i = 0; i < slides.length - 3; i++) {
for (let i = 0; i < slides.length; i++) {
const slide = slides[i];
if (!slide.html) {
@ -529,6 +528,7 @@ const CustomLayoutPage = () => {
key={index}
slide={slide}
index={index}
isProcessingPptx={isProcessingPptx}
retrySlide={retrySlide}
setSlides={setSlides}
onSlideUpdate={(updatedSlideData) =>

View file

@ -1,184 +1,361 @@
'use client'
import { useState, useEffect, useRef } from 'react'
"use client";
import React, { useState, useEffect, useRef } from "react";
import * as Babel from "@babel/standalone";
import * as z from "zod";
import { LayoutInfo, LayoutGroup, GroupedLayoutsResponse, GroupSetting } from '../types'
import { toast } from 'sonner'
import {
LayoutInfo,
LayoutGroup,
GroupedLayoutsResponse,
GroupSetting,
} from "../types";
import { toast } from "sonner";
interface UseGroupLayoutLoaderReturn {
layoutGroup: LayoutGroup | null
loading: boolean
error: string | null
retry: () => void
layoutGroup: LayoutGroup | null;
loading: boolean;
error: string | null;
retry: () => void;
}
// Global cache to store layout groups and avoid re-fetching
const layoutGroupCache = new Map<string, LayoutGroup>()
const loadingGroupsCache = new Set<string>()
const layoutGroupCache = new Map<string, LayoutGroup>();
const loadingGroupsCache = new Set<string>();
export const useGroupLayoutLoader = (groupSlug: string): UseGroupLayoutLoaderReturn => {
const [layoutGroup, setLayoutGroup] = useState<LayoutGroup | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const hasMountedRef = useRef(false)
// Extract Babel compilation logic into a utility function
const compileCustomLayout = (layoutCode: string, React: any, z: any) => {
const cleanCode = layoutCode
.replace(/import\s+React\s+from\s+'react';?/g, "")
.replace(/import\s*{\s*z\s*}\s*from\s+'zod';?/g, "");
const loadGroupLayouts = async () => {
// Check cache first
if (layoutGroupCache.has(groupSlug)) {
setLayoutGroup(layoutGroupCache.get(groupSlug)!)
setLoading(false)
setError(null)
return
}
const compiled = Babel.transform(cleanCode, {
presets: [
["react", { runtime: "classic" }],
["typescript", { isTSX: true, allExtensions: true }],
],
sourceType: "script",
}).code;
// Prevent multiple simultaneous requests for the same group
if (loadingGroupsCache.has(groupSlug)) {
return
}
const factory = new Function(
"React",
"z",
`
${compiled}
/* everything declared in the string is in scope here */
return {
__esModule: true,
default: dynamicSlideLayout,
layoutName,
layoutId,
layoutDescription,
Schema
};
`
);
return factory(React, z);
};
export const useGroupLayoutLoader = (
groupSlug: string
): UseGroupLayoutLoaderReturn => {
const [layoutGroup, setLayoutGroup] = useState<LayoutGroup | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const hasMountedRef = useRef(false);
const loadCustomLayouts = async () => {
try {
// Check if this is a custom group (starts with 'custom-')
if (!groupSlug.startsWith("custom-")) {
return null;
}
const presentationId = groupSlug.replace("custom-", "");
const customLayoutResponse = await fetch(
`/api/v1/ppt/layout-management/get-layouts/${presentationId}`
);
if (!customLayoutResponse.ok) {
throw new Error(
`Failed to fetch custom layouts: ${customLayoutResponse.statusText}`
);
}
const customLayoutsData = await customLayoutResponse.json();
const allLayouts = customLayoutsData.layouts;
const groupLayouts: LayoutInfo[] = [];
const settings: GroupSetting = {
description: `Custom presentation layouts`,
ordered: false,
default: false,
};
for (const layoutData of allLayouts) {
try {
setLoading(true)
setError(null)
loadingGroupsCache.add(groupSlug)
// Compile custom layout code
const module = compileCustomLayout(layoutData.layout_code, React, z);
const response = await fetch('/api/layouts')
if (!response.ok) {
toast.error('Error loading layouts', {
description: response.statusText,
})
return
}
const groupedLayoutsData: GroupedLayoutsResponse[] = await response.json()
// Find the specific group by slug
const targetGroupData = groupedLayoutsData.find(
group => group.groupName.toLowerCase() === groupSlug.toLowerCase()
)
if (!module.default) {
toast.error(`Custom Layout has no default export`, {
description:
"Please ensure the layout file exports a default component",
});
console.warn(`❌ Custom Layout has no default export`);
continue;
}
if (!targetGroupData) {
setError(`Group "${groupSlug}" not found`)
return
}
if (!module.Schema) {
toast.error(`Custom Layout has no Schema export`, {
description: "Please ensure the layout file exports a Schema",
});
console.warn(`❌ Custom Layout has no Schema export`);
continue;
}
const groupLayouts: LayoutInfo[] = []
// Use empty object to let schema apply its default values
const sampleData = module.Schema.parse({});
// Use settings from settings.json or provide defaults
const groupSettings: GroupSetting = targetGroupData.settings ? targetGroupData.settings : {
description: `${targetGroupData.groupName} presentation layouts`,
ordered: false,
default: false
}
for (const fileName of targetGroupData.files) {
try {
const layoutName = fileName.replace('.tsx', '').replace('.ts', '')
const module = await import(`@/presentation-layouts/${targetGroupData.groupName}/${layoutName}`)
const originalLayoutId =
module.layoutId ||
layoutData.layout_name.toLowerCase().replace(/layout$/, "");
const layoutName =
module.layoutName ||
layoutData.layout_name.replace(/([A-Z])/g, " $1").trim();
if (!module.default) {
toast.error(`${layoutName} has no default export`, {
description: 'Please ensure the layout file exports a default component',
})
const layoutInfo: LayoutInfo = {
name: layoutName,
component: module.default,
schema: module.Schema,
sampleData,
fileName: layoutData.layout_name,
groupName: groupSlug,
layoutId: originalLayoutId,
};
console.warn(`${layoutName} has no default export`)
return;
}
groupLayouts.push(layoutInfo);
} catch (compilationError) {
console.error(
`Failed to compile custom layout ${layoutData.layout_name}:`,
compilationError
);
toast.error(`Failed to compile ${layoutData.layout_name}`, {
description: "There was an error compiling the custom layout code",
});
}
}
if (!module.Schema) {
toast.error(`${layoutName} is missing required Schema export`, {
description: 'Please ensure the layout file exports a Schema',
})
console.error(`${layoutName} is missing required Schema export`)
return;
}
if (groupLayouts.length === 0) {
throw new Error(
`No valid custom layouts found in "${groupSlug}" group.`
);
}
// Use empty object to let schema apply its default values
const sampleData = module.Schema.parse({})
const layoutId = module.layoutId || layoutName.toLowerCase().replace(/layout$/, '')
return {
groupName: groupSlug,
layouts: groupLayouts,
settings,
};
} catch (error) {
console.error("Error loading custom layouts:", error);
throw error;
}
};
const layoutInfo: LayoutInfo = {
name: layoutName,
component: module.default,
schema: module.Schema,
sampleData,
fileName,
groupName: targetGroupData.groupName,
layoutId
}
const loadGroupLayouts = async () => {
// Check cache first
if (layoutGroupCache.has(groupSlug)) {
setLayoutGroup(layoutGroupCache.get(groupSlug)!);
setLoading(false);
setError(null);
return;
}
groupLayouts.push(layoutInfo)
// Prevent multiple simultaneous requests for the same group
if (loadingGroupsCache.has(groupSlug)) {
return;
}
} catch (importError) {
console.error(`Failed to import ${fileName} from ${targetGroupData.groupName}:`, importError)
try {
setLoading(true);
setError(null);
loadingGroupsCache.add(groupSlug);
// Try alternative import path
try {
const layoutName = fileName.replace('.tsx', '').replace('.ts', '')
const module = await import(`@/presentation-layouts/${targetGroupData.groupName}/${layoutName}`)
// Check if this is a custom group
if (groupSlug.startsWith("custom-")) {
const customGroup = await loadCustomLayouts();
if (customGroup) {
// Cache the result
layoutGroupCache.set(groupSlug, customGroup);
setLayoutGroup(customGroup);
setError(null);
return;
}
}
if (module.default && module.Schema) {
const sampleData = module.Schema.parse({})
// if layoutId is not provided, use the layoutName
const layoutId = module.layoutId || layoutName.toLowerCase().replace(/layout$/, '')
const layoutInfo: LayoutInfo = {
name: layoutName,
component: module.default,
schema: module.Schema,
sampleData,
fileName,
groupName: targetGroupData.groupName,
layoutId
}
groupLayouts.push(layoutInfo)
} else {
console.error(`${layoutName} is missing required exports (default component or Schema)`)
}
} catch (altError) {
console.error(`Alternative import also failed for ${fileName} from ${targetGroupData.groupName}:`, altError)
}
}
}
// Load standard layouts
const response = await fetch("/api/layouts");
if (!response.ok) {
toast.error("Error loading layouts", {
description: response.statusText,
});
return;
}
const groupedLayoutsData: GroupedLayoutsResponse[] =
await response.json();
if (groupLayouts.length === 0) {
toast.error('No valid layouts found', {
description: `No valid layouts found in "${groupSlug}" group.`,
})
setError(`No valid layouts found in "${groupSlug}" group.`)
// Find the specific group by slug
const targetGroupData = groupedLayoutsData.find(
(group) => group.groupName.toLowerCase() === groupSlug.toLowerCase()
);
if (!targetGroupData) {
setError(`Group "${groupSlug}" not found`);
return;
}
const groupLayouts: LayoutInfo[] = [];
// Use settings from settings.json or provide defaults
const groupSettings: GroupSetting = targetGroupData.settings
? targetGroupData.settings
: {
description: `${targetGroupData.groupName} presentation layouts`,
ordered: false,
default: false,
};
for (const fileName of targetGroupData.files) {
try {
const layoutName = fileName.replace(".tsx", "").replace(".ts", "");
const module = await import(
`@/presentation-layouts/${targetGroupData.groupName}/${layoutName}`
);
if (!module.default) {
toast.error(`${layoutName} has no default export`, {
description:
"Please ensure the layout file exports a default component",
});
console.warn(`${layoutName} has no default export`);
return;
}
if (!module.Schema) {
toast.error(`${layoutName} is missing required Schema export`, {
description: "Please ensure the layout file exports a Schema",
});
console.error(`${layoutName} is missing required Schema export`);
return;
}
// Use empty object to let schema apply its default values
const sampleData = module.Schema.parse({});
const layoutId =
module.layoutId || layoutName.toLowerCase().replace(/layout$/, "");
const layoutInfo: LayoutInfo = {
name: layoutName,
component: module.default,
schema: module.Schema,
sampleData,
fileName,
groupName: targetGroupData.groupName,
layoutId,
};
groupLayouts.push(layoutInfo);
} catch (importError) {
console.error(
`Failed to import ${fileName} from ${targetGroupData.groupName}:`,
importError
);
// Try alternative import path
try {
const layoutName = fileName.replace(".tsx", "").replace(".ts", "");
const module = await import(
`@/presentation-layouts/${targetGroupData.groupName}/${layoutName}`
);
if (module.default && module.Schema) {
const sampleData = module.Schema.parse({});
// if layoutId is not provided, use the layoutName
const layoutId =
module.layoutId ||
layoutName.toLowerCase().replace(/layout$/, "");
const layoutInfo: LayoutInfo = {
name: layoutName,
component: module.default,
schema: module.Schema,
sampleData,
fileName,
groupName: targetGroupData.groupName,
layoutId,
};
groupLayouts.push(layoutInfo);
} else {
const group: LayoutGroup = {
groupName: targetGroupData.groupName,
layouts: groupLayouts,
settings: groupSettings
}
// Cache the result
layoutGroupCache.set(groupSlug, group)
setLayoutGroup(group)
setError(null)
console.error(
`${layoutName} is missing required exports (default component or Schema)`
);
}
} catch (error) {
console.error('Error loading group layouts:', error)
setError(error instanceof Error ? error.message : 'Failed to load group layouts')
} finally {
setLoading(false)
loadingGroupsCache.delete(groupSlug)
} catch (altError) {
console.error(
`Alternative import also failed for ${fileName} from ${targetGroupData.groupName}:`,
altError
);
}
}
}
}
const retry = () => {
hasMountedRef.current = false
loadGroupLayouts()
}
if (groupLayouts.length === 0) {
toast.error("No valid layouts found", {
description: `No valid layouts found in "${groupSlug}" group.`,
});
setError(`No valid layouts found in "${groupSlug}" group.`);
} else {
const group: LayoutGroup = {
groupName: targetGroupData.groupName,
layouts: groupLayouts,
settings: groupSettings,
};
useEffect(() => {
if (groupSlug && !hasMountedRef.current) {
hasMountedRef.current = true
loadGroupLayouts()
}
}, [groupSlug])
return {
layoutGroup,
loading,
error,
retry,
// Cache the result
layoutGroupCache.set(groupSlug, group);
setLayoutGroup(group);
setError(null);
}
} catch (error) {
console.error("Error loading group layouts:", error);
setError(
error instanceof Error ? error.message : "Failed to load group layouts"
);
} finally {
setLoading(false);
loadingGroupsCache.delete(groupSlug);
}
}
};
const retry = () => {
hasMountedRef.current = false;
loadGroupLayouts();
};
useEffect(() => {
if (groupSlug && !hasMountedRef.current) {
hasMountedRef.current = true;
loadGroupLayouts();
}
}, [groupSlug]);
return {
layoutGroup,
loading,
error,
retry,
};
};