feat(Nextjs): Custom Layout Load, Preview & presentation generation added
This commit is contained in:
parent
0014c57605
commit
bef5afb32a
8 changed files with 902 additions and 822 deletions
Binary file not shown.
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 "{slide.layout}" not found in "{slide.layout_group}" 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 "{slide.layout}" not found in "
|
||||
{slide.layout_group}" 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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue