diff --git a/.woodpecker.yml b/.woodpecker.yml
index 7e7c83c..8cba77a 100644
--- a/.woodpecker.yml
+++ b/.woodpecker.yml
@@ -1,9 +1,11 @@
steps:
- docker:
+ build-backend:
image: woodpeckerci/plugin-kaniko
settings:
registry: registry.threesix.ai
- repo: "testfix"
+ repo: "testfix-backend"
+ dockerfile: backend/Dockerfile
+ context: backend
tags:
- latest
- ${CI_COMMIT_SHA:0:8}
@@ -13,10 +15,34 @@ steps:
- event: push
branch: main
- deploy:
- image: bitnami/kubectl:latest
- commands:
- - kubectl set image deployment/testfix testfix=registry.threesix.ai/testfix:${CI_COMMIT_SHA:0:8} -n projects
+ build-frontend:
+ image: woodpeckerci/plugin-kaniko
+ settings:
+ registry: registry.threesix.ai
+ repo: "testfix-frontend"
+ dockerfile: frontend/Dockerfile
+ context: frontend
+ tags:
+ - latest
+ - ${CI_COMMIT_SHA:0:8}
+ cache: true
+ skip-tls-verify: true
+ when:
+ - event: push
+ branch: main
+
+ deploy-backend:
+ image: bitnami/kubectl:latest
+ commands:
+ - kubectl set image deployment/testfix-backend backend=registry.threesix.ai/testfix-backend:${CI_COMMIT_SHA:0:8} -n projects
+ when:
+ - event: push
+ branch: main
+
+ deploy-frontend:
+ image: bitnami/kubectl:latest
+ commands:
+ - kubectl set image deployment/testfix-frontend frontend=registry.threesix.ai/testfix-frontend:${CI_COMMIT_SHA:0:8} -n projects
when:
- event: push
branch: main
diff --git a/backend/.gitignore b/backend/.gitignore
new file mode 100644
index 0000000..f87401c
--- /dev/null
+++ b/backend/.gitignore
@@ -0,0 +1,31 @@
+# Binaries
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+main
+taskmanager
+
+# Test binary
+*.test
+
+# Output of go coverage
+*.out
+
+# Dependency directories
+vendor/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Environment
+.env
+.env.local
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..be27a5d
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,36 @@
+# Build stage
+FROM golang:1.22-alpine AS builder
+
+WORKDIR /app
+
+# Install dependencies
+RUN apk add --no-cache git
+
+# Copy go mod files
+COPY go.mod go.sum ./
+
+# Download dependencies
+RUN go mod download
+
+# Copy source code
+COPY . .
+
+# Build the application
+RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
+
+# Final stage
+FROM alpine:3.19
+
+WORKDIR /app
+
+# Install ca-certificates for HTTPS
+RUN apk --no-cache add ca-certificates
+
+# Copy binary from builder
+COPY --from=builder /app/main .
+
+# Expose port
+EXPOSE 8080
+
+# Run the application
+CMD ["./main"]
diff --git a/backend/go.mod b/backend/go.mod
new file mode 100644
index 0000000..d3a5f9b
--- /dev/null
+++ b/backend/go.mod
@@ -0,0 +1,7 @@
+module taskmanager
+
+go 1.22
+
+require github.com/go-chi/chi/v5 v5.0.12
+
+require github.com/go-chi/cors v1.2.1
diff --git a/backend/go.sum b/backend/go.sum
new file mode 100644
index 0000000..ef00cda
--- /dev/null
+++ b/backend/go.sum
@@ -0,0 +1,4 @@
+github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
+github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
+github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
+github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
diff --git a/backend/main.go b/backend/main.go
new file mode 100644
index 0000000..3a5f271
--- /dev/null
+++ b/backend/main.go
@@ -0,0 +1,224 @@
+package main
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/chi/v5/middleware"
+ "github.com/go-chi/cors"
+)
+
+type Task struct {
+ ID int `json:"id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Status string `json:"status"`
+ Priority string `json:"priority"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type TaskStore struct {
+ mu sync.RWMutex
+ tasks map[int]Task
+ nextID int
+}
+
+func NewTaskStore() *TaskStore {
+ store := &TaskStore{
+ tasks: make(map[int]Task),
+ nextID: 1,
+ }
+ // Add some sample tasks
+ now := time.Now()
+ store.tasks[1] = Task{
+ ID: 1,
+ Title: "Set up development environment",
+ Description: "Install all necessary tools and dependencies for the project",
+ Status: "completed",
+ Priority: "high",
+ CreatedAt: now.Add(-48 * time.Hour),
+ UpdatedAt: now.Add(-24 * time.Hour),
+ }
+ store.tasks[2] = Task{
+ ID: 2,
+ Title: "Design database schema",
+ Description: "Create the initial database schema for task management",
+ Status: "in_progress",
+ Priority: "high",
+ CreatedAt: now.Add(-24 * time.Hour),
+ UpdatedAt: now,
+ }
+ store.tasks[3] = Task{
+ ID: 3,
+ Title: "Implement user authentication",
+ Description: "Add JWT-based authentication system",
+ Status: "pending",
+ Priority: "medium",
+ CreatedAt: now.Add(-12 * time.Hour),
+ UpdatedAt: now.Add(-12 * time.Hour),
+ }
+ store.nextID = 4
+ return store
+}
+
+func (s *TaskStore) GetAll() []Task {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ tasks := make([]Task, 0, len(s.tasks))
+ for _, task := range s.tasks {
+ tasks = append(tasks, task)
+ }
+ return tasks
+}
+
+func (s *TaskStore) Get(id int) (Task, bool) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ task, ok := s.tasks[id]
+ return task, ok
+}
+
+func (s *TaskStore) Create(task Task) Task {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ task.ID = s.nextID
+ task.CreatedAt = time.Now()
+ task.UpdatedAt = time.Now()
+ if task.Status == "" {
+ task.Status = "pending"
+ }
+ if task.Priority == "" {
+ task.Priority = "medium"
+ }
+ s.tasks[task.ID] = task
+ s.nextID++
+ return task
+}
+
+func (s *TaskStore) Delete(id int) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if _, ok := s.tasks[id]; ok {
+ delete(s.tasks, id)
+ return true
+ }
+ return false
+}
+
+type APIResponse struct {
+ Success bool `json:"success"`
+ Data interface{} `json:"data,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+func respondJSON(w http.ResponseWriter, status int, payload interface{}) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(payload)
+}
+
+func main() {
+ store := NewTaskStore()
+
+ r := chi.NewRouter()
+
+ // Middleware
+ r.Use(middleware.Logger)
+ r.Use(middleware.Recoverer)
+ r.Use(middleware.RequestID)
+ r.Use(cors.Handler(cors.Options{
+ AllowedOrigins: []string{"http://localhost:3000", "http://localhost:8080", "*"},
+ AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
+ AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
+ ExposedHeaders: []string{"Link"},
+ AllowCredentials: true,
+ MaxAge: 300,
+ }))
+
+ // Health check
+ r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
+ respondJSON(w, http.StatusOK, APIResponse{Success: true, Data: "healthy"})
+ })
+
+ // API routes
+ r.Route("/api", func(r chi.Router) {
+ r.Route("/tasks", func(r chi.Router) {
+ r.Get("/", func(w http.ResponseWriter, r *http.Request) {
+ tasks := store.GetAll()
+ respondJSON(w, http.StatusOK, APIResponse{Success: true, Data: tasks})
+ })
+
+ r.Post("/", func(w http.ResponseWriter, r *http.Request) {
+ var task Task
+ if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
+ respondJSON(w, http.StatusBadRequest, APIResponse{
+ Success: false,
+ Error: "Invalid request body",
+ })
+ return
+ }
+ if task.Title == "" {
+ respondJSON(w, http.StatusBadRequest, APIResponse{
+ Success: false,
+ Error: "Title is required",
+ })
+ return
+ }
+ created := store.Create(task)
+ respondJSON(w, http.StatusCreated, APIResponse{Success: true, Data: created})
+ })
+
+ r.Get("/{id}", func(w http.ResponseWriter, r *http.Request) {
+ idStr := chi.URLParam(r, "id")
+ id, err := strconv.Atoi(idStr)
+ if err != nil {
+ respondJSON(w, http.StatusBadRequest, APIResponse{
+ Success: false,
+ Error: "Invalid task ID",
+ })
+ return
+ }
+ task, ok := store.Get(id)
+ if !ok {
+ respondJSON(w, http.StatusNotFound, APIResponse{
+ Success: false,
+ Error: "Task not found",
+ })
+ return
+ }
+ respondJSON(w, http.StatusOK, APIResponse{Success: true, Data: task})
+ })
+
+ r.Delete("/{id}", func(w http.ResponseWriter, r *http.Request) {
+ idStr := chi.URLParam(r, "id")
+ id, err := strconv.Atoi(idStr)
+ if err != nil {
+ respondJSON(w, http.StatusBadRequest, APIResponse{
+ Success: false,
+ Error: "Invalid task ID",
+ })
+ return
+ }
+ if !store.Delete(id) {
+ respondJSON(w, http.StatusNotFound, APIResponse{
+ Success: false,
+ Error: "Task not found",
+ })
+ return
+ }
+ respondJSON(w, http.StatusOK, APIResponse{Success: true, Data: "Task deleted"})
+ })
+ })
+ })
+
+ log.Println("Starting server on :8080")
+ if err := http.ListenAndServe(":8080", r); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..bef5cb5
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,35 @@
+version: '3.8'
+
+services:
+ backend:
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ ports:
+ - "8080:8080"
+ environment:
+ - PORT=8080
+ healthcheck:
+ test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 10s
+ restart: unless-stopped
+
+ frontend:
+ build:
+ context: ./frontend
+ dockerfile: Dockerfile
+ ports:
+ - "3000:3000"
+ environment:
+ - BACKEND_URL=http://backend:8080
+ depends_on:
+ backend:
+ condition: service_healthy
+ restart: unless-stopped
+
+networks:
+ default:
+ driver: bridge
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..a68f5d3
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,34 @@
+# Dependencies
+/node_modules
+/.pnp
+.pnp.js
+.yarn/install-state.gz
+
+# Testing
+/coverage
+
+# Next.js
+/.next/
+/out/
+
+# Production
+/build
+
+# Misc
+.DS_Store
+*.pem
+
+# Debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Local env files
+.env*.local
+
+# Vercel
+.vercel
+
+# Typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..af33a60
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,51 @@
+# Dependencies stage
+FROM node:20-alpine AS deps
+
+WORKDIR /app
+
+# Copy package files
+COPY package.json package-lock.json* ./
+
+# Install dependencies
+RUN npm ci || npm install
+
+# Builder stage
+FROM node:20-alpine AS builder
+
+WORKDIR /app
+
+# Copy dependencies
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+
+# Set environment variables for build
+ENV NEXT_TELEMETRY_DISABLED=1
+
+# Build the application
+RUN npm run build
+
+# Runner stage
+FROM node:20-alpine AS runner
+
+WORKDIR /app
+
+ENV NODE_ENV=production
+ENV NEXT_TELEMETRY_DISABLED=1
+
+# Create non-root user
+RUN addgroup --system --gid 1001 nodejs
+RUN adduser --system --uid 1001 nextjs
+
+# Copy necessary files from builder
+COPY --from=builder /app/public ./public
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+
+USER nextjs
+
+EXPOSE 3000
+
+ENV PORT=3000
+ENV HOSTNAME="0.0.0.0"
+
+CMD ["node", "server.js"]
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
new file mode 100644
index 0000000..e7f52b0
--- /dev/null
+++ b/frontend/app/globals.css
@@ -0,0 +1,46 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --background: 222.2 84% 4.9%;
+ --foreground: 210 40% 98%;
+
+ --card: 222.2 84% 4.9%;
+ --card-foreground: 210 40% 98%;
+
+ --popover: 222.2 84% 4.9%;
+ --popover-foreground: 210 40% 98%;
+
+ --primary: 217.2 91.2% 59.8%;
+ --primary-foreground: 222.2 47.4% 11.2%;
+
+ --secondary: 217.2 32.6% 17.5%;
+ --secondary-foreground: 210 40% 98%;
+
+ --muted: 217.2 32.6% 17.5%;
+ --muted-foreground: 215 20.2% 65.1%;
+
+ --accent: 217.2 32.6% 17.5%;
+ --accent-foreground: 210 40% 98%;
+
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 210 40% 98%;
+
+ --border: 217.2 32.6% 17.5%;
+ --input: 217.2 32.6% 17.5%;
+ --ring: 224.3 76.3% 48%;
+
+ --radius: 0.5rem;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
new file mode 100644
index 0000000..c928d20
--- /dev/null
+++ b/frontend/app/layout.tsx
@@ -0,0 +1,33 @@
+import type { Metadata } from "next";
+import { Inter } from "next/font/google";
+import "./globals.css";
+
+const inter = Inter({ subsets: ["latin"] });
+
+export const metadata: Metadata = {
+ title: "Task Manager",
+ description: "A modern task management application",
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
new file mode 100644
index 0000000..e730543
--- /dev/null
+++ b/frontend/app/page.tsx
@@ -0,0 +1,216 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { Plus, Trash2, CheckCircle, Clock, Circle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Task, fetchTasks, deleteTask } from "@/lib/api";
+
+const statusIcons = {
+ pending: Circle,
+ in_progress: Clock,
+ completed: CheckCircle,
+};
+
+const statusColors = {
+ pending: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
+ in_progress: "bg-blue-500/20 text-blue-400 border-blue-500/30",
+ completed: "bg-green-500/20 text-green-400 border-green-500/30",
+};
+
+const priorityColors = {
+ low: "bg-slate-500/20 text-slate-400 border-slate-500/30",
+ medium: "bg-orange-500/20 text-orange-400 border-orange-500/30",
+ high: "bg-red-500/20 text-red-400 border-red-500/30",
+};
+
+export default function Dashboard() {
+ const [tasks, setTasks] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const loadTasks = async () => {
+ try {
+ setLoading(true);
+ const data = await fetchTasks();
+ setTasks(data);
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load tasks");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadTasks();
+ }, []);
+
+ const handleDelete = async (id: number) => {
+ try {
+ await deleteTask(id);
+ setTasks(tasks.filter((t) => t.id !== id));
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to delete task");
+ }
+ };
+
+ const stats = {
+ total: tasks.length,
+ pending: tasks.filter((t) => t.status === "pending").length,
+ inProgress: tasks.filter((t) => t.status === "in_progress").length,
+ completed: tasks.filter((t) => t.status === "completed").length,
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* Stats Overview */}
+
+
+
+
+ Total Tasks
+
+
+
+ {stats.total}
+
+
+
+
+
+ Pending
+
+
+
+
+ {stats.pending}
+
+
+
+
+
+
+ In Progress
+
+
+
+
+ {stats.inProgress}
+
+
+
+
+
+
+ Completed
+
+
+
+
+ {stats.completed}
+
+
+
+
+
+ {/* Actions */}
+
+
Tasks
+
+
+
+
+
+ {/* Error Message */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Task List */}
+ {tasks.length === 0 ? (
+
+
+ No tasks yet
+
+
+
+
+
+ ) : (
+
+ {tasks.map((task) => {
+ const StatusIcon = statusIcons[task.status];
+ return (
+
+
+
+
+
+
+
+ {task.title}
+
+ {task.description && (
+
+ {task.description}
+
+ )}
+
+
+ {task.status.replace("_", " ")}
+
+
+ {task.priority}
+
+
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/frontend/app/tasks/[id]/page.tsx b/frontend/app/tasks/[id]/page.tsx
new file mode 100644
index 0000000..2cdc3d3
--- /dev/null
+++ b/frontend/app/tasks/[id]/page.tsx
@@ -0,0 +1,212 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useParams, useRouter } from "next/navigation";
+import Link from "next/link";
+import { ArrowLeft, Trash2, CheckCircle, Clock, Circle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Task, fetchTask, deleteTask } from "@/lib/api";
+
+const statusIcons = {
+ pending: Circle,
+ in_progress: Clock,
+ completed: CheckCircle,
+};
+
+const statusColors = {
+ pending: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
+ in_progress: "bg-blue-500/20 text-blue-400 border-blue-500/30",
+ completed: "bg-green-500/20 text-green-400 border-green-500/30",
+};
+
+const priorityColors = {
+ low: "bg-slate-500/20 text-slate-400 border-slate-500/30",
+ medium: "bg-orange-500/20 text-orange-400 border-orange-500/30",
+ high: "bg-red-500/20 text-red-400 border-red-500/30",
+};
+
+export default function TaskDetail() {
+ const params = useParams();
+ const router = useRouter();
+ const [task, setTask] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+
+ useEffect(() => {
+ const loadTask = async () => {
+ try {
+ setLoading(true);
+ const id = parseInt(params.id as string);
+ const data = await fetchTask(id);
+ setTask(data);
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load task");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (params.id) {
+ loadTask();
+ }
+ }, [params.id]);
+
+ const handleDelete = async () => {
+ if (!task) return;
+ try {
+ await deleteTask(task.id);
+ router.push("/");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to delete task");
+ }
+ };
+
+ const formatDate = (dateString: string) => {
+ return new Date(dateString).toLocaleString("en-US", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ });
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error || !task) {
+ return (
+
+
+
+ Back to Dashboard
+
+
+
+ {error || "Task not found"}
+
+
+
+
+
+
+ );
+ }
+
+ const StatusIcon = statusIcons[task.status];
+
+ return (
+
+
+
+ Back to Dashboard
+
+
+
+
+
+
+
+
+ {task.title}
+
+ Created {formatDate(task.createdAt)}
+
+
+
+
+
+
+
+
+
+ {task.status.replace("_", " ")}
+
+
+ {task.priority} priority
+
+
+
+ {task.description && (
+
+
+ Description
+
+
+ {task.description}
+
+
+ )}
+
+
+
+
+ Created
+
+
{formatDate(task.createdAt)}
+
+
+
+ Last Updated
+
+
{formatDate(task.updatedAt)}
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/tasks/new/page.tsx b/frontend/app/tasks/new/page.tsx
new file mode 100644
index 0000000..cb847f8
--- /dev/null
+++ b/frontend/app/tasks/new/page.tsx
@@ -0,0 +1,153 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import Link from "next/link";
+import { ArrowLeft } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { createTask } from "@/lib/api";
+
+export default function NewTask() {
+ const router = useRouter();
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [formData, setFormData] = useState({
+ title: "",
+ description: "",
+ status: "pending" as const,
+ priority: "medium" as const,
+ });
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!formData.title.trim()) {
+ setError("Title is required");
+ return;
+ }
+
+ try {
+ setLoading(true);
+ setError(null);
+ await createTask(formData);
+ router.push("/");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to create task");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ Back to Dashboard
+
+
+
+
+ Create New Task
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/components.json b/frontend/components.json
new file mode 100644
index 0000000..fa674c9
--- /dev/null
+++ b/frontend/components.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "default",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.ts",
+ "css": "app/globals.css",
+ "baseColor": "slate",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils"
+ }
+}
diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx
new file mode 100644
index 0000000..9ec9a1a
--- /dev/null
+++ b/frontend/components/ui/badge.tsx
@@ -0,0 +1,36 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+);
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx
new file mode 100644
index 0000000..de31d90
--- /dev/null
+++ b/frontend/components/ui/button.tsx
@@ -0,0 +1,56 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ sm: "h-9 rounded-md px-3",
+ lg: "h-11 rounded-md px-8",
+ icon: "h-10 w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+ return (
+
+ );
+ }
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/frontend/components/ui/card.tsx b/frontend/components/ui/card.tsx
new file mode 100644
index 0000000..b375b06
--- /dev/null
+++ b/frontend/components/ui/card.tsx
@@ -0,0 +1,79 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+Card.displayName = "Card";
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardHeader.displayName = "CardHeader";
+
+const CardTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardTitle.displayName = "CardTitle";
+
+const CardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardDescription.displayName = "CardDescription";
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardContent.displayName = "CardContent";
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardFooter.displayName = "CardFooter";
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
diff --git a/frontend/components/ui/dialog.tsx b/frontend/components/ui/dialog.tsx
new file mode 100644
index 0000000..399eb42
--- /dev/null
+++ b/frontend/components/ui/dialog.tsx
@@ -0,0 +1,122 @@
+"use client";
+
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Dialog = DialogPrimitive.Root;
+
+const DialogTrigger = DialogPrimitive.Trigger;
+
+const DialogPortal = DialogPrimitive.Portal;
+
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogFooter.displayName = "DialogFooter";
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+};
diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx
new file mode 100644
index 0000000..644632d
--- /dev/null
+++ b/frontend/components/ui/input.tsx
@@ -0,0 +1,25 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+export interface InputProps
+ extends React.InputHTMLAttributes {}
+
+const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+Input.displayName = "Input";
+
+export { Input };
diff --git a/frontend/components/ui/label.tsx b/frontend/components/ui/label.tsx
new file mode 100644
index 0000000..40378d4
--- /dev/null
+++ b/frontend/components/ui/label.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import * as React from "react";
+import * as LabelPrimitive from "@radix-ui/react-label";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const labelVariants = cva(
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+);
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+));
+Label.displayName = LabelPrimitive.Root.displayName;
+
+export { Label };
diff --git a/frontend/components/ui/select.tsx b/frontend/components/ui/select.tsx
new file mode 100644
index 0000000..482a1e1
--- /dev/null
+++ b/frontend/components/ui/select.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import * as React from "react";
+import * as SelectPrimitive from "@radix-ui/react-select";
+import { Check, ChevronDown, ChevronUp } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Select = SelectPrimitive.Root;
+
+const SelectGroup = SelectPrimitive.Group;
+
+const SelectValue = SelectPrimitive.Value;
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName;
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectLabel.displayName = SelectPrimitive.Label.displayName;
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+
+ {children}
+
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+};
diff --git a/frontend/components/ui/textarea.tsx b/frontend/components/ui/textarea.tsx
new file mode 100644
index 0000000..0f3eac6
--- /dev/null
+++ b/frontend/components/ui/textarea.tsx
@@ -0,0 +1,24 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+export interface TextareaProps
+ extends React.TextareaHTMLAttributes {}
+
+const Textarea = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+Textarea.displayName = "Textarea";
+
+export { Textarea };
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
new file mode 100644
index 0000000..7251d0d
--- /dev/null
+++ b/frontend/lib/api.ts
@@ -0,0 +1,52 @@
+export interface Task {
+ id: number;
+ title: string;
+ description: string;
+ status: "pending" | "in_progress" | "completed";
+ priority: "low" | "medium" | "high";
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface APIResponse {
+ success: boolean;
+ data?: T;
+ error?: string;
+}
+
+const API_BASE = "/api";
+
+export async function fetchTasks(): Promise {
+ const res = await fetch(`${API_BASE}/tasks`);
+ const json: APIResponse = await res.json();
+ if (!json.success) throw new Error(json.error || "Failed to fetch tasks");
+ return json.data || [];
+}
+
+export async function fetchTask(id: number): Promise {
+ const res = await fetch(`${API_BASE}/tasks/${id}`);
+ const json: APIResponse = await res.json();
+ if (!json.success) throw new Error(json.error || "Failed to fetch task");
+ return json.data!;
+}
+
+export async function createTask(
+ task: Pick
+): Promise {
+ const res = await fetch(`${API_BASE}/tasks`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(task),
+ });
+ const json: APIResponse = await res.json();
+ if (!json.success) throw new Error(json.error || "Failed to create task");
+ return json.data!;
+}
+
+export async function deleteTask(id: number): Promise {
+ const res = await fetch(`${API_BASE}/tasks/${id}`, {
+ method: "DELETE",
+ });
+ const json: APIResponse = await res.json();
+ if (!json.success) throw new Error(json.error || "Failed to delete task");
+}
diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts
new file mode 100644
index 0000000..365058c
--- /dev/null
+++ b/frontend/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/frontend/next.config.js b/frontend/next.config.js
new file mode 100644
index 0000000..be86f12
--- /dev/null
+++ b/frontend/next.config.js
@@ -0,0 +1,16 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ output: 'standalone',
+ async rewrites() {
+ return [
+ {
+ source: '/api/:path*',
+ destination: process.env.BACKEND_URL
+ ? `${process.env.BACKEND_URL}/api/:path*`
+ : 'http://backend:8080/api/:path*',
+ },
+ ];
+ },
+};
+
+module.exports = nextConfig;
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..2308a9a
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "task-manager-frontend",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "next": "14.2.3",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.1.1",
+ "tailwind-merge": "^2.3.0",
+ "lucide-react": "^0.378.0",
+ "@radix-ui/react-slot": "^1.0.2",
+ "@radix-ui/react-dialog": "^1.0.5",
+ "@radix-ui/react-label": "^2.0.2",
+ "@radix-ui/react-select": "^2.0.0",
+ "@radix-ui/react-toast": "^1.1.5"
+ },
+ "devDependencies": {
+ "@types/node": "^20.12.12",
+ "@types/react": "^18.3.2",
+ "@types/react-dom": "^18.3.0",
+ "autoprefixer": "^10.4.19",
+ "postcss": "^8.4.38",
+ "tailwindcss": "^3.4.3",
+ "tailwindcss-animate": "^1.0.7",
+ "typescript": "^5.4.5"
+ }
+}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000..12a703d
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/frontend/public/.gitkeep b/frontend/public/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts
new file mode 100644
index 0000000..41668a3
--- /dev/null
+++ b/frontend/tailwind.config.ts
@@ -0,0 +1,80 @@
+import type { Config } from "tailwindcss";
+
+const config = {
+ darkMode: ["class"],
+ content: [
+ "./pages/**/*.{ts,tsx}",
+ "./components/**/*.{ts,tsx}",
+ "./app/**/*.{ts,tsx}",
+ "./src/**/*.{ts,tsx}",
+ ],
+ prefix: "",
+ theme: {
+ container: {
+ center: true,
+ padding: "2rem",
+ screens: {
+ "2xl": "1400px",
+ },
+ },
+ extend: {
+ colors: {
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
+ },
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ },
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
+ keyframes: {
+ "accordion-down": {
+ from: { height: "0" },
+ to: { height: "var(--radix-accordion-content-height)" },
+ },
+ "accordion-up": {
+ from: { height: "var(--radix-accordion-content-height)" },
+ to: { height: "0" },
+ },
+ },
+ animation: {
+ "accordion-down": "accordion-down 0.2s ease-out",
+ "accordion-up": "accordion-up 0.2s ease-out",
+ },
+ },
+ },
+ plugins: [require("tailwindcss-animate")],
+} satisfies Config;
+
+export default config;
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..e7ff90f
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}