feat: Add full CRUD functionality, project detail panel, and improved UI
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:
gitmuhammedalbayrak
2025-11-27 03:18:48 +03:00
parent 066c16221d
commit b9148cfa4b
15 changed files with 1306 additions and 161 deletions

View File

@@ -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>
);
};

View 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;

View 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;

View File

@@ -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>
);

View File

@@ -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;
}

View File

@@ -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;
}
},
}));