From bef5afb32ad13f7536813ccf87ea08a814201b42 Mon Sep 17 00:00:00 2001 From: shiva raj badu Date: Sat, 2 Aug 2025 01:13:47 +0545 Subject: [PATCH] feat(Nextjs): Custom Layout Load, Preview & presentation generation added --- servers/fastapi/chroma/chroma.sqlite3 | Bin 4329472 -> 4329472 bytes .../components/TiptapTextReplacer.tsx | 562 ++++++++++-------- .../context/LayoutContext.tsx | 396 ++++-------- .../hooks/useGroupLayouts.tsx | 178 +++--- .../presentation/components/SlideContent.tsx | 55 +- .../custom-layout/components/EachSlide.tsx | 46 +- servers/nextjs/app/custom-layout/page.tsx | 4 +- .../hooks/useGroupLayoutLoader.ts | 483 ++++++++++----- 8 files changed, 902 insertions(+), 822 deletions(-) diff --git a/servers/fastapi/chroma/chroma.sqlite3 b/servers/fastapi/chroma/chroma.sqlite3 index 8e0ae9f0c6e5d6e88499cc605fc2e6ab0263c43b..6bcb2901e997e98e61b85d0b6b046fa938c648a1 100644 GIT binary patch delta 306 zcmWm9HxdB>06LcTD9_!!Ws;?b;p)oeU%u~NGK;ckupe~wsH*~u5MhED7Fc0}9S%6*f*T%4@WKZf zegqIi2w_AJMGSE$NFa$6(#Rl-9P%ijh!V=Epo$vmXrPG}+UTH*9{Lzyh!Mt^V2T;$ PSYV0&ef8GW_4e}vswZ>k delta 300 zcmWm9Hx|JF0Dxg3dWq;nkKTJHJZG>wN<0H2gWX`u z;z&S25-FsSK^8gWp`w5yN+_d(Dr%^sfhJmLqk}Gb=wpB(Mi^s)DQ1{sfhAV>-`8(L H+w49+NA`2c diff --git a/servers/nextjs/app/(presentation-generator)/components/TiptapTextReplacer.tsx b/servers/nextjs/app/(presentation-generator)/components/TiptapTextReplacer.tsx index d4be12ae..3f23474a 100644 --- a/servers/nextjs/app/(presentation-generator)/components/TiptapTextReplacer.tsx +++ b/servers/nextjs/app/(presentation-generator)/components/TiptapTextReplacer.tsx @@ -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 = ({ - children, - slideData, - slideIndex, - onContentChange = () => { }, + children, + slideData, + slideIndex, + onContentChange = () => {}, }) => { + const containerRef = useRef(null); + const [processedElements, setProcessedElements] = useState( + new Set() + ); + useEffect(() => { + if (!containerRef.current) return; - const containerRef = useRef(null); - const [processedElements, setProcessedElements] = useState(new Set()); - 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( - { - 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( + { + 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 ( -
- {children} -
- ); + // 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 ( +
+ {children} +
+ ); }; -export default TiptapTextReplacer; \ No newline at end of file +export default TiptapTextReplacer; diff --git a/servers/nextjs/app/(presentation-generator)/context/LayoutContext.tsx b/servers/nextjs/app/(presentation-generator)/context/LayoutContext.tsx index 520844d6..d08147b4 100644 --- a/servers/nextjs/app/(presentation-generator)/context/LayoutContext.tsx +++ b/servers/nextjs/app/(presentation-generator)/context/LayoutContext.tsx @@ -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 */} - - - -
- {/* Bottom horizontal line */} -
- - {/* Upper horizontal line */} -
- - {/* Right vertical line */} -
- - {/* Upper left circular element */} -
- - {/* Lower right circular element */} -
- - {/* Left decorative curved shape */} -
- - - - - - - - -
- - {/* Right decorative curved shape */} -
- - - - - - - - -
- - {/* Small icon/logo near company name */} -
- {slideData?.logo?.__image_prompt__ -
- - {/* Company name */} -
-

- {slideData?.companyName || 'AROWWAI INDUSTRIES'} -

-
- - {/* Main title */} -
-

- {titleLines.map((line, index) => ( - - {line} - {index < titleLines.length - 1 &&
} -
- ))} -

-
- - {/* Subtitle */} -
-

- {slideData?.subtitle || 'STRATEGIES FOR GROWTH AND INNOVATION'} -

-
- - {/* Bottom left text */} -
-

- {slideData?.authorText || 'BY GROUP 1'} -

-
-
- - ) -} - -// 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(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(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(); @@ -508,89 +302,103 @@ export const LayoutProvider: React.FC<{ const fileMap = new Map(); const groupedLayouts = new Map(); - 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, diff --git a/servers/nextjs/app/(presentation-generator)/hooks/useGroupLayouts.tsx b/servers/nextjs/app/(presentation-generator)/hooks/useGroupLayouts.tsx index 422539d9..fb25ec50 100644 --- a/servers/nextjs/app/(presentation-generator)/hooks/useGroupLayouts.tsx +++ b/servers/nextjs/app/(presentation-generator)/hooks/useGroupLayouts.tsx @@ -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 ( -
-

- Layout "{slide.layout}" not found in "{slide.layout_group}" group -

-
- ); - } - - if (isEditMode) { - return ( - - { - // Dispatch Redux action to update slide content - if (dataPath && slideIndex !== undefined) { - dispatch(updateSlideContent({ - slideIndex: slideIndex, - dataPath: dataPath, - content: content - })); - } - }} - > - - - - ); - } - return ; - }; - - }, [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; }; -}; \ No newline at end of file + }, [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 ( +
+ +
+ ); + } + if (!Layout) { + return ( +
+

+ Layout "{slide.layout}" not found in " + {slide.layout_group}" group +

+
+ ); + } + + if (isEditMode) { + return ( + + { + // Dispatch Redux action to update slide content + if (dataPath && slideIndex !== undefined) { + dispatch( + updateSlideContent({ + slideIndex: slideIndex, + dataPath: dataPath, + content: content, + }) + ); + } + }} + > + + + + ); + } + return ; + }; + }, [getGroupLayout, dispatch]); + + return { + getGroupLayout, + getGroupLayouts, + renderSlideContent, + loading, + }; +}; diff --git a/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx b/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx index 3abf1649..6a81f2cd 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx +++ b/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx @@ -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 ( <>
)} -
+
{/* render slides */} - {loading ?
- -
: slideContent} + {loading ? ( +
+ +
+ ) : ( + slideContent + )} {!showNewSlideSelection && (
@@ -194,8 +218,9 @@ const SlideContent = ({
-
- {slide.processed && slide.html && !isEditMode && ( -
- + {!isProcessingPptx && ( +
+ {slide.processed && slide.html && !isEditMode && ( +
+ + + +
+ )} +
+
- )} -
- - -
-
+ )}
diff --git a/servers/nextjs/app/custom-layout/page.tsx b/servers/nextjs/app/custom-layout/page.tsx index 357eae4e..81276442 100644 --- a/servers/nextjs/app/custom-layout/page.tsx +++ b/servers/nextjs/app/custom-layout/page.tsx @@ -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) => diff --git a/servers/nextjs/app/layout-preview/hooks/useGroupLayoutLoader.ts b/servers/nextjs/app/layout-preview/hooks/useGroupLayoutLoader.ts index e7cee788..7e556239 100644 --- a/servers/nextjs/app/layout-preview/hooks/useGroupLayoutLoader.ts +++ b/servers/nextjs/app/layout-preview/hooks/useGroupLayoutLoader.ts @@ -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() -const loadingGroupsCache = new Set() +const layoutGroupCache = new Map(); +const loadingGroupsCache = new Set(); -export const useGroupLayoutLoader = (groupSlug: string): UseGroupLayoutLoaderReturn => { - const [layoutGroup, setLayoutGroup] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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); } -} \ No newline at end of file + }; + + const retry = () => { + hasMountedRef.current = false; + loadGroupLayouts(); + }; + + useEffect(() => { + if (groupSlug && !hasMountedRef.current) { + hasMountedRef.current = true; + loadGroupLayouts(); + } + }, [groupSlug]); + + return { + layoutGroup, + loading, + error, + retry, + }; +};