import { useCallback, useState, useEffect, useMemo } from 'react' import '@xyflow/react/dist/style.css' import { ReactFlow, Node, Edge, Controls, MarkerType, NodeProps, Handle, Position, NodeChange, applyNodeChanges, EdgeChange, applyEdgeChanges, Connection, addEdge, } from '@xyflow/react' import { Layout, Component as ComponentIcon, X, Trash2, Edit, Save } from 'lucide-react' import { projectService } from '../../services/projectService' import { useToast } from '../../contexts/ToastContext' // Define custom node types following React Flow v12 pattern type PageNodeData = { label: string; type: string; route: string; components: number; }; type ServiceNodeData = { label: string; type: string; }; // Define union type for all custom nodes type CustomNodeTypes = Node | Node; // Custom node components const PageNode = ({ data }: NodeProps) => { const pageData = data as PageNodeData; return (
{pageData.label}
{pageData.type}
Route: {pageData.route}
Components: {pageData.components}
); }; const ServiceNode = ({ data }: NodeProps) => { const serviceData = data as ServiceNodeData; return (
{serviceData.label}
{serviceData.type}
); }; const nodeTypes = { page: PageNode, service: ServiceNode, } // Default/fallback nodes for when project has no features data const defaultNodes: Node[] = [ { id: 'start', type: 'page', data: { label: 'Start App', type: 'Entry Point', route: '/', components: 3, }, position: { x: 400, y: 0, }, }, { id: 'home', type: 'page', data: { label: 'Homepage', type: 'Main View', route: '/home', components: 6, }, position: { x: 400, y: 150, }, }, ]; // Default/fallback edges const defaultEdges: Edge[] = [ { id: 'start-home', source: 'start', target: 'home', animated: true, style: { stroke: '#22d3ee', }, markerEnd: { type: MarkerType.ArrowClosed, color: '#22d3ee', }, }, ]; interface FeaturesTabProps { project?: { id: string; title: string; features?: import('../types/jsonb').ProjectFeature[]; } | null; } export const FeaturesTab = ({ project }: FeaturesTabProps) => { const [nodes, setNodes] = useState([]) const [edges, setEdges] = useState([]) const [loading, setLoading] = useState(true) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [nodeToDelete, setNodeToDelete] = useState(null) const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) const [editingNode, setEditingNode] = useState(null) const [showEditModal, setShowEditModal] = useState(false) const [isSaving, setIsSaving] = useState(false) const { showToast } = useToast() // Load features from project or show empty state useEffect(() => { if (project?.features && Array.isArray(project.features) && project.features.length > 0) { // Ensure all nodes have required properties with defaults const normalizedNodes = project.features.map((node: any, index: number) => ({ ...node, // Ensure position exists with sensible defaults position: node.position || { x: 250 + (index % 3) * 250, // Spread horizontally y: 200 + Math.floor(index / 3) * 150 // Stack vertically }, // Ensure type exists (fallback based on data structure) type: node.type || (node.data?.route ? 'page' : 'service'), // Ensure data exists data: node.data || { label: 'Unknown', type: 'Unknown Component' } })); setNodes(normalizedNodes) // Generate edges based on the flow (simplified logic) const generatedEdges = generateEdgesFromNodes(normalizedNodes) setEdges(generatedEdges) } else { // Show empty state - no nodes or edges setNodes([]) setEdges([]) } setLoading(false) }, [project]) // Helper function to generate edges based on node positioning and types const generateEdgesFromNodes = (nodes: Node[]): Edge[] => { const edges: Edge[] = [] // Sort nodes by y position to create a logical flow (with safety check for position) const sortedNodes = [...nodes].sort((a, b) => { const aY = a.position?.y || 0; const bY = b.position?.y || 0; return aY - bY; }) for (let i = 0; i < sortedNodes.length - 1; i++) { const currentNode = sortedNodes[i] const nextNode = sortedNodes[i + 1] // Connect sequential nodes with appropriate styling const edgeStyle = currentNode.type === 'service' ? '#d946ef' : '#22d3ee' edges.push({ id: `${currentNode.id}-${nextNode.id}`, source: currentNode.id, target: nextNode.id, animated: true, style: { stroke: edgeStyle, }, markerEnd: { type: MarkerType.ArrowClosed, color: edgeStyle, }, }) } return edges } const onNodesChange = useCallback( (changes: NodeChange[]) => { setNodes((nds) => applyNodeChanges(changes, nds)) setHasUnsavedChanges(true) }, [], ) const onEdgesChange = useCallback( (changes: EdgeChange[]) => { setEdges((eds) => applyEdgeChanges(changes, eds)) setHasUnsavedChanges(true) }, [], ) const onConnect = useCallback( (connection: Connection) => { const sourceNode = nodes.find((node) => node.id === connection.source) // Set edge color based on source node type const edgeStyle = sourceNode?.type === 'service' ? { stroke: '#d946ef', } : // Fuchsia for service nodes { stroke: '#22d3ee', } // Cyan for page nodes setEdges((eds) => addEdge( { ...connection, animated: true, style: edgeStyle, markerEnd: { type: MarkerType.ArrowClosed, color: edgeStyle.stroke, }, }, eds, ), ) setHasUnsavedChanges(true) }, [nodes], ) const saveToDatabase = async (nodesToSave = nodes, edgesToSave = edges) => { if (!project?.id) { console.error('❌ No project ID available for saving features'); return; } setIsSaving(true); try { console.log('💾 Saving features to database...'); await projectService.updateProject(project.id, { features: nodesToSave }); console.log('✅ Features saved successfully'); setHasUnsavedChanges(false); } catch (error) { console.error('❌ Failed to save features:', error); throw error; } finally { setIsSaving(false); } }; const handleManualSave = async () => { await saveToDatabase(); }; const addPageNode = async () => { const newNode: Node = { id: `page-${Date.now()}`, type: 'page', data: { label: `New Page`, type: 'Page Component', route: '/new-page', components: 0, }, position: { x: 250, y: 200, }, } const newNodes = [...nodes, newNode]; setNodes(newNodes); setHasUnsavedChanges(true); // Auto-save when adding try { await saveToDatabase(newNodes, edges); } catch (error) { // Revert on error setNodes(nodes); } } const addServiceNode = async () => { const newNode: Node = { id: `service-${Date.now()}`, type: 'service', data: { label: 'New Service', type: 'Service Component', }, position: { x: 250, y: 200, }, } const newNodes = [...nodes, newNode]; setNodes(newNodes); setHasUnsavedChanges(true); // Auto-save when adding try { await saveToDatabase(newNodes, edges); } catch (error) { // Revert on error setNodes(nodes); } } const handleDeleteNode = useCallback(async (event: React.MouseEvent, nodeId: string) => { event.stopPropagation(); if (!project?.id) { console.error('❌ No project ID available for deleting node'); return; } // Show custom confirmation dialog setNodeToDelete(nodeId); setShowDeleteConfirm(true); }, [project?.id]); const confirmDelete = useCallback(async () => { if (!nodeToDelete) return; console.log('🗑️ Deleting node:', nodeToDelete); try { // Remove node from UI const newNodes = nodes.filter(node => node.id !== nodeToDelete); // Remove any edges connected to this node const newEdges = edges.filter(edge => edge.source !== nodeToDelete && edge.target !== nodeToDelete ); setNodes(newNodes); setEdges(newEdges); // Save to database await saveToDatabase(newNodes, newEdges); showToast('Node deleted successfully', 'success'); // Close confirmation dialog setShowDeleteConfirm(false); setNodeToDelete(null); } catch (error) { console.error('❌ Failed to delete node:', error); // Revert UI changes on error setNodes(nodes); setEdges(edges); showToast('Failed to delete node', 'error'); } }, [nodeToDelete, nodes, edges]); const cancelDelete = useCallback(() => { setShowDeleteConfirm(false); setNodeToDelete(null); }, []); const handleNodeClick = useCallback((event: React.MouseEvent, node: Node) => { setEditingNode(node); setShowEditModal(true); }, []); const saveNodeChanges = async (updatedNode: Node) => { // Update local state first const newNodes = nodes.map(node => node.id === updatedNode.id ? updatedNode : node ); setNodes(newNodes); // Save to database await saveToDatabase(newNodes, edges); setShowEditModal(false); setEditingNode(null); }; // Memoize node types with delete and edit functionality const nodeTypes = useMemo(() => ({ page: ({ data, id }: NodeProps) => { const pageData = data as any; return (
{ const actualNode = nodes.find(node => node.id === id); if (actualNode) { handleNodeClick(e, actualNode); } }} >
{pageData.label}
{pageData.type}
Route: {pageData.route}
Components: {pageData.components}
); }, service: ({ data, id }: NodeProps) => { const serviceData = data as any; return (
{ const actualNode = nodes.find(node => node.id === id); if (actualNode) { handleNodeClick(e, actualNode); } }} >
{serviceData.label}
{serviceData.type}
); } }), [handleNodeClick, handleDeleteNode, nodes]); if (loading) { return (
Loading features...
) } return (
Feature Planner {project?.features ? `(${project.features.length} features)` : '(Default)'}
{hasUnsavedChanges && ( )}
{/* Subtle neon glow at the top */}
{nodes.length === 0 ? (

No features defined

Add pages and services to get started

) : ( )}
{/* Delete Confirmation Modal */} {showDeleteConfirm && ( n.id === nodeToDelete)?.data.label as string || 'node'} /> )} {/* Edit Modal */} {showEditModal && editingNode && ( { setShowEditModal(false); setEditingNode(null); }} /> )}
) } // Delete Confirmation Modal Component const DeleteConfirmModal = ({ onConfirm, onCancel, nodeName }: { onConfirm: () => void; onCancel: () => void; nodeName: string; }) => { return (

Delete Node

This action cannot be undone

Are you sure you want to delete "{nodeName}"? This will also remove all related connections.

); }; // Edit Feature Modal Component const EditFeatureModal = ({ node, onSave, onClose }: { node: Node; onSave: (node: Node) => void; onClose: () => void; }) => { const [name, setName] = useState(node.data.label as string); const [route, setRoute] = useState((node.data as any).route || ''); const [components, setComponents] = useState((node.data as any).components || 0); const isPageNode = node.type === 'page'; const handleSave = () => { const updatedNode = { ...node, data: { ...node.data, label: name, ...(isPageNode && { route, components }) } }; onSave(updatedNode); }; return (

{isPageNode ? : } Edit {isPageNode ? 'Page' : 'Service'}

setName(e.target.value)} className="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-cyan-500 focus:outline-none" />
{isPageNode && ( <>
setRoute(e.target.value)} className="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-cyan-500 focus:outline-none" placeholder="/example-page" />
setComponents(parseInt(e.target.value) || 0)} className="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-cyan-500 focus:outline-none" min="0" />
)}
); };