diff --git a/backend/template/react-ts/index.html b/backend/template/react-ts/index.html index 2548720f..8e50b0f8 100644 --- a/backend/template/react-ts/index.html +++ b/backend/template/react-ts/index.html @@ -9,6 +9,7 @@
+ diff --git a/backend/template/react-ts/package-lock.json b/backend/template/react-ts/package-lock.json index fd53297d..55c33bed 100644 --- a/backend/template/react-ts/package-lock.json +++ b/backend/template/react-ts/package-lock.json @@ -43,8 +43,10 @@ "cmdk": "^1.0.0", "date-fns": "^3.6.0", "embla-carousel-react": "^8.5.2", + "framer-motion": "^12.5.0", "input-otp": "^1.4.2", "lucide-react": "^0.445.0", + "magic-string": "^0.30.17", "next-themes": "^0.4.4", "react": "^18.3.1", "react-day-picker": "^8.10.1", @@ -1010,8 +1012,7 @@ "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -4501,6 +4502,33 @@ "node": ">= 6" } }, + "node_modules/framer-motion": { + "version": "12.6.5", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.6.5.tgz", + "integrity": "sha512-MKvnWov0paNjvRJuIy6x418w23tFqRfS6CXHhZrCiSEpXVlo/F+usr8v4/3G6O0u7CpsaO1qop+v4Ip7PRCBqQ==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.6.5", + "motion-utils": "^12.6.5", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -5112,6 +5140,15 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5173,6 +5210,21 @@ "node": "*" } }, + "node_modules/motion-dom": { + "version": "12.6.5", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.6.5.tgz", + "integrity": "sha512-jpM9TQLXzYMWMJ7Ec7sAj0iis8oIuu6WvjI3yNKJLdrZyrsI/b2cRInDVL8dCl683zQQq19DpL9cSMP+k8T1NA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.6.5" + } + }, + "node_modules/motion-utils": { + "version": "12.6.5", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.6.5.tgz", + "integrity": "sha512-IsOeKsOF+FWBhxQEDFBO6ZYC8/jlidmVbbLpe9/lXSA9j9kzGIMUuIBx2SZY+0reAS0DjZZ1i7dJp4NHrjocPw==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/backend/template/react-ts/package.json b/backend/template/react-ts/package.json index 60b4587e..b0dcd56c 100644 --- a/backend/template/react-ts/package.json +++ b/backend/template/react-ts/package.json @@ -48,6 +48,7 @@ "framer-motion": "^12.5.0", "input-otp": "^1.4.2", "lucide-react": "^0.445.0", + "magic-string": "^0.30.17", "next-themes": "^0.4.4", "react": "^18.3.1", "react-day-picker": "^8.10.1", diff --git a/backend/template/react-ts/public/customInspector.js b/backend/template/react-ts/public/customInspector.js new file mode 100644 index 00000000..2bcfa105 --- /dev/null +++ b/backend/template/react-ts/public/customInspector.js @@ -0,0 +1,697 @@ +// customInspector.js - Add this to your template's public folder +// or serve it from your own CDN + +(function () { + // Configuration + const CONFIG = { + HIGHLIGHT_COLOR: '#60a5fa', + HIGHLIGHT_BG: '#60a5fa1a', + // Replace with your development domains + ALLOWED_ORIGINS: [ + 'http://localhost:3000', + 'http://localhost:5173', + 'https://codefox.net', + ], + Z_INDEX: 10000, + SELECTED_ATTR: 'data-custom-selected', + HOVERED_ATTR: 'data-custom-hovered', + }; + + // State + let state = { + hoveredElement: null, + isActive: false, + tooltip: null, + selectedElements: new Map(), // Map of id -> element + }; + + // Only initialize in iframe context + if (window.top === window.self) { + return; + } + + // Utility: Send message to parent window + function sendToParent(message) { + CONFIG.ALLOWED_ORIGINS.forEach((origin) => { + try { + if (window.parent) { + console.log(`Sending message to parent`); + window.parent.postMessage(message, origin); + } else { + console.error('Cannot access window.parent'); + } + } catch (error) { + console.error(`Failed to send message to ${origin}:`, error); + } + }); + } + + // Utility: Check if element has component data + function hasComponentData(element) { + return ( + element.hasAttribute('data-custom-id') || + element.hasAttribute('data-custom-path') + ); + } + + // Utility: Extract component data + function extractComponentData(element) { + const id = element.getAttribute('data-custom-id') || ''; + const [filePath, lineNumber, col] = id.split(':'); + + // Parse the content attribute if it exists + let contentData = {}; + try { + const contentAttr = element.getAttribute('data-custom-content'); + if (contentAttr) { + contentData = JSON.parse(decodeURIComponent(contentAttr)); + } + } catch (e) { + console.error('Error parsing data-custom-content', e); + contentData = {}; + } + + // Get class data either from the class attribute or from parsed content + const classNames = + element.getAttribute('class') || contentData.className || ''; + + return { + id, + name: element.getAttribute('data-custom-name') || '', + path: element.getAttribute('data-custom-path') || filePath || '', + line: element.getAttribute('data-custom-line') || lineNumber || '', + column: col || '0', + file: element.getAttribute('data-custom-file') || '', + content: contentData, + className: classNames, + attributes: { + class: classNames, + }, + // Get only direct text content, excluding children's text + textContent: Array.from(element.childNodes) + .filter((node) => node.nodeType === Node.TEXT_NODE) + .map((node) => node.nodeValue.trim()) + .join(''), + }; + } + + // Create styles for highlighting and tooltip + function createStyles() { + const style = document.createElement('style'); + style.textContent = ` + [${CONFIG.HOVERED_ATTR}] { + position: relative; + } + + [${CONFIG.HOVERED_ATTR}]::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + outline: 2px dashed ${CONFIG.HIGHLIGHT_COLOR} !important; + background-color: ${CONFIG.HIGHLIGHT_BG} !important; + z-index: ${CONFIG.Z_INDEX}; + pointer-events: none; + } + + [${CONFIG.SELECTED_ATTR}] { + position: relative; + } + + [${CONFIG.SELECTED_ATTR}]::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + outline: 2px solid ${CONFIG.HIGHLIGHT_COLOR} !important; + outline-offset: 2px !important; + z-index: ${CONFIG.Z_INDEX}; + pointer-events: none; + } + + .custom-inspector-tooltip { + position: fixed; + z-index: ${CONFIG.Z_INDEX + 1}; + pointer-events: none; + background-color: ${CONFIG.HIGHLIGHT_COLOR}; + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: bold; + white-space: nowrap; + display: none; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); + } + `; + document.head.appendChild(style); + + // Create tooltip element + const tooltip = document.createElement('div'); + tooltip.className = 'custom-inspector-tooltip'; + document.body.appendChild(tooltip); + state.tooltip = tooltip; + } + + // Highlight element on hover + function highlightElement(element) { + element.setAttribute(CONFIG.HOVERED_ATTR, 'true'); + + if (state.tooltip) { + const rect = element.getBoundingClientRect(); + state.tooltip.textContent = + element.getAttribute('data-custom-name') || + element.tagName.toLowerCase(); + state.tooltip.style.display = 'block'; + state.tooltip.style.left = `${Math.max(0, rect.left)}px`; + state.tooltip.style.top = `${Math.max(0, rect.top - 25)}px`; + } + } + + // Remove highlight + function unhighlightElement(element) { + element.removeAttribute(CONFIG.HOVERED_ATTR); + + if (state.tooltip) { + state.tooltip.style.display = 'none'; + } + } + + // Debounce function + function debounce(fn, delay) { + let timeout; + return function (...args) { + clearTimeout(timeout); + timeout = setTimeout(() => fn(...args), delay); + }; + } + + // Handle mouseover event + const handleMouseOver = debounce((event) => { + if (!state.isActive) return; + + // Find closest element with component data + let element = event.target; + while (element && !hasComponentData(element)) { + element = element.parentElement; + } + + if (!element) return; + + // Unhighlight previous element + if (state.hoveredElement && state.hoveredElement !== element) { + unhighlightElement(state.hoveredElement); + } + + // Highlight current element + state.hoveredElement = element; + highlightElement(element); + }, 10); + + // Handle mouseout event + const handleMouseOut = debounce(() => { + if (!state.isActive || !state.hoveredElement) return; + + unhighlightElement(state.hoveredElement); + state.hoveredElement = null; + }, 10); + + // Handle click event + function handleClick(event) { + if (!state.isActive) return; + + // Find closest element with component data + let element = event.target; + while (element && !hasComponentData(element)) { + element = element.parentElement; + } + + if (!element) return; + + // Prevent default behavior + event.preventDefault(); + event.stopPropagation(); + + // Extract component data + const componentData = extractComponentData(element); + + // Add selection + element.setAttribute(CONFIG.SELECTED_ATTR, 'true'); + state.selectedElements.set(componentData.id, element); + + // Send to parent + sendToParent({ + type: 'ELEMENT_CLICKED', + payload: componentData, + isMultiSelect: event.metaKey || event.ctrlKey, + }); + } + + // Get computed styles for an element + function getComputedStyles(element) { + const computedStyle = window.getComputedStyle(element); + const styles = {}; + + // Common style properties to extract + const properties = [ + // Colors + 'color', + 'backgroundColor', + 'borderColor', + // Text + 'fontSize', + 'fontWeight', + 'fontFamily', + 'textAlign', + 'lineHeight', + // Spacing + 'margin', + 'marginTop', + 'marginRight', + 'marginBottom', + 'marginLeft', + 'padding', + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + // Layout + 'display', + 'flexDirection', + 'justifyContent', + 'alignItems', + 'gap', + // Size + 'width', + 'height', + 'maxWidth', + 'maxHeight', + // Border + 'borderWidth', + 'borderStyle', + 'borderRadius', + // Other + 'opacity', + 'boxShadow', + 'transform', + 'transition', + ]; + + properties.forEach((prop) => { + styles[prop] = computedStyle.getPropertyValue(prop); + }); + + // Make sure backgroundColor is not empty (could be transparent or rgba(0,0,0,0)) + if ( + !styles.backgroundColor || + styles.backgroundColor === 'transparent' || + styles.backgroundColor === 'rgba(0, 0, 0, 0)' + ) { + // Try to detect if the element has a background color class + const classes = element.getAttribute('class') || ''; + if (classes.includes('bg-')) { + // Extract the actual rendered color using a temp element trick + const tempDiv = document.createElement('div'); + tempDiv.style.position = 'absolute'; + tempDiv.style.visibility = 'hidden'; + tempDiv.className = classes + .split(' ') + .filter((cls) => cls.startsWith('bg-')) + .join(' '); + document.body.appendChild(tempDiv); + + // Get the computed background color + const bgColor = window.getComputedStyle(tempDiv).backgroundColor; + if ( + bgColor && + bgColor !== 'transparent' && + bgColor !== 'rgba(0, 0, 0, 0)' + ) { + styles.backgroundColor = bgColor; + } + + document.body.removeChild(tempDiv); + } + } + + return styles; + } + + // Update element style + function updateElementStyle(elementId, styles) { + // Find the element by ID + let element = null; + + try { + if (state.selectedElements.has(elementId)) { + element = state.selectedElements.get(elementId); + } else { + // Find by data-custom-id + element = document.querySelector(`[data-custom-id="${elementId}"]`); + + // If not found, try to parse the ID and find by path and line + if (!element && elementId.includes(':')) { + const [path, line] = elementId.split(':'); + element = document.querySelector( + `[data-custom-path="${path}"][data-custom-line="${line}"]`, + ); + } + } + + if (!element) { + sendToParent({ + type: 'ELEMENT_STYLE_UPDATED', + payload: { + elementId: elementId, + success: false, + error: `Failed to find element with ID: ${elementId}`, + }, + }); + return false; + } + + // Apply styles + Object.entries(styles).forEach(([property, value]) => { + // Special handling for 'class' property + if (property === 'class' || property === 'className') { + element.setAttribute('class', value); + } else { + element.style[property] = value; + } + }); + + // Return the updated element data + const updatedData = extractComponentData(element); + + console.log('Style update successful, sending response'); + sendToParent({ + type: 'ELEMENT_STYLE_UPDATED', + payload: { + elementId: elementId, + success: true, + elementData: updatedData, + appliedStyles: styles, + }, + }); + + return true; + } catch (error) { + console.error('Error applying styles:', error); + sendToParent({ + type: 'ELEMENT_STYLE_UPDATED', + payload: { + elementId: elementId, + success: false, + error: error.message || 'Unknown error applying styles', + }, + }); + return false; + } + } + + // Update element content + function updateElementContent(elementId, content) { + console.log( + `Received content update request for element: ${elementId}`, + content, + ); + + // Find the element + let element = null; + + try { + if (state.selectedElements.has(elementId)) { + element = state.selectedElements.get(elementId); + console.log('Found element in selectedElements map'); + } else { + // Find by data-custom-id + element = document.querySelector(`[data-custom-id="${elementId}"]`); + console.log('Element search by data-custom-id:', !!element); + + // If not found, try to parse the ID and find by path and line + if (!element && elementId.includes(':')) { + const [path, line] = elementId.split(':'); + element = document.querySelector( + `[data-custom-path="${path}"][data-custom-line="${line}"]`, + ); + console.log('Element search by path and line:', !!element); + } + } + + if (!element) { + console.error(`No element found with ID: ${elementId}`); + sendToParent({ + type: 'ELEMENT_CONTENT_UPDATED', + payload: { + elementId: elementId, + success: false, + error: `Failed to find element with ID: ${elementId}`, + }, + }); + return false; + } + + // Update content + console.log('Applying content to element:', element); + element.innerHTML = content; + + // Return the updated element data + const updatedData = extractComponentData(element); + + console.log('Content update successful, sending response'); + sendToParent({ + type: 'ELEMENT_CONTENT_UPDATED', + payload: { + elementId: elementId, + success: true, + elementData: updatedData, + appliedContent: content, + }, + }); + + return true; + } catch (error) { + console.error('Error updating content:', error); + sendToParent({ + type: 'ELEMENT_CONTENT_UPDATED', + payload: { + elementId: elementId, + success: false, + error: error.message || 'Unknown error updating content', + }, + }); + return false; + } + } + + // Handle messages from parent + function handleMessage(event) { + if (!CONFIG.ALLOWED_ORIGINS.includes(event.origin)) return; + if (!event.data || typeof event.data !== 'object') { + console.warn('Received invalid message structure:', event.data); + return; + } + + if (!event.data.type) { + console.warn('Received message with no type:', event.data); + return; + } + + console.log('Received message:', event.data.type, event.data); + + switch (event.data.type) { + case 'TOGGLE_INSPECTOR': + state.isActive = event.data.enabled; + + if (state.isActive) { + // Enable inspector + document.addEventListener('mouseover', handleMouseOver); + document.addEventListener('mouseout', handleMouseOut); + document.addEventListener('click', handleClick, true); + document.body.style.cursor = 'pointer'; + } else { + // Disable inspector + document.removeEventListener('mouseover', handleMouseOver); + document.removeEventListener('mouseout', handleMouseOut); + document.removeEventListener('click', handleClick, true); + document.body.style.cursor = ''; + + // Clear any highlighting + if (state.hoveredElement) { + unhighlightElement(state.hoveredElement); + state.hoveredElement = null; + } + + // Clear any selections + document + .querySelectorAll(`[${CONFIG.SELECTED_ATTR}]`) + .forEach((el) => { + el.removeAttribute(CONFIG.SELECTED_ATTR); + }); + + // Clear selection map + state.selectedElements.clear(); + } + break; + + case 'GET_COMPONENT_TREE': + // Send component tree to parent + const root = document.querySelector('#root'); + if (root) { + const componentTree = buildComponentTree(root); + sendToParent({ + type: 'COMPONENT_TREE', + payload: componentTree, + }); + } + break; + + case 'GET_ELEMENT_STYLES': + // Get computed styles for an element + const elementId = event.data.payload.elementId; + let element = null; + + if (state.selectedElements.has(elementId)) { + element = state.selectedElements.get(elementId); + } else { + // Find by data-custom-id + element = document.querySelector(`[data-custom-id="${elementId}"]`); + + // If not found, try to parse the ID and find by path and line + if (!element && elementId.includes(':')) { + const [path, line] = elementId.split(':'); + element = document.querySelector( + `[data-custom-path="${path}"][data-custom-line="${line}"]`, + ); + } + } + + if (element) { + const styles = getComputedStyles(element); + sendToParent({ + type: 'ELEMENT_STYLES', + payload: { + elementId, + styles, + success: true, + }, + }); + } else { + sendToParent({ + type: 'ELEMENT_STYLES', + payload: { + elementId, + success: false, + error: 'Element not found', + }, + }); + } + break; + + case 'UPDATE_ELEMENT_STYLE': + // Update element style + console.log('Processing style update:', event.data.payload); + if ( + !event.data.payload || + !event.data.payload.elementId || + !event.data.payload.styles + ) { + console.error('Invalid style update payload:', event.data.payload); + sendToParent({ + type: 'ELEMENT_STYLE_UPDATED', + payload: { + success: false, + error: 'Invalid style update request', + }, + }); + return; + } + updateElementStyle( + event.data.payload.elementId, + event.data.payload.styles, + ); + break; + + case 'UPDATE_ELEMENT_CONTENT': + // Update element content + console.log('Processing content update:', event.data.payload); + if ( + !event.data.payload || + !event.data.payload.elementId || + event.data.payload.content === undefined + ) { + console.error('Invalid content update payload:', event.data.payload); + sendToParent({ + type: 'ELEMENT_CONTENT_UPDATED', + payload: { + success: false, + error: 'Invalid content update request', + }, + }); + return; + } + updateElementContent( + event.data.payload.elementId, + event.data.payload.content, + ); + break; + } + } + + // Build component tree (simplified) + function buildComponentTree(element) { + if (!element) return null; + + const result = { + type: 'element', + tagName: element.tagName.toLowerCase(), + children: [], + }; + + if (hasComponentData(element)) { + result.component = extractComponentData(element); + } + + // Process children + Array.from(element.children).forEach((child) => { + const childTree = buildComponentTree(child); + if (childTree) { + result.children.push(childTree); + } + }); + + return result; + } + + // Initialize + function initialize() { + console.log('[Custom Inspector] Initializing...'); + + // Create styles + createStyles(); + + // Listen for messages from parent + window.addEventListener('message', handleMessage); + + // Let parent know we're ready + sendToParent({ + type: 'INSPECTOR_READY', + payload: { + url: window.location.href, + }, + }); + + console.log('[Custom Inspector] Ready'); + } + + // Start when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initialize); + } else { + initialize(); + } +})(); diff --git a/backend/template/react-ts/src/plugins/customComponentTagger.ts b/backend/template/react-ts/src/plugins/customComponentTagger.ts new file mode 100644 index 00000000..67e5cd29 --- /dev/null +++ b/backend/template/react-ts/src/plugins/customComponentTagger.ts @@ -0,0 +1,222 @@ +// src/plugins/customComponentTagger.ts +import { parse, ParserOptions } from '@babel/parser'; +import MagicString from 'magic-string'; +import path from 'path'; +import type { Node as BabelNode } from '@babel/types'; + +// Define a simple Plugin type if Vite types are not available +interface Plugin { + name: string; + enforce?: 'pre' | 'post'; + transform?: (code: string, id: string) => Promise | any; + closeBundle?: () => void; + [key: string]: any; +} + +// Define interface for our content object +interface ContentObject { + text?: string; + placeholder?: string; + className?: string; + [key: string]: string | undefined; +} + +// Define interface for attributes +interface AttributesMap { + [key: string]: string; + placeholder?: string; + className?: string; +} + +const validExtensions = new Set(['.jsx', '.tsx']); + +export function customComponentTagger(): Plugin { + const cwd = process.cwd(); + const stats = { + totalFiles: 0, + processedFiles: 0, + totalElements: 0, + }; + + return { + name: 'vite-plugin-custom-component-tagger', + enforce: 'pre', + + async transform(code, id) { + // Only process JSX/TSX files, skip node_modules + if ( + !validExtensions.has(path.extname(id)) || + id.includes('node_modules') + ) { + return null; + } + + stats.totalFiles++; + const relativePath = path.relative(cwd, id); + + try { + // Parse the code into an AST + const parserOptions: ParserOptions = { + sourceType: 'module', + plugins: ['jsx', 'typescript'] as any, + }; + + const ast = parse(code, parserOptions); + const magicString = new MagicString(code); + let changedElementsCount = 0; + let currentElement: any = null; + + // Process the AST manually instead of using estree-walker + const processAST = (node: BabelNode) => { + if (node.type === 'Program') { + // Process all program body elements + for (const child of (node as any).body) { + processAST(child); + } + } + + // Process JSX elements recursively + if (node.type === 'JSXElement') { + currentElement = node; + + // Process JSX opening element + const jsxOpeningElement = (node as any).openingElement; + if (jsxOpeningElement) { + processJSXOpeningElement(jsxOpeningElement); + } + + // Process children recursively + if ((node as any).children) { + for (const child of (node as any).children) { + processAST(child); + } + } + } + + // Recursively process other nodes + for (const key in node) { + if (node[key] && typeof node[key] === 'object' && key !== 'loc') { + if (Array.isArray(node[key])) { + for (const child of node[key]) { + if (child && typeof child === 'object') { + processAST(child as BabelNode); + } + } + } else if (node[key].type) { + processAST(node[key] as BabelNode); + } + } + } + }; + + const seenNodes = new Set(); + // Process JSXOpeningElement nodes + const processJSXOpeningElement = (jsxNode: any) => { + if (seenNodes.has(jsxNode.start)) return; + seenNodes.add(jsxNode.start); + // Get the element name + let elementName; + if (jsxNode.name.type === 'JSXIdentifier') { + elementName = jsxNode.name.name; + } else if (jsxNode.name.type === 'JSXMemberExpression') { + const memberExpr = jsxNode.name; + elementName = `${memberExpr.object.name}.${memberExpr.property.name}`; + } else { + return; + } + + // Skip React fragments + if (elementName === 'Fragment' || elementName === 'React.Fragment') { + return; + } + + // Extract attributes + const attributes: AttributesMap = jsxNode.attributes.reduce( + (acc: AttributesMap, attr: any) => { + if (attr.type === 'JSXAttribute') { + if (attr.value?.type === 'StringLiteral') { + acc[attr.name.name] = attr.value.value; + } else if ( + attr.value?.type === 'JSXExpressionContainer' && + attr.value.expression.type === 'StringLiteral' + ) { + acc[attr.name.name] = attr.value.expression.value; + } + } + return acc; + }, + {}, + ); + + // Extract text content + let textContent = ''; + if (currentElement && (currentElement as any).children) { + textContent = (currentElement as any).children + .map((child: any) => { + if (child.type === 'JSXText') { + return child.value.trim(); + } else if (child.type === 'JSXExpressionContainer') { + if (child.expression.type === 'StringLiteral') { + return child.expression.value; + } + } + return ''; + }) + .filter(Boolean) + .join(' ') + .trim(); + } + + // Build content object + const content: ContentObject = {}; + if (textContent) { + content.text = textContent; + } + if (attributes.placeholder) { + content.placeholder = attributes.placeholder; + } + if (attributes.className) { + content.className = attributes.className; + } + + // Create component ID + const line = jsxNode.loc?.start?.line ?? 0; + const col = jsxNode.loc?.start?.column ?? 0; + const dataComponentId = `${relativePath}:${line}:${col}`; + const fileName = path.basename(id); + + // Add data attributes + const dataAttrs = ` data-custom-id="${dataComponentId}" data-custom-name="${elementName}" data-custom-path="${relativePath}" data-custom-line="${line}" data-custom-file="${fileName}" data-custom-content="${encodeURIComponent( + JSON.stringify(content), + )}"`; + + magicString.appendLeft(jsxNode.name.end ?? 0, dataAttrs); + changedElementsCount++; + }; + + // Process the AST + processAST(ast.program); + + stats.processedFiles++; + stats.totalElements += changedElementsCount; + + return { + code: magicString.toString(), + map: magicString.generateMap({ hires: true }), + }; + } catch (error) { + console.error(`Error processing file ${relativePath}:`, error); + stats.processedFiles++; + return null; + } + }, + + // Optional: Add a way to see statistics + closeBundle() { + console.log('\nComponent Tagger Statistics:'); + console.log(`Total files scanned: ${stats.totalFiles}`); + console.log(`Files processed: ${stats.processedFiles}`); + console.log(`Elements tagged: ${stats.totalElements}\n`); + }, + }; +} diff --git a/backend/template/react-ts/vite.config.ts b/backend/template/react-ts/vite.config.ts index 057d5ddb..c6eeab03 100644 --- a/backend/template/react-ts/vite.config.ts +++ b/backend/template/react-ts/vite.config.ts @@ -2,9 +2,10 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; import path from 'path'; +import { customComponentTagger } from './src/plugins/customComponentTagger'; -export default defineConfig({ - plugins: [react(), tailwindcss()], +export default defineConfig(({ mode }) => ({ + plugins: [react(), tailwindcss(), customComponentTagger()].filter(Boolean), resolve: { alias: { '@': path.resolve(__dirname, './src'), @@ -32,4 +33,4 @@ export default defineConfig({ }, allowedHosts: true, }, -}); +})); diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index ee27ef53..99356410 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -5,6 +5,7 @@ import './globals.css'; import { BaseProviders } from '@/providers/BaseProvider'; import NavLayout from '@/components/root/nav-layout'; import RootLayout from '@/components/root/root-layout'; +import { Toaster } from 'sonner'; const inter = Inter({ subsets: ['latin'], variable: '--font-inter' }); const playfair = Playfair_Display({ @@ -30,7 +31,10 @@ export default function Layout({ children }: { children: React.ReactNode }) {
- {children} + + {children} + +
diff --git a/frontend/src/components/chat/chat-bottombar.tsx b/frontend/src/components/chat/chat-bottombar.tsx index 213876e5..283b9066 100644 --- a/frontend/src/components/chat/chat-bottombar.tsx +++ b/frontend/src/components/chat/chat-bottombar.tsx @@ -2,9 +2,9 @@ import React, { useEffect, useRef, useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import TextareaAutosize from 'react-textarea-autosize'; -import { PaperclipIcon, Send, X } from 'lucide-react'; +import { PaperclipIcon, Send, X, Code, Wand2 } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { Message } from '../../const/MessageType'; +import { Message, ChatRequestOptions } from '../../const/MessageType'; import Image from 'next/image'; import { Tooltip, @@ -12,19 +12,40 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; +import { ComponentInspector } from './code-engine/component-inspector'; +import { Badge } from '@/components/ui/badge'; interface ChatBottombarProps { messages: Message[]; input: string; handleInputChange: (e: React.ChangeEvent) => void; - handleSubmit: (e: React.FormEvent) => void; + handleSubmit: ( + e: React.FormEvent, + chatRequestOptions?: ChatRequestOptions + ) => void; stop: () => void; formRef: React.RefObject; setInput?: React.Dispatch>; setMessages: (messages: Message[]) => void; setSelectedModel: React.Dispatch>; + isInspectMode?: boolean; + setIsInspectMode?: React.Dispatch>; } +// Add subtle pulse animation for Component Mode badge +const pulseBadge = { + initial: { scale: 1 }, + animate: { + scale: [1, 1.03, 1], + transition: { + repeat: Infinity, + repeatType: "mirror" as const, + duration: 2, + ease: "easeInOut" + } + } +}; + export default function ChatBottombar({ messages, input, @@ -34,10 +55,14 @@ export default function ChatBottombar({ setInput, setMessages, setSelectedModel, + isInspectMode, + setIsInspectMode, }: ChatBottombarProps) { const [isMobile, setIsMobile] = useState(false); const [isFocused, setIsFocused] = useState(false); const [attachments, setAttachments] = useState([]); + const [isComponentMode, setIsComponentMode] = useState(false); + const [componentData, setComponentData] = useState(''); const inputRef = useRef(null); const fileInputRef = useRef(null); @@ -77,9 +102,66 @@ export default function ChatBottombar({ setAttachments((prev) => prev.filter((_, i) => i !== index)); }; + const populateChatInput = (content: string) => { + if (setInput) { + // Store the component data in state instead of showing it in the input + setComponentData(content); + // Keep input clean - don't prefill any text + setInput(""); + setIsComponentMode(true); + // Focus the input after populating + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + } + }; + + // Check if we're still in component mode + useEffect(() => { + if (!input || input === "") { + setIsComponentMode(false); + setComponentData(''); + } + }, [input]); + + // Function to exit component mode + const exitComponentMode = () => { + if (setInput) { + setInput(''); + setIsComponentMode(false); + setComponentData(''); + } + }; + + // Modify submit to include component data if in component mode const submitWithAttachments = (e: React.FormEvent) => { - // Here you would normally handle attachments with your form submission - // For this example, we'll just clear them after submission + e.preventDefault(); + + // If in component mode, append the hidden component data + if (isComponentMode && componentData && setInput) { + // Get user input + const userInput = input || ""; + + // Create the full prompt with component data first, then user input + const fullPrompt = componentData + "\n\n" + userInput; + + // Call handleSubmit with the content in ChatRequestOptions + handleSubmit(e, { content: fullPrompt }); + + // Reset component mode + setIsComponentMode(false); + setComponentData(''); + setAttachments([]); + + // Clean up input after submission + setTimeout(() => { + setInput(""); + }, 50); + + return; + } + + // Regular submission flow handleSubmit(e); setAttachments([]); }; @@ -91,7 +173,133 @@ export default function ChatBottombar({ }, []); return ( -
+
+ {/* Component Inspector Popup */} + + {isInspectMode && ( + + {/* Resizable handle - positioned at the top of the panel */} +
{ + e.preventDefault(); + + // Store references to elements and initial values + const panel = e.currentTarget.parentElement; + if (!panel) return; + + const startY = e.clientY; + const startHeight = panel.getBoundingClientRect().height; + + const handleMouseMove = (moveEvent: MouseEvent) => { + // Stop propagation to prevent other events + moveEvent.preventDefault(); + moveEvent.stopPropagation(); + + // Calculate new height + const delta = startY - moveEvent.clientY; + const newHeight = Math.min( + Math.max(startHeight + delta, 100), // Min height reduced to 100px + window.innerHeight - 150 // Max height + ); + + // Apply new height to the stored panel reference + if (panel) { + panel.style.height = `${newHeight}px`; + } + }; + + const handleMouseUp = () => { + // Clean up all event listeners + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + // Add the event listeners to document + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }} + /> + +
+
+ +

UI Inspector

+
+
+ + Edit UI components directly + + +
+
+
+ +
+ + )} + + + {/* Component Mode Badge - outside and above the input box */} + + {isComponentMode && ( + + + + + Component Selected + + + + + )} + + + {/* Component mode indicator */} + {isComponentMode && ( +
+ )} + {/* Attachments preview */} {attachments.length > 0 && ( @@ -183,40 +398,52 @@ export default function ChatBottombar({ +
+ {/* Add Edit UI button */} + {setIsInspectMode && ( -

Feature not available yet

+

{isInspectMode ? 'Disable' : 'Enable'} UI Edit Mode

-
+ )} {/* Text input */}
+ + {isComponentMode && ( + + )} + setIsFocused(true)} onBlur={() => setIsFocused(false)} name="message" - placeholder="Message Agent..." - className="resize-none px-2 py-2.5 w-full focus:outline-none bg-transparent text-gray-800 dark:text-zinc-200 text-sm placeholder:text-gray-400 dark:placeholder:text-zinc-400" + placeholder={isComponentMode ? "Message Agent... (component details will be included)" : "Message Agent..."} + className="resize-none px-2 w-full focus:outline-none bg-transparent text-gray-800 dark:text-zinc-200 text-sm placeholder:text-gray-400 dark:placeholder:text-zinc-400 py-2.5" maxRows={5} />
diff --git a/frontend/src/components/chat/chat-panel.tsx b/frontend/src/components/chat/chat-panel.tsx index 47797c6d..81a4a82e 100644 --- a/frontend/src/components/chat/chat-panel.tsx +++ b/frontend/src/components/chat/chat-panel.tsx @@ -16,7 +16,7 @@ export interface ChatProps { handleInputChange: (e: React.ChangeEvent) => void; handleSubmit: ( e: React.FormEvent, - chatRequestOptions?: ChatRequestOptions + chatRequestOptions?: ChatRequestOptions, ) => void; loadingSubmit?: boolean; stop: () => void; @@ -26,6 +26,8 @@ export interface ChatProps { setMessages: (messages: Message[]) => void; setThinkingProcess: React.Dispatch>; isTPUpdating: boolean; + isInspectMode?: boolean; + setIsInspectMode?: React.Dispatch>; } export default function ChatContent({ @@ -44,6 +46,8 @@ export default function ChatContent({ thinkingProcess, setThinkingProcess, isTPUpdating, + isInspectMode, + setIsInspectMode, }: ChatProps) { return (
diff --git a/frontend/src/components/chat/code-engine/code-engine.tsx b/frontend/src/components/chat/code-engine/code-engine.tsx index 71de736d..49237ce6 100644 --- a/frontend/src/components/chat/code-engine/code-engine.tsx +++ b/frontend/src/components/chat/code-engine/code-engine.tsx @@ -15,10 +15,14 @@ export function CodeEngine({ chatId, isProjectReady = false, projectId, + isInspectMode, + setIsInspectMode, }: { chatId: string; isProjectReady?: boolean; projectId?: string; + isInspectMode?: boolean; + setIsInspectMode?: React.Dispatch>; }) { const { curProject, projectLoading, pollChatProject, editorRef } = useContext(ProjectContext); @@ -258,25 +262,28 @@ export function CodeEngine({ }; const renderTabContent = () => { - switch (activeTab) { - case 'code': - return ( - - ); - case 'preview': - return ; - case 'console': - return ; - default: - return null; + if (activeTab === 'preview') { + return ( + + ); + } else if (activeTab === 'console') { + return ; + } else { + // Default to code tab + return ( + + ); } }; diff --git a/frontend/src/components/chat/code-engine/component-inspector/components/ColorPicker.tsx b/frontend/src/components/chat/code-engine/component-inspector/components/ColorPicker.tsx new file mode 100644 index 00000000..5ddb1027 --- /dev/null +++ b/frontend/src/components/chat/code-engine/component-inspector/components/ColorPicker.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { ColorPickerProps } from '../types'; +import { getColorValue } from '../utils/color-utils'; + +/** + * Component for selecting and displaying color values + */ +export const ColorPicker: React.FC = ({ + style, + label, + color, + onChange +}) => { + // Get a clean color value + const displayColor = getColorValue(color); + + return ( +
+ +
+ onChange(style, e.target.value)} + className="w-10 h-10 p-1 rounded-full overflow-hidden" + /> + onChange(style, e.target.value)} + className="flex-1 h-8 text-xs" + placeholder={`Enter ${label.toLowerCase()}`} + /> +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/chat/code-engine/component-inspector/components/SpacingControls.tsx b/frontend/src/components/chat/code-engine/component-inspector/components/SpacingControls.tsx new file mode 100644 index 00000000..f25a95ea --- /dev/null +++ b/frontend/src/components/chat/code-engine/component-inspector/components/SpacingControls.tsx @@ -0,0 +1,124 @@ +import React, { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { handleMouseDrag } from '../utils/drag-utils'; +import { SpacingControlsProps } from '../types'; +import { addPxUnitIfNeeded } from '../utils/style-utils'; + +/** + * Component for adjusting spacing (padding/margin) values + */ +export const SpacingControls: React.FC = ({ + property, + label, + pairedProperty, + displayValue, + onValueChange +}) => { + return ( +
+
+ +
+ + +
+
+
+
+ { + onValueChange(property, e.target.value, !!pairedProperty, pairedProperty); + }} + className="h-7 text-xs flex-1 cursor-ew-resize" + onMouseDown={(e) => { + // Only use drag on the input (not on buttons or other elements) + if (e.currentTarget === e.target) { + handleMouseDrag( + property, + parseInt(displayValue || "0"), + e, + onValueChange, + pairedProperty + ); + } + }} + /> +
+ px +
+
+ {pairedProperty && ( + + )} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/chat/code-engine/component-inspector/components/TypographyControls.tsx b/frontend/src/components/chat/code-engine/component-inspector/components/TypographyControls.tsx new file mode 100644 index 00000000..27670763 --- /dev/null +++ b/frontend/src/components/chat/code-engine/component-inspector/components/TypographyControls.tsx @@ -0,0 +1,184 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { TypographyControlsProps } from '../types'; + +/** + * Component for typography controls + */ +export const TypographyControls: React.FC = ({ + customStyles, + computedStyles, + onChange +}) => { + return ( +
+

+
+ + Typography +
+

+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+ +
+ {['left', 'center', 'right', 'justify'].map(align => ( + + ))} +
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/chat/code-engine/component-inspector/hooks/useMessageHandler.tsx b/frontend/src/components/chat/code-engine/component-inspector/hooks/useMessageHandler.tsx new file mode 100644 index 00000000..b589aaa1 --- /dev/null +++ b/frontend/src/components/chat/code-engine/component-inspector/hooks/useMessageHandler.tsx @@ -0,0 +1,148 @@ +import { useEffect } from 'react'; +import { + ComponentData, + ComputedStyles, + CustomStyles, + SpacingInputs +} from '../types'; +import { initializeSpacingInputs } from '../utils/style-utils'; + +/** + * Custom hook for handling iframe message communication + */ +interface UseMessageHandlerProps { + setSelectedComponent: React.Dispatch>; + setComputedStyles: React.Dispatch>; + setCustomStyles: React.Dispatch>; + setSpacingInputs: React.Dispatch>; + setIsContentEdited: React.Dispatch>; + setIsStyleEdited: React.Dispatch>; + setEditableContent: React.Dispatch>; + setOriginalContent: React.Dispatch>; + setApplyingChanges: React.Dispatch>; +} + +export const useMessageHandler = ({ + setSelectedComponent, + setComputedStyles, + setCustomStyles, + setSpacingInputs, + setIsContentEdited, + setIsStyleEdited, + setEditableContent, + setOriginalContent, + setApplyingChanges +}: UseMessageHandlerProps) => { + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (!event.data || !event.data.type) { + console.warn("Received message with missing type:", event.data); + return; + } + + console.log("ComponentInspector received message:", event.data.type); + + // Handle component click data forwarded from iframe-click-handler + if (event.data.type === 'COMPONENT_CLICK') { + console.log("Processing component click:", event.data.componentData); + setSelectedComponent(event.data.componentData); + setComputedStyles(null); // Reset computed styles + setCustomStyles({}); // Reset custom styles + setIsContentEdited(false); + setIsStyleEdited(false); + + // Reset spacing inputs + setSpacingInputs({}); + + // Get latest content and store it as the original content too + if (event.data.componentData.content.text) { + setEditableContent(event.data.componentData.content.text); + setOriginalContent(event.data.componentData.content.text); + } else { + setEditableContent(''); + setOriginalContent(''); + } + } + // Handle styles data received from iframe + else if (event.data.type === 'ELEMENT_STYLES') { + console.log("Processing element styles response:", event.data.payload); + if (event.data.payload && event.data.payload.success) { + console.log("Received computed styles:", event.data.payload.styles); + + // Process received styles + const styles = event.data.payload.styles; + + // Log background color specifically for debugging + console.log("Background color from computed styles:", styles.backgroundColor); + + // Initialize spacing inputs from computed styles + const initialSpacingInputs = initializeSpacingInputs(styles); + setSpacingInputs(initialSpacingInputs); + setComputedStyles(styles); + } else { + console.error("Error fetching styles:", event.data.payload?.error || "Unknown error"); + } + } else if (event.data.type === 'ELEMENT_STYLE_UPDATED') { + console.log("Processing style update response:", event.data.payload); + setApplyingChanges(false); + + if (event.data.payload && event.data.payload.success) { + // Update the selected component with new data + setSelectedComponent(prev => { + if (!prev) return prev; + return { + ...prev, + ...event.data.payload.elementData, + }; + }); + + // Display confirmation + console.log('Style updated successfully', event.data.payload.appliedStyles); + setIsStyleEdited(false); + } else { + console.error("Style update failed:", event.data.payload?.error || "Unknown error"); + alert(`Failed to update style: ${event.data.payload?.error || "Unknown error"}`); + } + } else if (event.data.type === 'ELEMENT_CONTENT_UPDATED') { + console.log("Processing content update response:", event.data.payload); + setApplyingChanges(false); + + if (event.data.payload && event.data.payload.success) { + // Update the selected component with new data + setSelectedComponent(prev => { + if (!prev) return prev; + return { + ...prev, + ...event.data.payload.elementData, + content: { + ...prev.content, + text: event.data.payload.appliedContent, + }, + }; + }); + + // Display confirmation + console.log('Content updated successfully'); + setIsContentEdited(false); + } else { + console.error("Content update failed:", event.data.payload?.error || "Unknown error"); + alert(`Failed to update content: ${event.data.payload?.error || "Unknown error"}`); + } + } + }; + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [ + setSelectedComponent, + setComputedStyles, + setCustomStyles, + setSpacingInputs, + setIsContentEdited, + setIsStyleEdited, + setEditableContent, + setOriginalContent, + setApplyingChanges + ]); +}; \ No newline at end of file diff --git a/frontend/src/components/chat/code-engine/component-inspector/index.tsx b/frontend/src/components/chat/code-engine/component-inspector/index.tsx new file mode 100644 index 00000000..7bd0b00f --- /dev/null +++ b/frontend/src/components/chat/code-engine/component-inspector/index.tsx @@ -0,0 +1,470 @@ +import React, { useState, useEffect } from 'react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; +import { ContentTab } from './tabs/ContentTab'; +import { StylesTab } from './tabs/StylesTab'; +import { ClassesTab } from './tabs/ClassesTab'; +import { InfoTab } from './tabs/InfoTab'; +import { DragStyles } from './utils/drag-utils'; +import { useMessageHandler } from './hooks/useMessageHandler'; +import { getElementStyles, updateElementStyle, updateElementContent } from './utils/iframe-utils'; +import { StyleUpdateService, ComponentData as StyleComponentData } from '../style-update/index'; +import { ComponentData, ComputedStyles, CustomStyles, SpacingInputs } from './types'; +import { Code, HelpCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +interface ComponentInspectorProps { + setIsInspectMode?: React.Dispatch>; + populateChatInput?: (content: string) => void; +} + +export function ComponentInspector({ + setIsInspectMode, + populateChatInput +}: ComponentInspectorProps = {}) { + // Component selection state + const [selectedComponent, setSelectedComponent] = useState(null); + const [computedStyles, setComputedStyles] = useState(null); + const [customStyles, setCustomStyles] = useState({}); + + // Spacing inputs for controlled inputs + const [spacingInputs, setSpacingInputs] = useState({}); + + // Content editing + const [editableContent, setEditableContent] = useState(''); + const [originalContent, setOriginalContent] = useState(''); + + // UI state + const [activeTab, setActiveTab] = useState('content'); + const [applyingChanges, setApplyingChanges] = useState(false); + const [isStyleEdited, setIsStyleEdited] = useState(false); + const [isContentEdited, setIsContentEdited] = useState(false); + + // Set up message handler + useMessageHandler({ + setSelectedComponent, + setComputedStyles, + setCustomStyles, + setSpacingInputs, + setIsContentEdited, + setIsStyleEdited, + setEditableContent, + setOriginalContent, + setApplyingChanges + }); + + // Request element styles when a component is selected + useEffect(() => { + if (selectedComponent) { + getElementStyles(selectedComponent.id); + + // Reset content editing state when component changes + setIsContentEdited(false); + + } + }, [selectedComponent]); + + // Handle style property change + const handleStyleChange = (property: string, value: string) => { + const updatedStyles = { + ...customStyles, + [property]: value + }; + + setCustomStyles(updatedStyles); + setIsStyleEdited(true); + + // Apply visual change immediately + if (selectedComponent) { + updateElementStyle(selectedComponent.id, updatedStyles); + } + }; + + // Handle spacing input changes + const handleSpacingInputChange = ( + property: string, + value: string, + bothSides = false, + pairedProperty?: string + ) => { + // Add debug logging + console.log(`Updating spacing: ${property}=${value}, bothSides=${bothSides}, paired=${pairedProperty}`); + + // Update the input value immediately for responsive UI + setSpacingInputs(prev => ({ + ...prev, + [property]: value + })); + + // Add px unit if needed + const valueWithUnit = value ? (value.endsWith('px') ? value : `${value}px`) : value; + + // Create a copy of customStyles to update + let updatedStyles = { ...customStyles }; + + // Always update the current property + updatedStyles[property] = valueWithUnit; + + // If both sides flag is set and paired property provided, update that too + if (bothSides && pairedProperty) { + updatedStyles[pairedProperty] = valueWithUnit; + + // Also update the input display for paired property + setSpacingInputs(prev => ({ + ...prev, + [pairedProperty]: value + })); + } + + // Update custom styles - this should trigger isStyleEdited in the effect + console.log('Setting customStyles to:', updatedStyles); + setCustomStyles(updatedStyles); + + // Force set isStyleEdited true regardless of other checks + setIsStyleEdited(true); + + // Apply visual change immediately + if (selectedComponent) { + updateElementStyle(selectedComponent.id, updatedStyles); + } + }; + + // Apply style changes to the element + const applyStyleChanges = () => { + // Add additional debug logging + console.log('Applying style changes, selectedComponent:', selectedComponent); + + if (!selectedComponent) { + console.error('Cannot apply style changes: selectedComponent is undefined'); + setApplyingChanges(false); + return; + } + + setApplyingChanges(true); + + try { + // Use direct component properties instead of parsing from selector + const filePath = selectedComponent.path || 'src/pages/Index.tsx'; + const lineNumber = selectedComponent.line || '0'; + const fileName = selectedComponent.file || 'Index.tsx'; + + console.log('Component info:', { filePath, lineNumber, fileName }); + + // Adapt our ComponentData to match StyleUpdateService's expected format + const adaptedComponent: StyleComponentData = { + id: selectedComponent.id, + name: selectedComponent.name || 'Component', + path: filePath, + line: lineNumber, + file: fileName + }; + + console.log('Adapted component for StyleUpdateService:', adaptedComponent); + + // First apply the visual change + updateElementStyle(selectedComponent.id, customStyles); + + // Then persist to file if needed + StyleUpdateService.persistStyleChanges(adaptedComponent, customStyles) + .then(() => { + console.log('Style changes applied successfully'); + setIsStyleEdited(false); + setApplyingChanges(false); + }) + .catch(err => { + console.error('Error saving styles:', err); + setApplyingChanges(false); + }); + } catch (error) { + console.error('Error in applyStyleChanges:', error); + setApplyingChanges(false); + } + }; + + // Apply content changes to the element + const applyContentChanges = (content: string) => { + if (!selectedComponent) return; + setApplyingChanges(true); + + try { + // Use direct component properties + const filePath = selectedComponent.path || 'src/pages/Index.tsx'; + const lineNumber = selectedComponent.line || '0'; + const fileName = selectedComponent.file || 'Index.tsx'; + + console.log('Component info for content update:', { filePath, lineNumber, fileName }); + + // Adapt our ComponentData to match StyleUpdateService's expected format + const adaptedComponent: StyleComponentData = { + id: selectedComponent.id, + name: selectedComponent.name || 'Component', + path: filePath, + line: lineNumber, + file: fileName + }; + + // First apply the visual change + updateElementContent(selectedComponent.id, content); + + // Then persist to file if needed + StyleUpdateService.persistContentChanges(adaptedComponent, content) + .then(() => { + console.log('Content changes applied successfully'); + setIsContentEdited(false); + setApplyingChanges(false); + }) + .catch(err => { + console.error('Error saving content:', err); + setApplyingChanges(false); + }); + } catch (error) { + console.error('Error in applyContentChanges:', error); + setApplyingChanges(false); + } + }; + + // Save classes to a CSS file + const saveClassesToFile = () => { + if (!selectedComponent) return; + + console.log('Saving classes to file, selectedComponent:', selectedComponent); + + try { + // Set applying changes to true to show loading state + setApplyingChanges(true); + + // Use direct component properties + const filePath = selectedComponent.path || 'src/pages/Index.tsx'; + const lineNumber = selectedComponent.line || '0'; + const fileName = selectedComponent.file || 'Index.tsx'; + + console.log('Component info for class update:', { filePath, lineNumber, fileName }); + + // Adapt our ComponentData to match StyleUpdateService's expected type + const adaptedComponent: StyleComponentData = { + id: selectedComponent.id, + name: selectedComponent.name || 'Component', + path: filePath, + line: lineNumber, + file: fileName + }; + + // Get the className value from customStyles + if (!customStyles.className) { + console.error('No className found in customStyles'); + setApplyingChanges(false); + return; + } + + console.log('Saving className:', customStyles.className); + + // Create styles with className + const classStyles = { + className: customStyles.className + }; + + // Use StyleUpdateService to persist changes + StyleUpdateService.persistStyleChanges(adaptedComponent, classStyles) + .then(() => { + console.log('Successfully saved class changes to file'); + setIsStyleEdited(false); + setApplyingChanges(false); + }) + .catch(err => { + console.error('Error saving classes:', err); + setApplyingChanges(false); + }); + } catch (error) { + console.error('Error in saveClassesToFile:', error); + setApplyingChanges(false); + } + }; + + if (!selectedComponent) { + return ( +
+
+
+
+
+ +
+
+

UI Edit Mode

+

+ Click any component in the preview to edit +

+
+
+ ); + } + + // Parse selector for display + const elementPath = selectedComponent.selector || selectedComponent.id || ''; + + return ( +
+ + + {/* Component Info Header */} +
+
+
+ + {selectedComponent?.tagName ? selectedComponent.tagName.toLowerCase() : 'Component'} + + {selectedComponent?.className && ( + + {(selectedComponent.className.trim().split(/\s+/).filter(Boolean).length)} classes + + )} +
+ + {/* Ask AI Button */} + + + + + + +

Ask AI to help modify this component

+
+
+
+
+

+ {elementPath} +

+
+ + {/* Tabs */} + +
+ + Content + Layout & Colors + Classes + Info + +
+ +
+ {/* Content Tab */} + + + + + {/* Styles Tab */} + + + + + {/* Classes Tab */} + + + + + {/* Info Tab */} + + + +
+
+ + {/* Loading overlay */} + {applyingChanges && ( +
+
+

Applying changes...

+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/chat/code-engine/component-inspector/tabs/ClassesTab.tsx b/frontend/src/components/chat/code-engine/component-inspector/tabs/ClassesTab.tsx new file mode 100644 index 00000000..c74a76e3 --- /dev/null +++ b/frontend/src/components/chat/code-engine/component-inspector/tabs/ClassesTab.tsx @@ -0,0 +1,443 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { X, PlusCircle, Save, RotateCcw } from 'lucide-react'; +import { ClassesTabProps } from '../types'; +import { updateElementStyle } from '../utils/iframe-utils'; + +/** + * Classes tab - Displays and manages classes for the selected component + */ +export const ClassesTab: React.FC = ({ + selectedComponent, + isStyleEdited, + applyingChanges, + saveClassesToFile, + setCustomStyles, + setIsStyleEdited +}) => { + const [newClass, setNewClass] = useState(''); + const [activeClasses, setActiveClasses] = useState([]); + const [removedClasses, setRemovedClasses] = useState([]); + const [hasChanges, setHasChanges] = useState(false); + const [pendingSave, setPendingSave] = useState(false); + + // Track original classes for the current component + const originalClassesRef = useRef([]); + // Track the current component ID to detect component changes + const previousComponentIdRef = useRef(null); + + // Initialize the classes when a component is selected + useEffect(() => { + console.log('Component selected in ClassesTab:', selectedComponent); + + if (selectedComponent && selectedComponent.id) { + // Check if this is a different component than before + const isNewComponent = previousComponentIdRef.current !== selectedComponent.id; + + if (isNewComponent) { + console.log('New component selected, resetting state'); + // Get classes from various possible sources + let classString = ''; + + // 1. Try to get from className property directly + if (selectedComponent.className) { + classString = selectedComponent.className; + } + // 2. Try to get from the data-custom-content which is parsed into the content field + else if (selectedComponent.content) { + // The content field might have className as part of its parsed JSON object + try { + // Check if the content field has className data + const contentObj = selectedComponent.content as any; + if (contentObj && typeof contentObj === 'object' && contentObj.className) { + classString = contentObj.className; + } + } catch (err) { + console.error('Error parsing content for className:', err); + } + } + // 3. Try to extract from the attributes + else if (selectedComponent.attributes && selectedComponent.attributes.class) { + classString = selectedComponent.attributes.class; + } + + console.log('Found class string:', classString); + + if (classString) { + const classes = classString.split(' ') + .filter(Boolean) + .map(cls => cls.trim()); + + // Store original classes and set active classes + originalClassesRef.current = [...classes]; + setActiveClasses(classes); + + // Reset removed classes for new component + setRemovedClasses([]); + setHasChanges(false); + } else { + // No classes found + originalClassesRef.current = []; + setActiveClasses([]); + setRemovedClasses([]); + setHasChanges(false); + } + + // Update the current component ID + previousComponentIdRef.current = selectedComponent.id; + } + } else { + // No component selected + originalClassesRef.current = []; + setActiveClasses([]); + setRemovedClasses([]); + setHasChanges(false); + previousComponentIdRef.current = null; + } + }, [selectedComponent?.id]); + + // Effect to trigger file save after custom styles are set + useEffect(() => { + if (pendingSave && selectedComponent) { + // Perform the actual save + saveClassesToFile(); + + // Update original classes reference to match the current active classes + originalClassesRef.current = [...activeClasses]; + + // Reset removed classes as we've committed the changes + setRemovedClasses([]); + + // Reset states + setHasChanges(false); + setPendingSave(false); + } + }, [pendingSave, saveClassesToFile, selectedComponent, activeClasses]); + + // Apply class changes to the component in the iframe + const applyClassChanges = () => { + if (!selectedComponent || !selectedComponent.id) return; + + console.log('Applying class changes, new classes:', activeClasses.join(' ')); + + // Update the element's class in the iframe to show visual changes immediately + updateElementStyle(selectedComponent.id, { + class: activeClasses.join(' ') + }); + + // Mark that we have changes that need to be saved + setHasChanges(true); + setIsStyleEdited(true); + }; + + // Handle saving classes to file + const handleSaveClasses = () => { + if (!selectedComponent || !hasChanges) return; + + // Get the current active classes as a single string + const classString = activeClasses.join(' '); + + console.log('Saving classes:', classString); + + // Update customStyles with the className + setCustomStyles({ + className: classString + }); + + // Set pendingSave flag to trigger the effect that will save the file + setPendingSave(true); + }; + + // Reset to original classes + const handleResetClasses = () => { + if (!selectedComponent || !selectedComponent.id) return; + + console.log('Resetting to original classes:', originalClassesRef.current); + + // Restore original classes + setActiveClasses([...originalClassesRef.current]); + setRemovedClasses([]); + setHasChanges(false); + + // Apply visual change + updateElementStyle(selectedComponent.id, { + class: originalClassesRef.current.join(' ') + }); + }; + + // Handle adding a new class + const handleAddClass = () => { + if (!newClass.trim()) return; + + // Check if class already exists + if (!activeClasses.includes(newClass)) { + // Create updated classes array with the new class added + const updatedClasses = [...activeClasses, newClass]; + + // Update our state + setActiveClasses(updatedClasses); + + // If it was previously removed, take it out of removedClasses + if (removedClasses.includes(newClass)) { + setRemovedClasses(removedClasses.filter(c => c !== newClass)); + } + + // Apply changes directly to the component in the DOM + if (selectedComponent && selectedComponent.id) { + console.log('Adding new class:', newClass); + console.log('Updated classes:', updatedClasses.join(' ')); + + // Use the updated array directly to ensure immediate visual update + updateElementStyle(selectedComponent.id, { + class: updatedClasses.join(' ') + }); + + // Mark changes + setHasChanges(true); + setIsStyleEdited(true); + } + } + + // Clear the input field + setNewClass(''); + }; + + // Handle removing a class + const handleRemoveClass = (classToRemove: string) => { + // Filter out the class to remove + const updatedClasses = activeClasses.filter(c => c !== classToRemove); + + // Update our state with the filtered classes + setActiveClasses(updatedClasses); + + // Add to removed classes only if it was in the original set + if (originalClassesRef.current.includes(classToRemove) && + !removedClasses.includes(classToRemove)) { + setRemovedClasses([...removedClasses, classToRemove]); + } + + // Apply changes to the component's class directly in the DOM + if (selectedComponent && selectedComponent.id) { + console.log('Applying class removal, removed class:', classToRemove); + console.log('New classes after removal:', updatedClasses.join(' ')); + + // Important: use the updated classes array directly instead of the state + // which might not be updated yet due to React's async state updates + updateElementStyle(selectedComponent.id, { + class: updatedClasses.join(' ') // Note: use 'class' not 'className' for the DOM attribute + }); + + // Mark that we have changes that need to be saved + setHasChanges(true); + setIsStyleEdited(true); + } + }; + + // Restore a removed class + const handleRestoreClass = (classToRestore: string) => { + // Remove from removedClasses + setRemovedClasses(removedClasses.filter(c => c !== classToRestore)); + + // Add back to activeClasses + const updatedClasses = [...activeClasses, classToRestore]; + setActiveClasses(updatedClasses); + + // Apply changes directly using the updated classes array + if (selectedComponent && selectedComponent.id) { + updateElementStyle(selectedComponent.id, { + class: updatedClasses.join(' ') // Note: use 'class' not 'className' for the DOM attribute + }); + + // Mark that we have changes + setHasChanges(true); + setIsStyleEdited(true); + } + }; + + // Get tailwind groups (utility-first categories) + const getTailwindGroups = () => { + const groups: { [key: string]: string[] } = { + Layout: [], + Spacing: [], + Sizing: [], + Typography: [], + Backgrounds: [], + Borders: [], + Effects: [], + Flexbox: [], + Grid: [], + Positioning: [], + Other: [] + }; + + activeClasses.forEach(cls => { + // Categorize classes based on prefix + if (cls.startsWith('p-') || cls.startsWith('m-') || cls.startsWith('gap-')) { + groups.Spacing.push(cls); + } else if (cls.startsWith('w-') || cls.startsWith('h-') || cls.startsWith('min-')) { + groups.Sizing.push(cls); + } else if (cls.startsWith('text-') || cls.startsWith('font-') || cls.startsWith('leading-')) { + groups.Typography.push(cls); + } else if (cls.startsWith('bg-') || cls.startsWith('from-') || cls.startsWith('to-')) { + groups.Backgrounds.push(cls); + } else if (cls.startsWith('border-') || cls.startsWith('rounded-')) { + groups.Borders.push(cls); + } else if (cls.startsWith('shadow-') || cls.startsWith('opacity-') || cls.startsWith('transform-')) { + groups.Effects.push(cls); + } else if (cls.startsWith('flex-') || cls.startsWith('items-') || cls.startsWith('justify-')) { + groups.Flexbox.push(cls); + } else if (cls.startsWith('grid-') || cls.startsWith('col-') || cls.startsWith('row-')) { + groups.Grid.push(cls); + } else if (cls.startsWith('absolute') || cls.startsWith('relative') || cls.startsWith('static')) { + groups.Positioning.push(cls); + } else if (cls.startsWith('block') || cls.startsWith('inline') || cls.startsWith('hidden')) { + groups.Layout.push(cls); + } else { + groups.Other.push(cls); + } + }); + + // Filter out empty groups + return Object.entries(groups).filter(([_, classes]) => classes.length > 0); + }; + + if (!selectedComponent) { + return ( +
+
+

No component selected

+

Click an element to inspect its classes

+
+
+ ); + } + + const tailwindGroups = getTailwindGroups(); + + return ( + +
+ {/* Header with class count */} +
+
+

+ Classes + + {activeClasses.length} + +

+

Manage component classes

+
+
+ + +
+
+ + {/* Add new class */} +
+
+ setNewClass(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleAddClass(); + } + }} + /> + +
+
+ + {/* Classes list grouped by type */} +
+ {tailwindGroups.map(([groupName, classes]) => ( +
+

{groupName}

+
+ {classes.map((cls) => ( + + {cls} + + + ))} +
+
+ ))} + + {/* Show empty state if no classes */} + {tailwindGroups.length === 0 && ( +
+

No classes added yet

+

Add classes above to style this component

+
+ )} +
+ + {/* Removed classes (for reference) */} + {removedClasses.length > 0 && ( +
+

Removed Classes

+
+ {removedClasses.map((cls) => ( + + {cls} + + + ))} +
+
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/chat/code-engine/component-inspector/tabs/ContentTab.tsx b/frontend/src/components/chat/code-engine/component-inspector/tabs/ContentTab.tsx new file mode 100644 index 00000000..5d4796b1 --- /dev/null +++ b/frontend/src/components/chat/code-engine/component-inspector/tabs/ContentTab.tsx @@ -0,0 +1,166 @@ +import React, { useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Save, RotateCcw } from 'lucide-react'; +import { ContentTabProps } from '../types'; +import { TypographyControls } from '../components/TypographyControls'; +import { updateElementContent } from '../utils/iframe-utils'; + +/** + * Content tab - Displays and manages text content for the selected component + */ +export const ContentTab: React.FC = ({ + selectedComponent, + customStyles, + computedStyles, + isContentEdited, + isStyleEdited, + applyingChanges, + editableContent, + originalContent, + setEditableContent, + setIsContentEdited, + setIsStyleEdited, + applyContentChanges, + applyStyleChanges, + handleStyleChange +}) => { + // Check if content has changed from original + const hasContentChanged = editableContent !== originalContent; + // Either the tag is text-editable or it actually has text content + const hasTextContent = Boolean(originalContent.trim()); + + // Reset content to original + const resetContent = () => { + setEditableContent(originalContent); + setIsContentEdited(false); + + // Reset visual display when resetting content + if (selectedComponent) { + updateElementContent(selectedComponent.id, originalContent); + } + }; + + // Apply content changes + const handleContentSave = () => { + if (!selectedComponent) { + console.error('No component selected for content save'); + return; + } + + // Save content changes if needed + if (hasContentChanged) { + applyContentChanges(editableContent); + } + + // Always save typography style changes when we have custom styles + if (Object.keys(customStyles).length > 0) { + console.log("Applying style changes as we have custom styles"); + applyStyleChanges(); + } else if (isStyleEdited) { + console.log("Applying style changes based on isStyleEdited flag"); + applyStyleChanges(); + } + }; + + // Handle content changes with real-time preview updates + const handleContentChange = (e: React.ChangeEvent) => { + const newContent = e.target.value; + setEditableContent(newContent); + setIsContentEdited(newContent !== originalContent); + + // Apply visual changes immediately + if (selectedComponent) { + updateElementContent(selectedComponent.id, newContent); + } + }; + + // Wrapper for handleStyleChange that also sets isStyleEdited flag + const handleTypographyStyleChange = (property: string, value: string) => { + console.log("Typography style change:", property, value); + + // Call the original style change handler + handleStyleChange(property, value); + + // Force isStyleEdited to true + setIsStyleEdited(true); + }; + + if (!selectedComponent) { + return ( +
+
+

No component selected

+

Click an element to edit its content

+
+
+ ); + } + + return ( + +
+ {/* Header with content type */} +
+
+

Text Content

+

Edit the component text

+
+
+ + +
+
+ + {/* Content editor */} +
+ {hasTextContent ? ( +