import { useEffect, useState, useCallback, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useProjectStore } from '../../store/projectStore'; import { useEditorStore } from '../../store/editorStore'; import { useCollaborationStore } from '../../store/collaborationStore'; import { useProjectSocket, getSocket } from '../../hooks/useWebSocket'; import { Blocks, Eye, Code2, FileType, Palette, Play, Save, ArrowLeft, Grid3X3, Maximize2, Minimize2, PanelLeft, PanelRight, Users } from 'lucide-react'; import FileTree from './FileTree'; import Toolbar from './Toolbar'; import BlockEditor from '../BlockEditor/BlockEditor'; import VisualEditor from '../VisualEditor/VisualEditor'; import CssRulePanel from '../VisualEditor/CssRulePanel'; import CodeView from '../CodeView/CodeView'; import ScriptPreview from './ScriptPreview'; import ShareModal from '../Collaboration/ShareModal'; import CollaboratorAvatars from '../Collaboration/CollaboratorAvatars'; import { ProjectFile } from '../../types/blocks'; import { generatePreviewHtml } from '../../compilers/web'; type FileCategory = 'markup' | 'script' | 'other'; function getFileCategory(fileType: string): FileCategory { if (['html', 'css'].includes(fileType)) return 'markup'; if (['js', 'ts', 'typescript', 'jsx', 'tsx', 'csharp', 'xaml'].includes(fileType)) return 'script'; return 'other'; } function findFileById(files: ProjectFile[], id: string): ProjectFile | null { for (const f of files) { if (f.id === id) return f; if (f.children) { const found = findFileById(f.children, id); if (found) return found; } } return null; } // Migrate file contents that contain legacy Blockly XML instead of generated code function migrateBlocklyXmlContent(files: ProjectFile[]): ProjectFile[] { return files.map((f) => { if (f.content && /^(); const navigate = useNavigate(); const { currentProject, loadProject, updateProject, loading, error } = useProjectStore(); const { editorMode, setEditorMode, viewMode, setViewMode, activeFileId, setActiveFile, fileTree, setFileTree, visualElements, setVisualElements, setElementRegistry, elementRegistry, blocksXml, blockCode, cssRules, snapToGrid, toggleSnapToGrid, showMiniMap, toggleMiniMap, setBlocksXml, setCssRules } = useEditorStore(); const [showSidebar, setShowSidebar] = useState(true); const [saving, setSaving] = useState(false); const [showShareModal, setShowShareModal] = useState(false); const [showPreview, setShowPreview] = useState(false); const { emitFileChanged, emitActiveFileChanged, emitSave, emitCursorMove } = useProjectSocket(id); const connected = useCollaborationStore((s) => s.connected); useEffect(() => { if (id) loadProject(id); if (id) (window as any).__projectId = id; return () => { (window as any).__projectId = undefined; }; }, [id, loadProject]); useEffect(() => { if (currentProject?.data) { const migratedFiles = migrateBlocklyXmlContent((currentProject.data.files || [])); setFileTree(migratedFiles); // Load element registry from persisted project data const visualData = currentProject.data.visual || {}; setElementRegistry(visualData); // Set visual elements for the active file setVisualElements( visualData[activeFileId || ''] || [] ); // Restore per-file blocksXml metadata if (currentProject.data.blocksXml) { const xmlMap = currentProject.data.blocksXml; // Handle legacy string format: old blocksXml was a global string, not per-file if (typeof xmlMap === 'string') { // Discard old global blocksXml; blocks start fresh per-file } else { Object.entries(xmlMap).forEach(([fid, xml]) => { // Don't overwrite if store already has a non-empty value for this file. // The in-memory state from initialBlocksXml (sent on join_project) is // more recent than the SQLite snapshot, since live block_state_sync // happens before auto-save persists to the database. const current = useEditorStore.getState().blocksXml[fid]; if (!current || current === '') { setBlocksXml(fid, xml); } }); } } // Restore per-file block code cache const { setBlockCode } = useEditorStore.getState(); if (currentProject.data.blockCode) { Object.entries(currentProject.data.blockCode).forEach(([fid, code]) => { setBlockCode(fid, code); }); } // Restore per-file CSS rules if (currentProject.data.cssRules) { Object.entries(currentProject.data.cssRules).forEach(([fid, rules]) => { setCssRules(fid, rules as any[]); }); } if (!activeFileId && currentProject.data.files?.length > 0) { const firstFile = currentProject.data.files[0]; if (firstFile) { setActiveFile(firstFile.id); const cat = getFileCategory(firstFile.type); if (firstFile.type === 'css') setEditorMode('css'); else if (cat === 'markup') setEditorMode('visual'); else if (cat === 'script') setEditorMode('blocks'); } } } }, [currentProject, setFileTree, setVisualElements, setActiveFile, activeFileId, setEditorMode, setBlocksXml, setElementRegistry, setCssRules]); // When activeFileId changes, load visual elements from the registry useEffect(() => { if (activeFileId) { const registry = useEditorStore.getState().elementRegistry; setVisualElements(registry[activeFileId] || []); } }, [activeFileId, setVisualElements]); const activeFile = useMemo( () => (activeFileId ? findFileById(fileTree, activeFileId) : null), [activeFileId, fileTree] ); const fileCategory = activeFile ? getFileCategory(activeFile.type) : 'other'; // Check if the active file is script linked under an HTML file (for preview) const isScriptUnderHtml = useMemo(() => { if (!activeFile || fileCategory !== 'script') return false; // Check if any HTML file has this script as a linked file const htmlFiles = fileTree.filter(f => f.type === 'html'); return htmlFiles.some(html => (html.linkedFiles || []).includes(activeFile.id)); }, [activeFile, fileCategory, fileTree]); const availableModes = useMemo(() => { if (fileCategory === 'markup') { if (activeFile?.type === 'css') { return [ { id: 'css' as const, label: 'Styles', icon: Palette }, { id: 'code' as const, label: 'Code', icon: Code2 }, { id: 'files' as const, label: 'Files', icon: FileType }, ]; } return [ { id: 'visual' as const, label: 'Visual', icon: Eye }, { id: 'code' as const, label: 'Code', icon: Code2 }, { id: 'files' as const, label: 'Files', icon: FileType }, ]; } if (fileCategory === 'script') { const modes: { id: string; label: string; icon: any }[] = [ { id: 'blocks', label: 'Blocks', icon: Blocks }, { id: 'code', label: 'Code', icon: Code2 }, ]; modes.push({ id: 'files', label: 'Files', icon: FileType }); return modes; } return [ { id: 'code' as const, label: 'Code', icon: Code2 }, { id: 'files' as const, label: 'Files', icon: FileType }, ]; }, [fileCategory, isScriptUnderHtml, activeFile]); useEffect(() => { if (!activeFileId) return; const validIds = availableModes.map(m => m.id); if (!validIds.includes(editorMode)) { setEditorMode(validIds[0] as any); } setViewMode('design'); }, [activeFileId, availableModes]); // Compute preview content for ScriptPreview const previewContent = useMemo(() => { if (!activeFile || fileCategory !== 'script' || !isScriptUnderHtml) return null; // Find the parent HTML file that links this script const htmlFile = fileTree.find(f => f.type === 'html' && (f.linkedFiles || []).includes(activeFile.id)); if (!htmlFile) return null; // Build htmlContent: prefer file content, otherwise generate from visual elements let htmlContent: string; if (htmlFile.content) { htmlContent = htmlFile.content; } else { const elements = elementRegistry[htmlFile.id] || []; if (elements.length > 0) { // Inline linked CSS content so it works inside the sandboxed iframe const linkedIds = htmlFile.linkedFiles || []; const linkedCssContent = linkedIds .map(id => findFileById(fileTree, id)) .filter((f): f is ProjectFile => f !== null && f.type === 'css') .map(f => ({ content: f.content || '' })); htmlContent = generatePreviewHtml({ visualElements: elements, projectName: currentProject?.name || 'My Web App', linkedCssContent, }); } else { htmlContent = ''; } } return { htmlContent, scriptContent: activeFile.content || '', fileName: activeFile.name, }; }, [activeFile, fileCategory, isScriptUnderHtml, fileTree, elementRegistry, currentProject]); // Emit active file changes to collaborators useEffect(() => { if (id && activeFileId) { emitActiveFileChanged(activeFileId); } }, [id, activeFileId, emitActiveFileChanged]); // Set up global block selection WS emit useEffect(() => { (window as any).__wsBlockSelection = (blockId: string | null) => { const projectId = (window as any).__projectId; const socket = (window as any).__wsSocket || getSocket(); if (socket?.connected && projectId) { socket.emit('block_selection_changed', { projectId, blockId }); } }; return () => { (window as any).__wsBlockSelection = undefined; }; }, []); // Keep global socket reference useEffect(() => { const socket = getSocket(); (window as any).__wsSocket = socket; }); const handleSave = useCallback(async () => { if (!currentProject || !id) return; setSaving(true); try { const { blocksXml: currentBlocksXml, blockCode: currentBlockCode, elementRegistry: currentRegistry, cssRules: currentCssRules } = useEditorStore.getState(); // Sync current visual elements into registry before saving const fullRegistry = { ...currentRegistry }; if (activeFileId) { fullRegistry[activeFileId] = visualElements; } const data = { ...currentProject.data, files: fileTree, visual: fullRegistry, blocksXml: currentBlocksXml, blockCode: currentBlockCode, cssRules: currentCssRules, }; // Try WebSocket save first, fall back to REST if (connected) { await emitSave(data); } else { await updateProject(id, { data }); } } finally { setSaving(false); } }, [currentProject, id, fileTree, visualElements, activeFileId, updateProject, connected, emitSave]); // Auto-save via WebSocket when data changes (no more 30s polling) useEffect(() => { if (!currentProject || !id || !connected) return; const timer = setTimeout(() => handleSave(), 2000); return () => clearTimeout(timer); }, [currentProject, id, fileTree, visualElements, elementRegistry, activeFileId, blocksXml, blockCode, cssRules, connected, handleSave]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); handleSave(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [handleSave]); if (loading && !currentProject) { return (

Loading project...

); } if (error && !currentProject) { return (
Failed to load project

{error}

); } if (!currentProject) { return (

No project data

); } const framework = currentProject.framework; const displayMode = !activeFileId ? 'files' : editorMode; return (
navigate('/dashboard')} onSave={handleSave} saving={saving} leftContent={
{availableModes.map((mode) => { const Icon = mode.icon; return ( ); })}
} rightContent={
{displayMode === 'visual' && ( <> )} {isScriptUnderHtml && ( )}
} />
{showSidebar && (
)}
{showPreview && previewContent && (
setShowPreview(false)} />
)}
{displayMode === 'blocks' && } {displayMode === 'visual' && } {displayMode === 'css' && } {displayMode === 'code' && } {displayMode === 'files' && (
)}
{id && ( setShowShareModal(false)} /> )}
); }