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 (
+
+
+
+
+
+ } />
+ } />
+
+
+
+ )
+}
+
+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 && (
+
+ )}
+
+
+ {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' : ''}`}>
+
+
+ )}
+
+ {/* 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 && (
+
+
+
+ )}
+
+ {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 && (
+
+ )}
+
+ {/* Import JSON Modal */}
+ {showImportModal && (
+
+
+
Import Project from JSON
+
+
+
+ )}
+
+ )
+}
+
+export default ProjectList
diff --git a/frontend/src/pages/ProjectView.jsx b/frontend/src/pages/ProjectView.jsx
new file mode 100644
index 0000000..c1ce7fc
--- /dev/null
+++ b/frontend/src/pages/ProjectView.jsx
@@ -0,0 +1,104 @@
+import { useState, useEffect } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import { ArrowLeft, LayoutList, LayoutGrid } from 'lucide-react'
+import { getProject } from '../utils/api'
+import TreeView from '../components/TreeView'
+import KanbanView from '../components/KanbanView'
+
+function ProjectView() {
+ const { projectId } = useParams()
+ const navigate = useNavigate()
+ const [project, setProject] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [view, setView] = useState('tree') // 'tree' or 'kanban'
+
+ useEffect(() => {
+ loadProject()
+ }, [projectId])
+
+ const loadProject = async () => {
+ try {
+ const data = await getProject(projectId)
+ setProject(data)
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (loading) {
+ return Loading...
+ }
+
+ if (error || !project) {
+ return (
+
+
{error || 'Project not found'}
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
{project.name}
+ {project.description && (
+
{project.description}
+ )}
+
+
+
+
+
+
+
+
+
+ {view === 'tree' ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+export default ProjectView
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
new file mode 100644
index 0000000..14247e1
--- /dev/null
+++ b/frontend/src/utils/api.js
@@ -0,0 +1,58 @@
+const API_BASE = '/api';
+
+async function fetchAPI(endpoint, options = {}) {
+ const response = await fetch(`${API_BASE}${endpoint}`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ },
+ ...options,
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: 'Request failed' }));
+ throw new Error(error.detail || `HTTP ${response.status}`);
+ }
+
+ if (response.status === 204) {
+ return null;
+ }
+
+ return response.json();
+}
+
+// Projects
+export const getProjects = () => fetchAPI('/projects');
+export const getProject = (id) => fetchAPI(`/projects/${id}`);
+export const createProject = (data) => fetchAPI('/projects', {
+ method: 'POST',
+ body: JSON.stringify(data),
+});
+export const updateProject = (id, data) => fetchAPI(`/projects/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(data),
+});
+export const deleteProject = (id) => fetchAPI(`/projects/${id}`, { method: 'DELETE' });
+
+// Tasks
+export const getProjectTasks = (projectId) => fetchAPI(`/projects/${projectId}/tasks`);
+export const getProjectTaskTree = (projectId) => fetchAPI(`/projects/${projectId}/tasks/tree`);
+export const getTasksByStatus = (projectId, status) =>
+ fetchAPI(`/projects/${projectId}/tasks/by-status/${status}`);
+
+export const getTask = (id) => fetchAPI(`/tasks/${id}`);
+export const createTask = (data) => fetchAPI('/tasks', {
+ method: 'POST',
+ body: JSON.stringify(data),
+});
+export const updateTask = (id, data) => fetchAPI(`/tasks/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(data),
+});
+export const deleteTask = (id) => fetchAPI(`/tasks/${id}`, { method: 'DELETE' });
+
+// JSON Import
+export const importJSON = (data) => fetchAPI('/import-json', {
+ method: 'POST',
+ body: JSON.stringify(data),
+});
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
new file mode 100644
index 0000000..9ad2e75
--- /dev/null
+++ b/frontend/tailwind.config.js
@@ -0,0 +1,24 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {
+ colors: {
+ 'cyber-dark': '#050509',
+ 'cyber-darker': '#0a0a0f',
+ 'cyber-darkest': '#101018',
+ 'cyber-orange': '#ff6b35',
+ 'cyber-orange-bright': '#ff8c42',
+ 'cyber-orange-dim': '#cc5428',
+ },
+ boxShadow: {
+ 'cyber': '0 0 10px rgba(255, 107, 53, 0.3)',
+ 'cyber-lg': '0 0 20px rgba(255, 107, 53, 0.5)',
+ }
+ },
+ },
+ plugins: [],
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..643d7ad
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,16 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ host: '0.0.0.0',
+ port: 5173,
+ proxy: {
+ '/api': {
+ target: 'http://backend:8000',
+ changeOrigin: true,
+ }
+ }
+ }
+})