diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..adeefa7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +# Backend +backend/__pycache__ +backend/*.pyc +backend/.pytest_cache +backend/.venv +backend/venv +backend/*.db +backend/.env + +# Frontend +frontend/node_modules +frontend/dist +frontend/.env + +# Git +.git +.gitignore + +# IDE +.vscode +.idea +*.swp +*.swo + +# Misc +README.md +docker-compose.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e565dfb --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +ENV/ +*.egg-info/ +.pytest_cache/ + +# Database +*.db +*.sqlite +*.sqlite3 + +# Node +node_modules/ +dist/ +build/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Docker +docker-compose.override.yml diff --git a/README.md b/README.md index e69de29..8907cf2 100644 Binary files a/README.md and b/README.md differ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..594cc9c --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY ./app ./app + +# Expose port +EXPOSE 8000 + +# Run the application +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/crud.py b/backend/app/crud.py new file mode 100644 index 0000000..db0f379 --- /dev/null +++ b/backend/app/crud.py @@ -0,0 +1,125 @@ +from sqlalchemy.orm import Session, joinedload +from typing import List, Optional +from . import models, schemas + + +# Project CRUD +def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project: + db_project = models.Project(**project.model_dump()) + db.add(db_project) + db.commit() + db.refresh(db_project) + return db_project + + +def get_project(db: Session, project_id: int) -> Optional[models.Project]: + return db.query(models.Project).filter(models.Project.id == project_id).first() + + +def get_projects(db: Session, skip: int = 0, limit: int = 100) -> List[models.Project]: + return db.query(models.Project).offset(skip).limit(limit).all() + + +def update_project( + db: Session, project_id: int, project: schemas.ProjectUpdate +) -> Optional[models.Project]: + db_project = get_project(db, project_id) + if not db_project: + return None + + update_data = project.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(db_project, key, value) + + db.commit() + db.refresh(db_project) + return db_project + + +def delete_project(db: Session, project_id: int) -> bool: + db_project = get_project(db, project_id) + if not db_project: + return False + db.delete(db_project) + db.commit() + return True + + +# Task CRUD +def create_task(db: Session, task: schemas.TaskCreate) -> models.Task: + # Get max sort_order for siblings + if task.parent_task_id: + max_order = db.query(models.Task).filter( + models.Task.parent_task_id == task.parent_task_id + ).count() + else: + max_order = db.query(models.Task).filter( + models.Task.project_id == task.project_id, + models.Task.parent_task_id.is_(None) + ).count() + + task_data = task.model_dump() + if "sort_order" not in task_data or task_data["sort_order"] == 0: + task_data["sort_order"] = max_order + + db_task = models.Task(**task_data) + db.add(db_task) + db.commit() + db.refresh(db_task) + return db_task + + +def get_task(db: Session, task_id: int) -> Optional[models.Task]: + return db.query(models.Task).filter(models.Task.id == task_id).first() + + +def get_tasks_by_project(db: Session, project_id: int) -> List[models.Task]: + return db.query(models.Task).filter(models.Task.project_id == project_id).all() + + +def get_root_tasks(db: Session, project_id: int) -> List[models.Task]: + """Get all root-level tasks (no parent) for a project""" + return db.query(models.Task).filter( + models.Task.project_id == project_id, + models.Task.parent_task_id.is_(None) + ).order_by(models.Task.sort_order).all() + + +def get_task_with_subtasks(db: Session, task_id: int) -> Optional[models.Task]: + """Recursively load a task with all its subtasks""" + return db.query(models.Task).options( + joinedload(models.Task.subtasks) + ).filter(models.Task.id == task_id).first() + + +def update_task( + db: Session, task_id: int, task: schemas.TaskUpdate +) -> Optional[models.Task]: + db_task = get_task(db, task_id) + if not db_task: + return None + + update_data = task.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(db_task, key, value) + + db.commit() + db.refresh(db_task) + return db_task + + +def delete_task(db: Session, task_id: int) -> bool: + db_task = get_task(db, task_id) + if not db_task: + return False + db.delete(db_task) + db.commit() + return True + + +def get_tasks_by_status(db: Session, project_id: int, status: models.TaskStatus) -> List[models.Task]: + """Get all tasks for a project with a specific status""" + return db.query(models.Task).filter( + models.Task.project_id == project_id, + models.Task.status == status + ).all() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..20a85c7 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,20 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +SQLALCHEMY_DATABASE_URL = "sqlite:///./tesseract.db" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..4a8c803 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,235 @@ +from fastapi import FastAPI, Depends, HTTPException, UploadFile, File +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy.orm import Session +from typing import List +import json + +from . import models, schemas, crud +from .database import engine, get_db + +# Create database tables +models.Base.metadata.create_all(bind=engine) + +app = FastAPI( + title="Tesseract - Nested Todo Tree API", + description="API for managing deeply nested todo trees", + version="1.0.0" +) + +# CORS middleware for frontend +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173", "http://localhost:3000"], # Vite default port + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# ========== PROJECT ENDPOINTS ========== + +@app.get("/api/projects", response_model=List[schemas.Project]) +def list_projects(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + """List all projects""" + return crud.get_projects(db, skip=skip, limit=limit) + + +@app.post("/api/projects", response_model=schemas.Project, status_code=201) +def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db)): + """Create a new project""" + return crud.create_project(db, project) + + +@app.get("/api/projects/{project_id}", response_model=schemas.Project) +def get_project(project_id: int, db: Session = Depends(get_db)): + """Get a specific project""" + db_project = crud.get_project(db, project_id) + if not db_project: + raise HTTPException(status_code=404, detail="Project not found") + return db_project + + +@app.put("/api/projects/{project_id}", response_model=schemas.Project) +def update_project( + project_id: int, project: schemas.ProjectUpdate, db: Session = Depends(get_db) +): + """Update a project""" + db_project = crud.update_project(db, project_id, project) + if not db_project: + raise HTTPException(status_code=404, detail="Project not found") + return db_project + + +@app.delete("/api/projects/{project_id}", status_code=204) +def delete_project(project_id: int, db: Session = Depends(get_db)): + """Delete a project and all its tasks""" + if not crud.delete_project(db, project_id): + raise HTTPException(status_code=404, detail="Project not found") + return None + + +# ========== TASK ENDPOINTS ========== + +@app.get("/api/projects/{project_id}/tasks", response_model=List[schemas.Task]) +def list_project_tasks(project_id: int, db: Session = Depends(get_db)): + """List all tasks for a project""" + if not crud.get_project(db, project_id): + raise HTTPException(status_code=404, detail="Project not found") + return crud.get_tasks_by_project(db, project_id) + + +@app.get("/api/projects/{project_id}/tasks/tree", response_model=List[schemas.TaskWithSubtasks]) +def get_project_task_tree(project_id: int, db: Session = Depends(get_db)): + """Get the task tree (root tasks with nested subtasks) for a project""" + if not crud.get_project(db, project_id): + raise HTTPException(status_code=404, detail="Project not found") + + root_tasks = crud.get_root_tasks(db, project_id) + + def build_tree(task): + task_dict = schemas.TaskWithSubtasks.model_validate(task) + task_dict.subtasks = [build_tree(subtask) for subtask in task.subtasks] + return task_dict + + return [build_tree(task) for task in root_tasks] + + +@app.get("/api/projects/{project_id}/tasks/by-status/{status}", response_model=List[schemas.Task]) +def get_tasks_by_status( + project_id: int, + status: models.TaskStatus, + db: Session = Depends(get_db) +): + """Get all tasks for a project filtered by status (for Kanban view)""" + if not crud.get_project(db, project_id): + raise HTTPException(status_code=404, detail="Project not found") + return crud.get_tasks_by_status(db, project_id, status) + + +@app.post("/api/tasks", response_model=schemas.Task, status_code=201) +def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)): + """Create a new task""" + if not crud.get_project(db, task.project_id): + raise HTTPException(status_code=404, detail="Project not found") + + if task.parent_task_id and not crud.get_task(db, task.parent_task_id): + raise HTTPException(status_code=404, detail="Parent task not found") + + return crud.create_task(db, task) + + +@app.get("/api/tasks/{task_id}", response_model=schemas.Task) +def get_task(task_id: int, db: Session = Depends(get_db)): + """Get a specific task""" + db_task = crud.get_task(db, task_id) + if not db_task: + raise HTTPException(status_code=404, detail="Task not found") + return db_task + + +@app.put("/api/tasks/{task_id}", response_model=schemas.Task) +def update_task(task_id: int, task: schemas.TaskUpdate, db: Session = Depends(get_db)): + """Update a task""" + db_task = crud.update_task(db, task_id, task) + if not db_task: + raise HTTPException(status_code=404, detail="Task not found") + return db_task + + +@app.delete("/api/tasks/{task_id}", status_code=204) +def delete_task(task_id: int, db: Session = Depends(get_db)): + """Delete a task and all its subtasks""" + if not crud.delete_task(db, task_id): + raise HTTPException(status_code=404, detail="Task not found") + return None + + +# ========== JSON IMPORT ENDPOINT ========== + +def _import_tasks_recursive( + db: Session, + project_id: int, + tasks: List[schemas.ImportSubtask], + parent_id: Optional[int] = None, + count: int = 0 +) -> int: + """Recursively import tasks and their subtasks""" + for idx, task_data in enumerate(tasks): + task = schemas.TaskCreate( + project_id=project_id, + parent_task_id=parent_id, + title=task_data.title, + description=task_data.description, + status=task_data.status, + sort_order=idx + ) + db_task = crud.create_task(db, task) + count += 1 + + if task_data.subtasks: + count = _import_tasks_recursive( + db, project_id, task_data.subtasks, db_task.id, count + ) + + return count + + +@app.post("/api/import-json", response_model=schemas.ImportResult) +def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_db)): + """ + Import a project with nested tasks from JSON. + + Expected format: + { + "project": { + "name": "Project Name", + "description": "Optional description" + }, + "tasks": [ + { + "title": "Task 1", + "description": "Optional", + "status": "backlog", + "subtasks": [ + { + "title": "Subtask 1.1", + "status": "backlog", + "subtasks": [] + } + ] + } + ] + } + """ + # Create the project + project = crud.create_project( + db, + schemas.ProjectCreate( + name=import_data.project.name, + description=import_data.project.description + ) + ) + + # Recursively import tasks + tasks_created = _import_tasks_recursive( + db, project.id, import_data.tasks + ) + + return schemas.ImportResult( + project_id=project.id, + project_name=project.name, + tasks_created=tasks_created + ) + + +@app.get("/") +def root(): + """API health check""" + return { + "status": "online", + "message": "Tesseract API - Nested Todo Tree Manager", + "docs": "/docs" + } + + +from typing import Optional diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..350b875 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,41 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum +from sqlalchemy.orm import relationship +from datetime import datetime +import enum +from .database import Base + + +class TaskStatus(str, enum.Enum): + BACKLOG = "backlog" + IN_PROGRESS = "in_progress" + BLOCKED = "blocked" + DONE = "done" + + +class Project(Base): + __tablename__ = "projects" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan") + + +class Task(Base): + __tablename__ = "tasks" + + id = Column(Integer, primary_key=True, index=True) + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) + parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True) + title = Column(String(500), nullable=False) + description = Column(Text, nullable=True) + status = Column(Enum(TaskStatus), default=TaskStatus.BACKLOG, nullable=False) + sort_order = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + project = relationship("Project", back_populates="tasks") + parent = relationship("Task", remote_side=[id], backref="subtasks") diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..f04e3d2 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,93 @@ +from pydantic import BaseModel, ConfigDict +from typing import Optional, List +from datetime import datetime +from .models import TaskStatus + + +# Task Schemas +class TaskBase(BaseModel): + title: str + description: Optional[str] = None + status: TaskStatus = TaskStatus.BACKLOG + parent_task_id: Optional[int] = None + sort_order: int = 0 + + +class TaskCreate(TaskBase): + project_id: int + + +class TaskUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + status: Optional[TaskStatus] = None + parent_task_id: Optional[int] = None + sort_order: Optional[int] = None + + +class Task(TaskBase): + id: int + project_id: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class TaskWithSubtasks(Task): + subtasks: List['TaskWithSubtasks'] = [] + + model_config = ConfigDict(from_attributes=True) + + +# Project Schemas +class ProjectBase(BaseModel): + name: str + description: Optional[str] = None + + +class ProjectCreate(ProjectBase): + pass + + +class ProjectUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + + +class Project(ProjectBase): + id: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class ProjectWithTasks(Project): + tasks: List[Task] = [] + + model_config = ConfigDict(from_attributes=True) + + +# JSON Import Schemas +class ImportSubtask(BaseModel): + title: str + description: Optional[str] = None + status: TaskStatus = TaskStatus.BACKLOG + subtasks: List['ImportSubtask'] = [] + + +class ImportProject(BaseModel): + name: str + description: Optional[str] = None + + +class ImportData(BaseModel): + project: ImportProject + tasks: List[ImportSubtask] = [] + + +class ImportResult(BaseModel): + project_id: int + project_name: str + tasks_created: int diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..9136dd7 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +sqlalchemy==2.0.25 +pydantic==2.5.3 +pydantic-settings==2.1.0 +python-multipart==0.0.6 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6c081c6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +version: '3.8' + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: tesseract-backend + ports: + - "8000:8000" + volumes: + - ./backend/app:/app/app + - tesseract-db:/app + environment: + - PYTHONUNBUFFERED=1 + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: tesseract-frontend + ports: + - "3000:80" + depends_on: + - backend + restart: unless-stopped + +volumes: + tesseract-db: diff --git a/example-import.json b/example-import.json new file mode 100644 index 0000000..22b1046 --- /dev/null +++ b/example-import.json @@ -0,0 +1,113 @@ +{ + "project": { + "name": "Project Lyra", + "description": "Long-running AI assistant project with modular architecture" + }, + "tasks": [ + { + "title": "Cortex Rewire", + "description": "Refactor reasoning layer for improved performance", + "status": "backlog", + "subtasks": [ + { + "title": "Reflection → fix backend argument bug", + "status": "in_progress", + "subtasks": [ + { + "title": "Normalize LLM backend arg in reflection calls", + "status": "in_progress" + }, + { + "title": "Add unit tests for reflection module", + "status": "backlog" + } + ] + }, + { + "title": "Reasoning parser cleanup", + "status": "backlog", + "subtasks": [ + { + "title": "Remove deprecated parse methods", + "status": "backlog" + }, + { + "title": "Optimize regex patterns", + "status": "backlog" + } + ] + } + ] + }, + { + "title": "Frontend Overhaul", + "description": "Modernize the UI with new component library", + "status": "backlog", + "subtasks": [ + { + "title": "Migrate to Tailwind CSS", + "status": "backlog" + }, + { + "title": "Build new component library", + "status": "backlog", + "subtasks": [ + { + "title": "Button components", + "status": "backlog" + }, + { + "title": "Form components", + "status": "backlog" + }, + { + "title": "Modal components", + "status": "backlog" + } + ] + }, + { + "title": "Implement dark mode toggle", + "status": "backlog" + } + ] + }, + { + "title": "API v2 Implementation", + "status": "blocked", + "description": "Blocked on database migration completion", + "subtasks": [ + { + "title": "Design new REST endpoints", + "status": "done" + }, + { + "title": "Implement GraphQL layer", + "status": "blocked" + }, + { + "title": "Add rate limiting", + "status": "backlog" + } + ] + }, + { + "title": "Documentation Sprint", + "status": "done", + "subtasks": [ + { + "title": "API documentation", + "status": "done" + }, + { + "title": "User guide", + "status": "done" + }, + { + "title": "Developer setup guide", + "status": "done" + } + ] + } + ] +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..b6d284b --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,26 @@ +FROM node:18-alpine AS builder + +WORKDIR /app + +# Install dependencies +COPY package.json ./ +RUN npm install + +# Copy source code +COPY . . + +# Build the app +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built files to nginx +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..2418f35 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Tesseract - Task Decomposition Engine + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..5874ed5 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,24 @@ +server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # Serve static files + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to backend + location /api { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..741b44d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "tesseract-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.1", + "lucide-react": "^0.303.0" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "vite": "^5.0.8" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..5533d68 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,27 @@ +import { Routes, Route } from 'react-router-dom' +import ProjectList from './pages/ProjectList' +import ProjectView from './pages/ProjectView' + +function App() { + return ( +
+
+
+

+ TESSERACT + Task Decomposition Engine +

+
+
+ +
+ + } /> + } /> + +
+
+ ) +} + +export default App diff --git a/frontend/src/components/KanbanView.jsx b/frontend/src/components/KanbanView.jsx new file mode 100644 index 0000000..e8d7214 --- /dev/null +++ b/frontend/src/components/KanbanView.jsx @@ -0,0 +1,290 @@ +import { useState, useEffect } from 'react' +import { Plus, Edit2, Trash2, Check, X, ChevronDown, ChevronRight } from 'lucide-react' +import { + getProjectTasks, + createTask, + updateTask, + deleteTask +} from '../utils/api' + +const STATUSES = [ + { key: 'backlog', label: 'Backlog', color: 'border-gray-600' }, + { key: 'in_progress', label: 'In Progress', color: 'border-blue-500' }, + { key: 'blocked', label: 'Blocked', color: 'border-red-500' }, + { key: 'done', label: 'Done', color: 'border-green-500' } +] + +function TaskCard({ task, allTasks, onUpdate, onDragStart }) { + const [isEditing, setIsEditing] = useState(false) + const [editTitle, setEditTitle] = useState(task.title) + const [showSubtasks, setShowSubtasks] = useState(false) + + const subtasks = allTasks.filter(t => t.parent_task_id === task.id) + const hasSubtasks = subtasks.length > 0 + + const handleSave = async () => { + try { + await updateTask(task.id, { title: editTitle }) + setIsEditing(false) + onUpdate() + } catch (err) { + alert(`Error: ${err.message}`) + } + } + + const handleDelete = async () => { + if (!confirm('Delete this task and all its subtasks?')) return + try { + await deleteTask(task.id) + onUpdate() + } catch (err) { + alert(`Error: ${err.message}`) + } + } + + return ( +
onDragStart(e, task)} + className="bg-cyber-darkest border border-cyber-orange/30 rounded-lg p-3 mb-2 cursor-move hover:border-cyber-orange/60 transition-all group" + > + {isEditing ? ( +
+ setEditTitle(e.target.value)} + className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" + autoFocus + /> + + +
+ ) : ( + <> +
+ {task.title} +
+ + +
+
+ + {hasSubtasks && ( +
+ + {showSubtasks && ( +
+ {subtasks.map(subtask => ( +
+ • {subtask.title} +
+ ))} +
+ )} +
+ )} + + )} +
+ ) +} + +function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, onDragOver }) { + const [showAddTask, setShowAddTask] = useState(false) + const [newTaskTitle, setNewTaskTitle] = useState('') + + const handleAddTask = async (e) => { + e.preventDefault() + if (!newTaskTitle.trim()) return + + try { + await createTask({ + project_id: parseInt(projectId), + parent_task_id: null, + title: newTaskTitle, + status: status.key + }) + setNewTaskTitle('') + setShowAddTask(false) + onUpdate() + } catch (err) { + alert(`Error: ${err.message}`) + } + } + + return ( +
onDrop(e, status.key)} + onDragOver={onDragOver} + > +
+

+ {status.label} + ({tasks.length}) +

+ +
+ + {showAddTask && ( +
+
+ setNewTaskTitle(e.target.value)} + placeholder="Task title..." + className="w-full px-2 py-2 bg-cyber-darkest border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange mb-2" + autoFocus + /> +
+ + +
+
+
+ )} + +
+ {tasks.map(task => ( + { + e.dataTransfer.setData('taskId', task.id.toString()) + }} + /> + ))} +
+
+ ) +} + +function KanbanView({ projectId }) { + const [allTasks, setAllTasks] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + useEffect(() => { + loadTasks() + }, [projectId]) + + const loadTasks = async () => { + try { + setLoading(true) + const data = await getProjectTasks(projectId) + setAllTasks(data) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + const handleDragOver = (e) => { + e.preventDefault() + } + + const handleDrop = async (e, newStatus) => { + e.preventDefault() + const taskId = parseInt(e.dataTransfer.getData('taskId')) + + if (!taskId) return + + try { + await updateTask(taskId, { status: newStatus }) + loadTasks() + } catch (err) { + alert(`Error: ${err.message}`) + } + } + + if (loading) { + return
Loading tasks...
+ } + + if (error) { + return
{error}
+ } + + // Only show root-level tasks in Kanban (tasks without parents) + const rootTasks = allTasks.filter(t => !t.parent_task_id) + + return ( +
+

Kanban Board

+ +
+ {STATUSES.map(status => ( + t.status === status.key)} + allTasks={allTasks} + projectId={projectId} + onUpdate={loadTasks} + onDrop={handleDrop} + onDragOver={handleDragOver} + /> + ))} +
+ + {rootTasks.length === 0 && ( +
+

No tasks yet

+

Add tasks using the + button in any column

+
+ )} +
+ ) +} + +export default KanbanView diff --git a/frontend/src/components/TreeView.jsx b/frontend/src/components/TreeView.jsx new file mode 100644 index 0000000..bb53f7b --- /dev/null +++ b/frontend/src/components/TreeView.jsx @@ -0,0 +1,336 @@ +import { useState, useEffect } from 'react' +import { + ChevronDown, + ChevronRight, + Plus, + Edit2, + Trash2, + Check, + X +} from 'lucide-react' +import { + getProjectTaskTree, + createTask, + updateTask, + deleteTask +} from '../utils/api' + +const STATUS_COLORS = { + backlog: 'text-gray-400', + in_progress: 'text-blue-400', + blocked: 'text-red-400', + done: 'text-green-400' +} + +const STATUS_LABELS = { + backlog: 'Backlog', + in_progress: 'In Progress', + blocked: 'Blocked', + done: 'Done' +} + +function TaskNode({ task, projectId, onUpdate, level = 0 }) { + const [isExpanded, setIsExpanded] = useState(true) + const [isEditing, setIsEditing] = useState(false) + const [editTitle, setEditTitle] = useState(task.title) + const [editStatus, setEditStatus] = useState(task.status) + const [showAddSubtask, setShowAddSubtask] = useState(false) + const [newSubtaskTitle, setNewSubtaskTitle] = useState('') + + const hasSubtasks = task.subtasks && task.subtasks.length > 0 + + const handleSave = async () => { + try { + await updateTask(task.id, { + title: editTitle, + status: editStatus + }) + setIsEditing(false) + onUpdate() + } catch (err) { + alert(`Error: ${err.message}`) + } + } + + const handleDelete = async () => { + if (!confirm('Delete this task and all its subtasks?')) return + try { + await deleteTask(task.id) + onUpdate() + } catch (err) { + alert(`Error: ${err.message}`) + } + } + + const handleAddSubtask = async (e) => { + e.preventDefault() + if (!newSubtaskTitle.trim()) return + + try { + await createTask({ + project_id: parseInt(projectId), + parent_task_id: task.id, + title: newSubtaskTitle, + status: 'backlog' + }) + setNewSubtaskTitle('') + setShowAddSubtask(false) + setIsExpanded(true) + onUpdate() + } catch (err) { + alert(`Error: ${err.message}`) + } + } + + return ( +
+
0 ? 'ml-6' : '' + }`} + > + {/* Expand/Collapse */} + {hasSubtasks && ( + + )} + {!hasSubtasks &&
} + + {/* Task Content */} + {isEditing ? ( +
+ setEditTitle(e.target.value)} + className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" + autoFocus + /> + + + +
+ ) : ( + <> +
+ {task.title} + + {STATUS_LABELS[task.status]} + +
+ + {/* Actions */} +
+ + + +
+ + )} +
+ + {/* Add Subtask Form */} + {showAddSubtask && ( +
0 ? 'ml-6' : ''}`}> +
+ setNewSubtaskTitle(e.target.value)} + placeholder="New subtask title..." + className="flex-1 px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" + autoFocus + /> + + +
+
+ )} + + {/* Subtasks */} + {isExpanded && hasSubtasks && ( +
+ {task.subtasks.map(subtask => ( + + ))} +
+ )} +
+ ) +} + +function TreeView({ projectId }) { + const [tasks, setTasks] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [showAddRoot, setShowAddRoot] = useState(false) + const [newTaskTitle, setNewTaskTitle] = useState('') + + useEffect(() => { + loadTasks() + }, [projectId]) + + const loadTasks = async () => { + try { + setLoading(true) + const data = await getProjectTaskTree(projectId) + setTasks(data) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + const handleAddRootTask = async (e) => { + e.preventDefault() + if (!newTaskTitle.trim()) return + + try { + await createTask({ + project_id: parseInt(projectId), + parent_task_id: null, + title: newTaskTitle, + status: 'backlog' + }) + setNewTaskTitle('') + setShowAddRoot(false) + loadTasks() + } catch (err) { + alert(`Error: ${err.message}`) + } + } + + if (loading) { + return
Loading tasks...
+ } + + if (error) { + return
{error}
+ } + + return ( +
+
+

Task Tree

+ +
+ + {showAddRoot && ( +
+
+ setNewTaskTitle(e.target.value)} + placeholder="New task title..." + className="flex-1 px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 focus:outline-none focus:border-cyber-orange" + autoFocus + /> + + +
+
+ )} + + {tasks.length === 0 ? ( +
+

No tasks yet

+

Add a root task to get started

+
+ ) : ( +
+ {tasks.map(task => ( + + ))} +
+ )} +
+ ) +} + +export default TreeView diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..28967d5 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,36 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + @apply bg-cyber-dark text-gray-200; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + @apply bg-cyber-darkest; +} + +::-webkit-scrollbar-thumb { + @apply bg-cyber-orange-dim rounded; +} + +::-webkit-scrollbar-thumb:hover { + @apply bg-cyber-orange; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..b15cdd6 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,13 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + , +) diff --git a/frontend/src/pages/ProjectList.jsx b/frontend/src/pages/ProjectList.jsx new file mode 100644 index 0000000..ed17c83 --- /dev/null +++ b/frontend/src/pages/ProjectList.jsx @@ -0,0 +1,229 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { Plus, Upload, Trash2 } from 'lucide-react' +import { getProjects, createProject, deleteProject, importJSON } from '../utils/api' + +function ProjectList() { + const [projects, setProjects] = useState([]) + const [loading, setLoading] = useState(true) + const [showCreateModal, setShowCreateModal] = useState(false) + const [showImportModal, setShowImportModal] = useState(false) + const [newProjectName, setNewProjectName] = useState('') + const [newProjectDesc, setNewProjectDesc] = useState('') + const [importJSON_Text, setImportJSONText] = useState('') + const [error, setError] = useState('') + const navigate = useNavigate() + + useEffect(() => { + loadProjects() + }, []) + + const loadProjects = async () => { + try { + const data = await getProjects() + setProjects(data) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + const handleCreateProject = async (e) => { + e.preventDefault() + try { + const project = await createProject({ + name: newProjectName, + description: newProjectDesc || null, + }) + setProjects([...projects, project]) + setShowCreateModal(false) + setNewProjectName('') + setNewProjectDesc('') + navigate(`/project/${project.id}`) + } catch (err) { + setError(err.message) + } + } + + const handleImportJSON = async (e) => { + e.preventDefault() + try { + const data = JSON.parse(importJSON_Text) + const result = await importJSON(data) + setShowImportModal(false) + setImportJSONText('') + await loadProjects() + navigate(`/project/${result.project_id}`) + } catch (err) { + setError(err.message || 'Invalid JSON format') + } + } + + const handleDeleteProject = async (projectId, e) => { + e.stopPropagation() + if (!confirm('Delete this project and all its tasks?')) return + + try { + await deleteProject(projectId) + setProjects(projects.filter(p => p.id !== projectId)) + } catch (err) { + setError(err.message) + } + } + + if (loading) { + return
Loading...
+ } + + return ( +
+
+

Projects

+
+ + +
+
+ + {error && ( +
+ {error} +
+ )} + + {projects.length === 0 ? ( +
+

No projects yet

+

Create a new project or import from JSON

+
+ ) : ( +
+ {projects.map(project => ( +
navigate(`/project/${project.id}`)} + className="p-6 bg-cyber-darkest border border-cyber-orange/30 rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group" + > +
+

+ {project.name} +

+ +
+ {project.description && ( +

{project.description}

+ )} +

+ Created {new Date(project.created_at).toLocaleDateString()} +

+
+ ))} +
+ )} + + {/* Create Project Modal */} + {showCreateModal && ( +
+
+

Create New Project

+
+
+ + setNewProjectName(e.target.value)} + className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 focus:border-cyber-orange focus:outline-none" + required + autoFocus + /> +
+
+ +