From b9148cfa4be2cbbbff8670b2a6e990cc10d4e118 Mon Sep 17 00:00:00 2001 From: gitmuhammedalbayrak Date: Thu, 27 Nov 2025 03:18:48 +0300 Subject: [PATCH] feat: Add full CRUD functionality, project detail panel, and improved UI - 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 --- backend/src/app.module.ts | 2 +- backend/src/database/schema-local.sql | 118 +++++++ backend/src/main.ts | 7 + backend/src/projects/projects.controller.ts | 12 +- backend/src/projects/projects.service.ts | 77 ++++- deploy/local/README.md | 120 +++++-- deploy/local/TEST.md | 111 ++++++ deploy/local/docker-compose.yml | 34 +- frontend/nginx.conf | 27 ++ frontend/src/components/MindMap.tsx | 226 +++++++++++- .../src/components/ProjectDetailPanel.tsx | 326 ++++++++++++++++++ frontend/src/components/ProjectModal.tsx | 82 +++++ frontend/src/components/Sidebar.tsx | 170 ++++++++- frontend/src/index.css | 70 +--- frontend/src/store/useProjectStore.ts | 85 ++++- 15 files changed, 1306 insertions(+), 161 deletions(-) create mode 100644 backend/src/database/schema-local.sql create mode 100644 deploy/local/TEST.md create mode 100644 frontend/src/components/ProjectDetailPanel.tsx create mode 100644 frontend/src/components/ProjectModal.tsx diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index aae8718..2bd0ac0 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -17,7 +17,7 @@ import { Project } from './projects/project.entity'; password: process.env.DB_PASSWORD || 'postgres', database: process.env.DB_DATABASE || 'evrak', entities: [Tenant, Project], - synchronize: true, // Disable in production! + synchronize: false, // Schema is managed by SQL migration files }), TenantsModule, ProjectsModule, diff --git a/backend/src/database/schema-local.sql b/backend/src/database/schema-local.sql new file mode 100644 index 0000000..0b57466 --- /dev/null +++ b/backend/src/database/schema-local.sql @@ -0,0 +1,118 @@ +-- Local Development Schema (RLS Disabled for easier testing) +-- This is a simplified version for local development +-- Production should use schema.sql with RLS enabled + +-- Enable required extensions +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "ltree"; +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; + +-- ----------------------------------------------------------------------------- +-- Tenants Table +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS tenants ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- ----------------------------------------------------------------------------- +-- Users Table +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + full_name VARCHAR(255), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE (tenant_id, email) +); + +-- ----------------------------------------------------------------------------- +-- Projects Table (Hierarchical) +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS projects ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT, + + -- Hierarchy using ltree + path ltree NOT NULL, + parent_id UUID REFERENCES projects(id) ON DELETE CASCADE, + + -- Flexible metadata + attributes JSONB DEFAULT '{}'::jsonb, + + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- ----------------------------------------------------------------------------- +-- Indexes (Create only if they don't exist) +-- ----------------------------------------------------------------------------- +-- GIST index for fast ltree operations (ancestor/descendant checks) +CREATE INDEX IF NOT EXISTS projects_path_gist_idx ON projects USING GIST (path); + +-- B-Tree index for path to speed up exact matches and sorting +CREATE INDEX IF NOT EXISTS projects_path_btree_idx ON projects USING btree (path); + +-- GIN index for JSONB searching +CREATE INDEX IF NOT EXISTS projects_attributes_gin_idx ON projects USING GIN (attributes); + +-- Tenant ID indexes for performance +CREATE INDEX IF NOT EXISTS users_tenant_id_idx ON users (tenant_id); +CREATE INDEX IF NOT EXISTS projects_tenant_id_idx ON projects (tenant_id); + +-- ----------------------------------------------------------------------------- +-- Row Level Security (DISABLED for local development) +-- ----------------------------------------------------------------------------- +-- RLS is disabled for local development to simplify testing +-- Production deployments should use schema.sql which enables RLS +ALTER TABLE tenants DISABLE ROW LEVEL SECURITY; +ALTER TABLE users DISABLE ROW LEVEL SECURITY; +ALTER TABLE projects DISABLE ROW LEVEL SECURITY; + +-- ----------------------------------------------------------------------------- +-- Helper Function for updating paths (Move Subtree) +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION move_project_subtree( + p_project_id UUID, + p_new_parent_id UUID +) RETURNS VOID AS $$ +DECLARE + v_old_path ltree; + v_new_path ltree; + v_new_parent_path ltree; +BEGIN + -- Get the old path + SELECT path INTO v_old_path FROM projects WHERE id = p_project_id; + + -- Calculate new path + IF p_new_parent_id IS NULL THEN + v_new_path := text2ltree(replace(p_project_id::text, '-', '_')); -- Root node + ELSE + SELECT path INTO v_new_parent_path FROM projects WHERE id = p_new_parent_id; + v_new_path := v_new_parent_path || text2ltree(replace(p_project_id::text, '-', '_')); + END IF; + + -- Update the project and all descendants + UPDATE projects + SET path = v_new_path || subpath(path, nlevel(v_old_path)) + WHERE path <@ v_old_path; + + -- Update parent_id for the moved node specifically + UPDATE projects SET parent_id = p_new_parent_id WHERE id = p_project_id; +END; +$$ LANGUAGE plpgsql; + +-- ----------------------------------------------------------------------------- +-- Create a default tenant for local development +-- ----------------------------------------------------------------------------- +INSERT INTO tenants (id, name) +VALUES ('00000000-0000-0000-0000-000000000001', 'Default Local Tenant') +ON CONFLICT (id) DO NOTHING; + diff --git a/backend/src/main.ts b/backend/src/main.ts index f76bc8d..b5d829f 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -3,6 +3,13 @@ import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); + + // Enable CORS for frontend + app.enableCors({ + origin: process.env.FRONTEND_URL || 'http://localhost:80', + credentials: true, + }); + await app.listen(process.env.PORT ?? 3000); } bootstrap(); diff --git a/backend/src/projects/projects.controller.ts b/backend/src/projects/projects.controller.ts index f83c9be..0870451 100644 --- a/backend/src/projects/projects.controller.ts +++ b/backend/src/projects/projects.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Body, Param, Put } from '@nestjs/common'; +import { Controller, Get, Post, Body, Param, Put, Delete, Patch } from '@nestjs/common'; import { ProjectsService } from './projects.service'; import { Project } from './project.entity'; @@ -21,6 +21,16 @@ export class ProjectsController { return this.projectsService.findOne(id); } + @Patch(':id') + update(@Param('id') id: string, @Body() data: Partial): Promise { + return this.projectsService.update(id, data); + } + + @Delete(':id') + remove(@Param('id') id: string): Promise { + return this.projectsService.remove(id); + } + @Put(':id/move') move(@Param('id') id: string, @Body('newParentId') newParentId: string | null): Promise { return this.projectsService.moveSubtree(id, newParentId); diff --git a/backend/src/projects/projects.service.ts b/backend/src/projects/projects.service.ts index ceceb8d..a359b3c 100644 --- a/backend/src/projects/projects.service.ts +++ b/backend/src/projects/projects.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; +import { randomUUID } from 'crypto'; import { Project } from './project.entity'; @Injectable() @@ -12,16 +13,35 @@ export class ProjectsService { ) { } async create(data: Partial): Promise { - // Logic to calculate path based on parent_id would go here - // For now, assuming path is provided or calculated by caller/DB trigger if we had one - // But typically we calculate it in service: + // Generate UUID if not provided + const projectId = data.id || randomUUID(); + + // Calculate ltree path based on parent_id + let path: string; + if (!data.parent_id) { + // Root node: path is just the project ID (with dashes replaced by underscores for ltree) + path = projectId.replace(/-/g, '_'); + } else { + // Get parent's path and append this project's ID + const parent = await this.projectsRepository.findOne({ + where: { id: data.parent_id }, + select: ['path'], + }); + + if (!parent) { + throw new Error(`Parent project with id ${data.parent_id} not found`); + } + + // Append this project's ID to parent path + path = `${parent.path}.${projectId.replace(/-/g, '_')}`; + } - let path = data.id ? data.id.replace(/-/g, '_') : 'temp'; // ID is not generated yet... - // Actually, we need to save first to get ID, or generate UUID manually. - // Better to generate UUID in code if we need it for path. - - // Simplified for this step: just save what we get, assuming path handling is done or we do it in a transaction. - const project = this.projectsRepository.create(data); + const project = this.projectsRepository.create({ + ...data, + id: projectId, + path: path as any, // TypeORM will handle ltree conversion + }); + return this.projectsRepository.save(project); } @@ -33,6 +53,45 @@ export class ProjectsService { return this.projectsRepository.findOneBy({ id }); } + async update(id: string, data: Partial): Promise { + const project = await this.projectsRepository.findOneBy({ id }); + if (!project) { + throw new Error(`Project with id ${id} not found`); + } + + // If parent_id is being updated, recalculate path + if (data.parent_id !== undefined && data.parent_id !== project.parent_id) { + // Use the move function to handle path recalculation + await this.moveSubtree(id, data.parent_id); + // Remove parent_id from data since moveSubtree handles it + const { parent_id, ...updateData } = data; + Object.assign(project, updateData); + } else { + Object.assign(project, data); + } + + return this.projectsRepository.save(project); + } + + async remove(id: string): Promise { + const project = await this.projectsRepository.findOneBy({ id }); + if (!project) { + throw new Error(`Project with id ${id} not found`); + } + + // Check if project has children + const children = await this.projectsRepository + .createQueryBuilder('project') + .where('project.parent_id = :id', { id }) + .getCount(); + + if (children > 0) { + throw new Error('Cannot delete project with children. Please delete or move children first.'); + } + + await this.projectsRepository.remove(project); + } + async moveSubtree(projectId: string, newParentId: string | null): Promise { await this.dataSource.query( 'SELECT move_project_subtree($1, $2)', diff --git a/deploy/local/README.md b/deploy/local/README.md index 5c55718..b5ae426 100644 --- a/deploy/local/README.md +++ b/deploy/local/README.md @@ -1,52 +1,114 @@ -# Local Development with Docker Swarm +# Local Development with Docker Compose -This directory contains the configuration to run the Evrak application locally using Docker Swarm on Windows 11. +This directory contains the configuration to run the Evrak application locally using Docker Compose. ## Prerequisites -1. **Docker Desktop**: Ensure Docker Desktop is installed and running. -2. **Swarm Mode**: Enable Swarm mode if not already enabled: - ```powershell - docker swarm init - ``` +1. **Docker Desktop**: Ensure Docker Desktop is installed and running on your machine. +2. **Ports**: Make sure ports 80, 3000, and 5432 are available on your host machine. -## How to Deploy +## Quick Start -### 1. Build Images -First, build the Docker images locally. `docker stack deploy` does not build images, so this step is required. +### 1. Build and Start Services -```powershell -docker compose -f deploy/local/docker-compose.yml build +From the project root directory, run: + +```bash +docker compose -f deploy/local/docker-compose.yml up --build ``` -### 2. Deploy to Swarm -Deploy the stack to your local Swarm cluster. - +Or in PowerShell: ```powershell -docker stack deploy -c deploy/local/docker-compose.yml evrak +docker compose -f deploy/local/docker-compose.yml up --build ``` -### 3. Verify -Check if the services are running: +This will: +- Build the Docker images for backend and frontend +- Start PostgreSQL database +- Start the backend API server +- Start the frontend web server -```powershell -docker service ls -docker stack ps evrak -``` +### 2. Access the Application + +Once all services are running, access the application at: -Access the application: * **Frontend**: http://localhost * **Backend API**: http://localhost:3000 -* **Database**: localhost:5432 +* **Database**: localhost:5432 (user: `evrak_user`, password: `evrak_password`, database: `evrak`) -### 4. Remove Stack -To stop and remove the application: +### 3. Stop Services -```powershell -docker stack rm evrak +To stop all services: + +```bash +docker compose -f deploy/local/docker-compose.yml down +``` + +To stop and remove volumes (database data): + +```bash +docker compose -f deploy/local/docker-compose.yml down -v +``` + +## Development Workflow + +### Running in Detached Mode + +To run services in the background: + +```bash +docker compose -f deploy/local/docker-compose.yml up -d --build +``` + +### View Logs + +View logs from all services: + +```bash +docker compose -f deploy/local/docker-compose.yml logs -f +``` + +View logs from a specific service: + +```bash +docker compose -f deploy/local/docker-compose.yml logs -f backend +docker compose -f deploy/local/docker-compose.yml logs -f frontend +docker compose -f deploy/local/docker-compose.yml logs -f postgres +``` + +### Rebuild After Code Changes + +If you make changes to the code, rebuild the affected service: + +```bash +docker compose -f deploy/local/docker-compose.yml up --build backend +docker compose -f deploy/local/docker-compose.yml up --build frontend ``` ## Troubleshooting -* **Image not found**: Make sure you ran the build step. * **Ports occupied**: Ensure ports 80, 3000, and 5432 are free on your host machine. +* **Build errors**: Make sure all dependencies are properly installed. Try removing `node_modules` and rebuilding. +* **Database connection issues**: Wait a few seconds after starting services for the database to initialize. +* **CORS errors**: The backend is configured to allow requests from `http://localhost:80`. If you're accessing from a different port, update the `FRONTEND_URL` environment variable in `docker-compose.yml`. + +## Service Details + +- **PostgreSQL**: Database service with automatic health checks and schema initialization + - The database schema (including ltree extension, indexes, and helper functions) is automatically initialized on first startup + - Row Level Security (RLS) is **disabled** for local development to simplify testing + - A default tenant is created automatically: `00000000-0000-0000-0000-000000000001` +- **Backend**: NestJS API server running on port 3000 + - Supports hierarchical project structure using PostgreSQL ltree + - Multi-tenant architecture (RLS disabled for local dev) +- **Frontend**: React application served via Nginx on port 80, with API proxying configured + +## Database Schema + +The database is automatically initialized with: +- PostgreSQL extensions: `uuid-ossp`, `ltree`, `pg_trgm` +- Tables: `tenants`, `users`, `projects` +- Indexes: GIST for ltree paths, GIN for JSONB attributes +- Helper function: `move_project_subtree()` for moving project hierarchies + +**Note**: For production deployments, use `backend/src/database/schema.sql` which enables Row Level Security (RLS) for proper tenant isolation. diff --git a/deploy/local/TEST.md b/deploy/local/TEST.md new file mode 100644 index 0000000..4956e84 --- /dev/null +++ b/deploy/local/TEST.md @@ -0,0 +1,111 @@ +# Testing Instructions + +## Prerequisites + +1. **Start Docker Desktop** - Make sure Docker Desktop is running on your Windows machine +2. **Verify Docker is running**: + ```powershell + docker ps + ``` + This should not show an error. + +## Step 1: Start the Services + +From the project root directory: + +```powershell +docker compose -f deploy/local/docker-compose.yml up --build +``` + +Or to run in detached mode (background): + +```powershell +docker compose -f deploy/local/docker-compose.yml up --build -d +``` + +## Step 2: Verify Services are Running + +Check that all containers are running: + +```powershell +docker compose -f deploy/local/docker-compose.yml ps +``` + +You should see three services: +- `evrak-postgres` (healthy) +- `evrak-backend` (running) +- `evrak-frontend` (running) + +## Step 3: Check Logs + +View logs to ensure everything started correctly: + +```powershell +# All services +docker compose -f deploy/local/docker-compose.yml logs + +# Specific service +docker compose -f deploy/local/docker-compose.yml logs backend +docker compose -f deploy/local/docker-compose.yml logs postgres +``` + +## Step 4: Test the API + +### Test Backend Health +```powershell +curl http://localhost:3000 +``` + +### Create a Tenant +```powershell +curl -X POST http://localhost:3000/tenants -H "Content-Type: application/json" -d "{\"name\":\"Test Tenant\"}" +``` + +### Create a Project +```powershell +curl -X POST http://localhost:3000/projects -H "Content-Type: application/json" -d "{\"name\":\"Test Project\",\"tenant_id\":\"00000000-0000-0000-0000-000000000001\",\"path\":\"test_project\"}" +``` + +### Get All Projects +```powershell +curl http://localhost:3000/projects +``` + +## Step 5: Access the Frontend + +Open your browser and navigate to: +- **Frontend**: http://localhost +- **Backend API**: http://localhost:3000 + +## Step 6: Stop Services + +When done testing: + +```powershell +docker compose -f deploy/local/docker-compose.yml down +``` + +To also remove volumes (database data): + +```powershell +docker compose -f deploy/local/docker-compose.yml down -v +``` + +## Troubleshooting + +### Docker Desktop Not Running +- Start Docker Desktop from Windows Start Menu +- Wait for it to fully start (whale icon in system tray should be steady) + +### Port Already in Use +- Check if ports 80, 3000, or 5432 are already in use +- Stop conflicting services or change ports in docker-compose.yml + +### Database Connection Issues +- Wait a few seconds after starting for database to initialize +- Check postgres logs: `docker compose -f deploy/local/docker-compose.yml logs postgres` + +### Build Errors +- Make sure you're in the project root directory +- Try: `docker compose -f deploy/local/docker-compose.yml build --no-cache` + diff --git a/deploy/local/docker-compose.yml b/deploy/local/docker-compose.yml index 968f222..eb1f1c2 100644 --- a/deploy/local/docker-compose.yml +++ b/deploy/local/docker-compose.yml @@ -1,8 +1,7 @@ -version: '3.8' - services: postgres: image: postgres:14-alpine + container_name: evrak-postgres environment: POSTGRES_USER: evrak_user POSTGRES_PASSWORD: evrak_password @@ -11,19 +10,22 @@ services: - "5432:5432" volumes: - db_data:/var/lib/postgresql/data + - ../../backend/src/database/schema-local.sql:/docker-entrypoint-initdb.d/01-schema.sql networks: - evrak-net - deploy: - replicas: 1 - restart_policy: - condition: on-failure + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U evrak_user -d evrak"] + interval: 10s + timeout: 5s + retries: 5 backend: - # Note: 'docker stack deploy' ignores 'build'. You must run 'docker compose build' first. build: context: ../../backend dockerfile: Dockerfile image: evrak-backend:local + container_name: evrak-backend environment: DB_HOST: postgres DB_PORT: 5432 @@ -34,35 +36,29 @@ services: ports: - "3000:3000" depends_on: - - postgres + postgres: + condition: service_healthy networks: - evrak-net - deploy: - replicas: 1 - restart_policy: - condition: on-failure + restart: unless-stopped frontend: build: context: ../../frontend dockerfile: Dockerfile image: evrak-frontend:local + container_name: evrak-frontend ports: - "80:80" depends_on: - backend networks: - evrak-net - deploy: - replicas: 1 - restart_policy: - condition: on-failure + restart: unless-stopped volumes: db_data: - networks: evrak-net: - driver: overlay - attachable: true + driver: bridge diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 8d2766e..50f9a43 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; diff --git a/frontend/src/components/MindMap.tsx b/frontend/src/components/MindMap.tsx index b4577b8..e5ddaa2 100644 --- a/frontend/src/components/MindMap.tsx +++ b/frontend/src/components/MindMap.tsx @@ -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(null); + const [modalVisible, setModalVisible] = useState(false); + const [nodes, setNodes] = useState([]); + const [edges, setEdges] = useState([]); + const nodePositionsRef = useRef>(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(); + 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(); + const visited = new Set(); + + 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: ( +
+ {project.name} +
+ ), + }, + 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 ( -
- - - - -
+ <> +
+ + + + +
+ { + setModalVisible(false); + setEditingProjectId(null); + }} + projectId={editingProjectId} + /> + + ); +}; + +const MindMap: React.FC = () => { + return ( + + + ); }; diff --git a/frontend/src/components/ProjectDetailPanel.tsx b/frontend/src/components/ProjectDetailPanel.tsx new file mode 100644 index 0000000..409afab --- /dev/null +++ b/frontend/src/components/ProjectDetailPanel.tsx @@ -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 = ({ 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 ( +
+
+

No project selected

+

Select a project from the tree to view details

+
+
+ ); + } + + const children = projects.filter(p => p.parentId === project.id); + const parent = project.parentId ? projects.find(p => p.id === project.parentId) : null; + + return ( +
+ {/* Header */} +
+
+
+ {onClose && ( +
+ +
+ {project && ( +
+ Current Project + {project.name} +
+ )} +
+ +
+
+ + {/* Content */} +
+ +
+ Project Name} + rules={[{ required: true, message: 'Please enter project name' }]} + style={{ marginBottom: '18px' }} + > + + + + Description} + style={{ marginBottom: '20px' }} + > + + + + + Hierarchy + + +
+
+ Parent + {parent ? ( +
{ + e.currentTarget.style.background = '#bae7ff'; + e.currentTarget.style.borderColor = '#69c0ff'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = '#e6f7ff'; + e.currentTarget.style.borderColor = '#91d5ff'; + }} + > + {parent.name} +
+ ) : ( + Root project (no parent) + )} +
+ +
+ Children ({children.length}) + {children.length > 0 ? ( +
+ {children.map(child => ( +
{ + e.currentTarget.style.background = '#e6f7ff'; + e.currentTarget.style.borderColor = '#91d5ff'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = '#f5f5f5'; + e.currentTarget.style.borderColor = '#d9d9d9'; + }} + > + {child.name} +
+ ))} +
+ ) : ( + No child projects + )} +
+
+ + + Metadata + + + Attributes (JSON)} + tooltip='Custom attributes in JSON format. Example: {"key": "value"}' + style={{ marginBottom: '32px' }} + > + + + + + +
+
+ Project ID + {project.id} +
+
+ Path + {project.path} +
+
+ +
+
+
+ ); +}; + +export default ProjectDetailPanel; + diff --git a/frontend/src/components/ProjectModal.tsx b/frontend/src/components/ProjectModal.tsx new file mode 100644 index 0000000..09f3254 --- /dev/null +++ b/frontend/src/components/ProjectModal.tsx @@ -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 = ({ 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 ( + +
+ + + + + + +
+
+ ); +}; + +export default ProjectModal; + diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 41c6726..a1b1c54 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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(null); + const [parentIdForNew, setParentIdForNew] = useState(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: ( +
+ {item.name} + , + onClick: () => { + setParentIdForNew(item.id); + setEditingProjectId(null); + setModalVisible(true); + }, + }, + { + key: 'edit', + label: 'Edit', + icon: , + onClick: () => { + setEditingProjectId(item.id); + setParentIdForNew(null); + setModalVisible(true); + }, + }, + { + key: 'delete', + label: 'Delete', + icon: , + danger: true, + onClick: () => handleDelete(item.id), + }, + ], + }} + trigger={['click']} + > +
+ ), + 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 ( -
- + {!showDetailPanel ? ( + <> +
+ +
+
+ {treeData.length > 0 ? ( + + ) : ( +
+

No projects yet

+

+ Click "New Project" to get started +

+
+ )} +
+ + ) : ( + { + setShowDetailPanel(false); + setSelectedKey(null); + }} + /> + )} + { + setModalVisible(false); + setEditingProjectId(null); + setParentIdForNew(null); + }} + projectId={editingProjectId} + parentId={parentIdForNew} />
); diff --git a/frontend/src/index.css b/frontend/src/index.css index 08a3ac9..68c8629 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; } diff --git a/frontend/src/store/useProjectStore.ts b/frontend/src/store/useProjectStore.ts index 2ec2365..72bd8e4 100644 --- a/frontend/src/store/useProjectStore.ts +++ b/frontend/src/store/useProjectStore.ts @@ -5,6 +5,8 @@ interface Project { name: string; path: string; parentId: string | null; + description?: string | null; + attributes?: Record; children?: Project[]; } @@ -17,6 +19,9 @@ interface ProjectState { setSelectedKey: (key: string | null) => void; expandNode: (key: string) => void; fetchProjects: () => Promise; + createProject: (data: { name: string; parentId?: string | null; description?: string }) => Promise; + updateProject: (id: string, data: { name?: string; description?: string; parentId?: string | null }) => Promise; + deleteProject: (id: string) => Promise; } const DUMMY_PROJECTS: Project[] = [ @@ -42,10 +47,17 @@ export const useProjectStore = create((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((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; + } + }, }));