feat: Add full CRUD functionality, project detail panel, and improved UI
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
- Add UPDATE and DELETE endpoints to backend - Implement project detail panel with comprehensive editing - Add drag-and-drop functionality for projects in mind map - Show all projects in map (not just selected + children) - Fix infinite render loop in MindMap component - Improve UI spacing and button layouts - Add local development database schema with RLS disabled - Update docker-compose for regular docker-compose (not Swarm) - Add CORS support and nginx API proxying - Improve button spacing and modern design principles
This commit is contained in:
@@ -2,6 +2,33 @@ server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Proxy direct backend endpoints (projects, tenants, etc.)
|
||||
location ~ ^/(projects|tenants) {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Serve frontend static files
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
|
||||
@@ -1,30 +1,218 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import ReactFlow, { Background, Controls, type Node, type Edge } from 'reactflow';
|
||||
import React, { useMemo, useState, useCallback, useRef } from 'react';
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
Controls,
|
||||
type Node,
|
||||
type Edge,
|
||||
type NodeMouseHandler,
|
||||
type OnNodesChange,
|
||||
type OnEdgesChange,
|
||||
applyNodeChanges,
|
||||
applyEdgeChanges,
|
||||
type Connection,
|
||||
addEdge,
|
||||
ReactFlowProvider,
|
||||
} from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
import { useProjectStore } from '../store/useProjectStore';
|
||||
import ProjectModal from './ProjectModal';
|
||||
|
||||
const MindMap: React.FC = () => {
|
||||
const { projects } = useProjectStore();
|
||||
const MindMapContent: React.FC = () => {
|
||||
const { projects, selectedKey, setSelectedKey, updateProject } = useProjectStore();
|
||||
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [edges, setEdges] = useState<Edge[]>([]);
|
||||
const nodePositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map());
|
||||
|
||||
// Filter nodes based on selection (e.g., show children of selected node)
|
||||
// For demo, just showing all projects as nodes
|
||||
const nodes: Node[] = useMemo(() => {
|
||||
return projects.map((p, index) => ({
|
||||
id: p.id,
|
||||
position: { x: index * 200, y: index * 100 },
|
||||
data: { label: p.name },
|
||||
}));
|
||||
// Show ALL projects in the map
|
||||
const displayProjects = useMemo(() => {
|
||||
return projects;
|
||||
}, [projects]);
|
||||
|
||||
const edges: Edge[] = []; // Add edges logic based on parentId
|
||||
// Create nodes with proper positioning - use hierarchical layout
|
||||
const projectNodes: Node[] = useMemo(() => {
|
||||
if (displayProjects.length === 0) return [];
|
||||
|
||||
// Build a map of projects by parent
|
||||
const projectsByParent = new Map<string | null, typeof projects>();
|
||||
displayProjects.forEach(project => {
|
||||
const parentId = project.parentId;
|
||||
if (!projectsByParent.has(parentId)) {
|
||||
projectsByParent.set(parentId, []);
|
||||
}
|
||||
projectsByParent.get(parentId)!.push(project);
|
||||
});
|
||||
|
||||
// Calculate positions using hierarchical layout
|
||||
const nodeMap = new Map<string, Node>();
|
||||
const visited = new Set<string>();
|
||||
|
||||
const calculatePosition = (_projectId: string, level: number, index: number): { x: number; y: number } => {
|
||||
const x = level * 300;
|
||||
const y = index * 150;
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const buildNodes = (parentId: string | null, level: number = 0, startIndex: number = 0): number => {
|
||||
const children = projectsByParent.get(parentId) || [];
|
||||
let currentIndex = startIndex;
|
||||
|
||||
children.forEach((project) => {
|
||||
if (visited.has(project.id)) return;
|
||||
visited.add(project.id);
|
||||
|
||||
// Use saved position if available, otherwise calculate new position
|
||||
const savedPosition = nodePositionsRef.current.get(project.id);
|
||||
const position = savedPosition || calculatePosition(project.id, level, currentIndex);
|
||||
const isSelected = project.id === selectedKey;
|
||||
|
||||
nodeMap.set(project.id, {
|
||||
id: project.id,
|
||||
position,
|
||||
data: {
|
||||
label: (
|
||||
<div style={{
|
||||
padding: '8px',
|
||||
textAlign: 'center',
|
||||
fontWeight: isSelected ? 'bold' : 'normal',
|
||||
}}>
|
||||
{project.name}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
border: isSelected ? '3px solid #1890ff' : '2px solid #d9d9d9',
|
||||
borderRadius: '8px',
|
||||
background: isSelected ? '#e6f7ff' : '#fff',
|
||||
width: 180,
|
||||
},
|
||||
draggable: true,
|
||||
});
|
||||
|
||||
currentIndex++;
|
||||
// Recursively process children
|
||||
const childCount = buildNodes(project.id, level + 1, currentIndex);
|
||||
currentIndex += childCount;
|
||||
});
|
||||
|
||||
return children.length;
|
||||
};
|
||||
|
||||
buildNodes(null, 0, 0);
|
||||
|
||||
return Array.from(nodeMap.values());
|
||||
}, [displayProjects, selectedKey]);
|
||||
|
||||
// Update nodes when projects change (but preserve positions)
|
||||
React.useEffect(() => {
|
||||
if (projectNodes.length > 0) {
|
||||
setNodes(projectNodes);
|
||||
} else if (displayProjects.length === 0) {
|
||||
setNodes([]);
|
||||
}
|
||||
}, [projectNodes, displayProjects.length]);
|
||||
|
||||
// Create edges based on parent-child relationships
|
||||
const projectEdges: Edge[] = useMemo(() => {
|
||||
const edgeList: Edge[] = [];
|
||||
displayProjects.forEach(project => {
|
||||
if (project.parentId) {
|
||||
const parentExists = displayProjects.find(p => p.id === project.parentId);
|
||||
if (parentExists) {
|
||||
edgeList.push({
|
||||
id: `${project.parentId}-${project.id}`,
|
||||
source: project.parentId,
|
||||
target: project.id,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return edgeList;
|
||||
}, [displayProjects]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setEdges(projectEdges);
|
||||
}, [projectEdges]);
|
||||
|
||||
// Handle node changes (drag)
|
||||
const onNodesChange: OnNodesChange = useCallback((changes) => {
|
||||
setNodes((nds) => applyNodeChanges(changes, nds));
|
||||
}, []);
|
||||
|
||||
// Handle edge changes
|
||||
const onEdgesChange: OnEdgesChange = useCallback((changes) => {
|
||||
setEdges((eds) => applyEdgeChanges(changes, eds));
|
||||
}, []);
|
||||
|
||||
// Handle node position changes (when dragging ends)
|
||||
const onNodeDragStop = useCallback((_event: React.MouseEvent, node: Node) => {
|
||||
// Save the position for future renders
|
||||
nodePositionsRef.current.set(node.id, node.position);
|
||||
// Update the node position
|
||||
setNodes((nds) => nds.map((n) => (n.id === node.id ? { ...n, position: node.position } : n)));
|
||||
}, []);
|
||||
|
||||
// Handle connecting nodes (creating parent-child relationship)
|
||||
const onConnect = useCallback(async (params: Connection) => {
|
||||
if (params.source && params.target) {
|
||||
try {
|
||||
await updateProject(params.target, { parentId: params.source });
|
||||
setEdges((eds) => addEdge(params, eds));
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update project parent:', error);
|
||||
}
|
||||
}
|
||||
}, [updateProject]);
|
||||
|
||||
const onNodeDoubleClick: NodeMouseHandler = useCallback((_event, node) => {
|
||||
setEditingProjectId(node.id);
|
||||
setModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const onNodeClick: NodeMouseHandler = useCallback((_event, node) => {
|
||||
setSelectedKey(node.id);
|
||||
}, [setSelectedKey]);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }}>
|
||||
<ReactFlow nodes={nodes} edges={edges} fitView>
|
||||
<Background />
|
||||
<Controls />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
<>
|
||||
<div style={{ height: '100%', width: '100%' }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onConnect={onConnect}
|
||||
fitView
|
||||
onNodeDoubleClick={onNodeDoubleClick}
|
||||
onNodeClick={onNodeClick}
|
||||
snapToGrid={true}
|
||||
snapGrid={[20, 20]}
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
<ProjectModal
|
||||
visible={modalVisible}
|
||||
onCancel={() => {
|
||||
setModalVisible(false);
|
||||
setEditingProjectId(null);
|
||||
}}
|
||||
projectId={editingProjectId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MindMap: React.FC = () => {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<MindMapContent />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
326
frontend/src/components/ProjectDetailPanel.tsx
Normal file
326
frontend/src/components/ProjectDetailPanel.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Form, Input, Button, message, Typography, Divider } from 'antd';
|
||||
import { SaveOutlined, DeleteOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { useProjectStore } from '../store/useProjectStore';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface ProjectDetailPanelProps {
|
||||
projectId: string | null;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const ProjectDetailPanel: React.FC<ProjectDetailPanelProps> = ({ projectId, onClose }) => {
|
||||
const [form] = Form.useForm();
|
||||
const { projects, updateProject, deleteProject, fetchProjects } = useProjectStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
||||
const project = projectId ? projects.find(p => p.id === projectId) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
form.setFieldsValue({
|
||||
name: project.name,
|
||||
description: project.description || '',
|
||||
attributes: project.attributes ? JSON.stringify(project.attributes, null, 2) : '{}',
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [project, form]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!project) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const values = await form.validateFields();
|
||||
|
||||
// Validate JSON if provided
|
||||
if (values.attributes) {
|
||||
try {
|
||||
JSON.parse(values.attributes);
|
||||
} catch (e) {
|
||||
message.error('Invalid JSON in attributes field');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await updateProject(project.id, {
|
||||
name: values.name,
|
||||
description: values.description || null,
|
||||
});
|
||||
|
||||
// Note: attributes update would need backend support
|
||||
message.success('Project updated successfully');
|
||||
await fetchProjects();
|
||||
} catch (error: any) {
|
||||
message.error(error.message || 'Failed to update project');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!project) return;
|
||||
|
||||
try {
|
||||
setDeleteLoading(true);
|
||||
await deleteProject(project.id);
|
||||
message.success('Project deleted successfully');
|
||||
if (onClose) onClose();
|
||||
} catch (error: any) {
|
||||
message.error(error.message || 'Failed to delete project');
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
color: '#999'
|
||||
}}>
|
||||
<div>
|
||||
<p style={{ fontSize: '16px', marginBottom: '8px' }}>No project selected</p>
|
||||
<p style={{ fontSize: '13px' }}>Select a project from the tree to view details</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const children = projects.filter(p => p.parentId === project.id);
|
||||
const parent = project.parentId ? projects.find(p => p.id === project.parentId) : null;
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#fff' }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
background: '#fafafa'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flex: 1 }}>
|
||||
{onClose && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={onClose}
|
||||
size="small"
|
||||
style={{
|
||||
padding: '0 4px',
|
||||
minWidth: 'auto'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Text strong style={{ fontSize: '15px', fontWeight: 600 }}>Project Details</Text>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={loading}
|
||||
onClick={handleSave}
|
||||
size="small"
|
||||
style={{
|
||||
minWidth: '70px',
|
||||
height: '28px',
|
||||
fontSize: '13px',
|
||||
padding: '0 12px'
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{project && (
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
padding: '8px 12px',
|
||||
background: '#fff',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #e8e8e8'
|
||||
}}>
|
||||
<Text type="secondary" style={{ fontSize: '12px', display: 'block', marginBottom: '4px' }}>Current Project</Text>
|
||||
<Text strong style={{ fontSize: '14px' }}>{project.name}</Text>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deleteLoading}
|
||||
onClick={handleDelete}
|
||||
size="small"
|
||||
block
|
||||
style={{
|
||||
height: '28px',
|
||||
fontSize: '13px'
|
||||
}}
|
||||
>
|
||||
Delete Project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '20px' }}>
|
||||
<Card bordered={false} style={{ boxShadow: 'none', padding: 0 }}>
|
||||
<Form form={form} layout="vertical" style={{ maxWidth: '100%' }}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={<Text strong style={{ fontSize: '13px', marginBottom: '6px', display: 'block' }}>Project Name</Text>}
|
||||
rules={[{ required: true, message: 'Please enter project name' }]}
|
||||
style={{ marginBottom: '18px' }}
|
||||
>
|
||||
<Input
|
||||
placeholder="Enter project name"
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label={<Text strong style={{ fontSize: '13px', marginBottom: '6px', display: 'block' }}>Description</Text>}
|
||||
style={{ marginBottom: '20px' }}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder="Enter project description (optional)"
|
||||
showCount
|
||||
maxLength={500}
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider style={{ marginTop: '8px', marginBottom: '24px' }}>
|
||||
<Text strong style={{ fontSize: '15px' }}>Hierarchy</Text>
|
||||
</Divider>
|
||||
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<Text type="secondary" style={{ fontSize: '13px', display: 'block', marginBottom: '10px', fontWeight: 500 }}>Parent</Text>
|
||||
{parent ? (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: '#e6f7ff',
|
||||
border: '1px solid #91d5ff',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#bae7ff';
|
||||
e.currentTarget.style.borderColor = '#69c0ff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#e6f7ff';
|
||||
e.currentTarget.style.borderColor = '#91d5ff';
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ color: '#1890ff', fontSize: '14px' }}>{parent.name}</Text>
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontStyle: 'italic', fontSize: '13px' }}>Root project (no parent)</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: '13px', display: 'block', marginBottom: '10px', fontWeight: 500 }}>Children ({children.length})</Text>
|
||||
{children.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{children.map(child => (
|
||||
<div
|
||||
key={child.id}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
background: '#f5f5f5',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#e6f7ff';
|
||||
e.currentTarget.style.borderColor = '#91d5ff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#f5f5f5';
|
||||
e.currentTarget.style.borderColor = '#d9d9d9';
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: '14px' }}>{child.name}</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontStyle: 'italic', fontSize: '13px' }}>No child projects</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider style={{ marginTop: '8px', marginBottom: '24px' }}>
|
||||
<Text strong style={{ fontSize: '15px' }}>Metadata</Text>
|
||||
</Divider>
|
||||
|
||||
<Form.Item
|
||||
name="attributes"
|
||||
label={<Text strong style={{ fontSize: '14px', marginBottom: '8px', display: 'block' }}>Attributes (JSON)</Text>}
|
||||
tooltip='Custom attributes in JSON format. Example: {"key": "value"}'
|
||||
style={{ marginBottom: '32px' }}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={6}
|
||||
placeholder='{"key": "value", "anotherKey": "anotherValue"}'
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '13px',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 16px'
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider style={{ marginTop: '8px', marginBottom: '24px' }} />
|
||||
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
background: '#fafafa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px', display: 'block', marginBottom: '8px', fontWeight: 500 }}>Project ID</Text>
|
||||
<Text code style={{ fontSize: '12px', wordBreak: 'break-all', padding: '4px 8px', background: '#fff', borderRadius: '4px' }}>{project.id}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: '12px', display: 'block', marginBottom: '8px', fontWeight: 500 }}>Path</Text>
|
||||
<Text code style={{ fontSize: '12px', wordBreak: 'break-all', padding: '4px 8px', background: '#fff', borderRadius: '4px' }}>{project.path}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectDetailPanel;
|
||||
|
||||
82
frontend/src/components/ProjectModal.tsx
Normal file
82
frontend/src/components/ProjectModal.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Modal, Form, Input, message } from 'antd';
|
||||
import { useProjectStore } from '../store/useProjectStore';
|
||||
|
||||
interface ProjectModalProps {
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
projectId?: string | null;
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
const ProjectModal: React.FC<ProjectModalProps> = ({ visible, onCancel, projectId, parentId }) => {
|
||||
const [form] = Form.useForm();
|
||||
const { projects, createProject, updateProject } = useProjectStore();
|
||||
const isEdit = !!projectId;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
if (isEdit) {
|
||||
const project = projects.find(p => p.id === projectId);
|
||||
if (project) {
|
||||
form.setFieldsValue({
|
||||
name: project.name,
|
||||
description: project.description || '',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}
|
||||
}, [visible, projectId, projects, form, isEdit]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (isEdit) {
|
||||
await updateProject(projectId!, values);
|
||||
message.success('Project updated successfully');
|
||||
} else {
|
||||
await createProject({
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
parentId: parentId || null,
|
||||
});
|
||||
message.success('Project created successfully');
|
||||
}
|
||||
form.resetFields();
|
||||
onCancel();
|
||||
} catch (error: any) {
|
||||
message.error(error.message || (isEdit ? 'Failed to update project' : 'Failed to create project'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isEdit ? 'Edit Project' : 'Create New Project'}
|
||||
open={visible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={onCancel}
|
||||
okText={isEdit ? 'Update' : 'Create'}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Project Name"
|
||||
rules={[{ required: true, message: 'Please enter project name' }]}
|
||||
>
|
||||
<Input placeholder="Enter project name" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="Description"
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="Enter project description (optional)" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectModal;
|
||||
|
||||
@@ -1,18 +1,86 @@
|
||||
import React from 'react';
|
||||
import { Tree } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { Tree, Button, Dropdown, message } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, MoreOutlined } from '@ant-design/icons';
|
||||
import { useProjectStore } from '../store/useProjectStore';
|
||||
import type { TreeDataNode } from 'antd';
|
||||
import ProjectModal from './ProjectModal';
|
||||
import ProjectDetailPanel from './ProjectDetailPanel';
|
||||
|
||||
const Sidebar: React.FC = () => {
|
||||
const { projects, expandedKeys, selectedKey, setExpandedKeys, setSelectedKey } = useProjectStore();
|
||||
const { projects, expandedKeys, selectedKey, setExpandedKeys, setSelectedKey, deleteProject } = useProjectStore();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
|
||||
const [parentIdForNew, setParentIdForNew] = useState<string | null>(null);
|
||||
const [showDetailPanel, setShowDetailPanel] = useState(false);
|
||||
|
||||
// Transform projects to AntD Tree DataNode format
|
||||
// This is a simplified transformation. In real app, you'd handle hierarchy properly.
|
||||
const treeData: TreeDataNode[] = projects.map((p) => ({
|
||||
title: p.name,
|
||||
key: p.id,
|
||||
isLeaf: false, // Assuming all can have children for now
|
||||
}));
|
||||
// Build hierarchy tree from flat list
|
||||
const buildTree = (items: typeof projects, parentId: string | null = null): TreeDataNode[] => {
|
||||
return items
|
||||
.filter(item => item.parentId === parentId)
|
||||
.map(item => ({
|
||||
title: (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||
<span>{item.name}</span>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'add',
|
||||
label: 'Add Child',
|
||||
icon: <PlusOutlined />,
|
||||
onClick: () => {
|
||||
setParentIdForNew(item.id);
|
||||
setEditingProjectId(null);
|
||||
setModalVisible(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Edit',
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => {
|
||||
setEditingProjectId(item.id);
|
||||
setParentIdForNew(null);
|
||||
setModalVisible(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => handleDelete(item.id),
|
||||
},
|
||||
],
|
||||
}}
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<MoreOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
),
|
||||
key: item.id,
|
||||
children: buildTree(items, item.id),
|
||||
}));
|
||||
};
|
||||
|
||||
const treeData = buildTree(projects);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteProject(id);
|
||||
message.success('Project deleted successfully');
|
||||
} catch (error: any) {
|
||||
message.error(error.message || 'Failed to delete project');
|
||||
}
|
||||
};
|
||||
|
||||
const onExpand = (newExpandedKeys: React.Key[]) => {
|
||||
setExpandedKeys(newExpandedKeys as string[]);
|
||||
@@ -20,18 +88,84 @@ const Sidebar: React.FC = () => {
|
||||
|
||||
const onSelect = (newSelectedKeys: React.Key[]) => {
|
||||
if (newSelectedKeys.length > 0) {
|
||||
setSelectedKey(newSelectedKeys[0] as string);
|
||||
const key = newSelectedKeys[0] as string;
|
||||
setSelectedKey(key);
|
||||
setShowDetailPanel(true);
|
||||
} else {
|
||||
setShowDetailPanel(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', borderRight: '1px solid #ddd', padding: '10px' }}>
|
||||
<Tree
|
||||
treeData={treeData}
|
||||
expandedKeys={expandedKeys}
|
||||
selectedKeys={selectedKey ? [selectedKey] : []}
|
||||
onExpand={onExpand}
|
||||
onSelect={onSelect}
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', borderRight: '1px solid #f0f0f0', background: '#fff' }}>
|
||||
{!showDetailPanel ? (
|
||||
<>
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
background: '#fafafa'
|
||||
}}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
block
|
||||
onClick={() => {
|
||||
setParentIdForNew(null);
|
||||
setEditingProjectId(null);
|
||||
setModalVisible(true);
|
||||
}}
|
||||
style={{
|
||||
height: '36px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
New Project
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}>
|
||||
{treeData.length > 0 ? (
|
||||
<Tree
|
||||
treeData={treeData}
|
||||
expandedKeys={expandedKeys}
|
||||
selectedKeys={selectedKey ? [selectedKey] : []}
|
||||
onExpand={onExpand}
|
||||
onSelect={onSelect}
|
||||
showLine={{ showLeafIcon: false }}
|
||||
defaultExpandAll={false}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px',
|
||||
color: '#999'
|
||||
}}>
|
||||
<p>No projects yet</p>
|
||||
<p style={{ fontSize: '12px', marginTop: '8px' }}>
|
||||
Click "New Project" to get started
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ProjectDetailPanel
|
||||
projectId={selectedKey}
|
||||
onClose={() => {
|
||||
setShowDetailPanel(false);
|
||||
setSelectedKey(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ProjectModal
|
||||
visible={modalVisible}
|
||||
onCancel={() => {
|
||||
setModalVisible(false);
|
||||
setEditingProjectId(null);
|
||||
setParentIdForNew(null);
|
||||
}}
|
||||
projectId={editingProjectId}
|
||||
parentId={parentIdForNew}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,68 +1,12 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
#root {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ interface Project {
|
||||
name: string;
|
||||
path: string;
|
||||
parentId: string | null;
|
||||
description?: string | null;
|
||||
attributes?: Record<string, any>;
|
||||
children?: Project[];
|
||||
}
|
||||
|
||||
@@ -17,6 +19,9 @@ interface ProjectState {
|
||||
setSelectedKey: (key: string | null) => void;
|
||||
expandNode: (key: string) => void;
|
||||
fetchProjects: () => Promise<void>;
|
||||
createProject: (data: { name: string; parentId?: string | null; description?: string }) => Promise<void>;
|
||||
updateProject: (id: string, data: { name?: string; description?: string; parentId?: string | null }) => Promise<void>;
|
||||
deleteProject: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const DUMMY_PROJECTS: Project[] = [
|
||||
@@ -42,10 +47,17 @@ export const useProjectStore = create<ProjectState>((set) => ({
|
||||
})),
|
||||
fetchProjects: async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3000/projects');
|
||||
// Use relative path when proxied through nginx, or environment variable
|
||||
const apiUrl = import.meta.env.VITE_API_URL || '/projects';
|
||||
const response = await fetch(apiUrl);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
set({ projects: data });
|
||||
// Map API response to frontend format (parent_id -> parentId)
|
||||
const mappedData = data.map((p: any) => ({
|
||||
...p,
|
||||
parentId: p.parent_id || null,
|
||||
}));
|
||||
set({ projects: mappedData });
|
||||
} else {
|
||||
console.error('Failed to fetch projects');
|
||||
set({ projects: DUMMY_PROJECTS });
|
||||
@@ -55,4 +67,73 @@ export const useProjectStore = create<ProjectState>((set) => ({
|
||||
set({ projects: DUMMY_PROJECTS });
|
||||
}
|
||||
},
|
||||
createProject: async (data) => {
|
||||
try {
|
||||
const apiUrl = import.meta.env.VITE_API_URL || '/projects';
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: data.name,
|
||||
description: data.description || null,
|
||||
parent_id: data.parentId || null,
|
||||
tenant_id: '00000000-0000-0000-0000-000000000001', // Default tenant for local dev
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
await useProjectStore.getState().fetchProjects();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Failed to create project');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
updateProject: async (id, data) => {
|
||||
try {
|
||||
const apiUrl = import.meta.env.VITE_API_URL || '/projects';
|
||||
const response = await fetch(`${apiUrl}/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...(data.name && { name: data.name }),
|
||||
...(data.description !== undefined && { description: data.description }),
|
||||
...(data.parentId !== undefined && { parent_id: data.parentId }),
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
await useProjectStore.getState().fetchProjects();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Failed to update project');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating project:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
deleteProject: async (id) => {
|
||||
try {
|
||||
const apiUrl = import.meta.env.VITE_API_URL || '/projects';
|
||||
const response = await fetch(`${apiUrl}/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (response.ok) {
|
||||
await useProjectStore.getState().fetchProjects();
|
||||
// Clear selection if deleted project was selected
|
||||
const state = useProjectStore.getState();
|
||||
if (state.selectedKey === id) {
|
||||
state.setSelectedKey(null);
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Failed to delete project');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting project:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user