diff --git a/.woodpecker.yml b/.woodpecker.yml
index 2621b0d..d3d0f3f 100644
--- a/.woodpecker.yml
+++ b/.woodpecker.yml
@@ -1,9 +1,27 @@
steps:
- docker:
+ build-backend:
image: woodpeckerci/plugin-kaniko
settings:
registry: registry.threesix.ai
- repo: "fs3"
+ repo: "fs3-backend"
+ context: backend
+ dockerfile: backend/Dockerfile
+ tags:
+ - latest
+ - ${CI_COMMIT_SHA:0:8}
+ cache: true
+ skip-tls-verify: true
+ when:
+ - event: push
+ branch: main
+
+ build-frontend:
+ image: woodpeckerci/plugin-kaniko
+ settings:
+ registry: registry.threesix.ai
+ repo: "fs3-frontend"
+ context: frontend
+ dockerfile: frontend/Dockerfile
tags:
- latest
- ${CI_COMMIT_SHA:0:8}
@@ -16,7 +34,8 @@ steps:
deploy:
image: bitnami/kubectl:latest
commands:
- - kubectl set image deployment/fs3 fs3=registry.threesix.ai/fs3:${CI_COMMIT_SHA:0:8} -n projects
+ - kubectl set image deployment/fs3-backend backend=registry.threesix.ai/fs3-backend:${CI_COMMIT_SHA:0:8} -n projects
+ - kubectl set image deployment/fs3-frontend frontend=registry.threesix.ai/fs3-frontend:${CI_COMMIT_SHA:0:8} -n projects
when:
- event: push
branch: main
diff --git a/Dockerfile b/Dockerfile
index e7846dc..e65c6ea 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,9 +1,102 @@
-# Default Dockerfile - replace with your application
+# Combined build for single-container deployment
+# For production, use separate frontend/backend containers via docker-compose
+
+# Build backend
+FROM golang:1.22-alpine AS backend-builder
+
+WORKDIR /app/backend
+
+COPY backend/go.mod backend/go.sum* ./
+RUN go mod download
+
+COPY backend/ .
+RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
+
+# Build frontend
+FROM node:20-alpine AS frontend-builder
+
+WORKDIR /app/frontend
+
+COPY frontend/package.json frontend/package-lock.json* ./
+RUN npm ci || npm install
+
+COPY frontend/ .
+RUN npm run build
+
+# Final stage with nginx to serve frontend and proxy to backend
FROM nginx:alpine
-# Copy static files or your app
-COPY . /usr/share/nginx/html/
+# Install supervisor to run multiple processes
+RUN apk add --no-cache supervisor
-EXPOSE 80
+# Copy backend binary
+COPY --from=backend-builder /app/backend/main /app/backend/main
-CMD ["nginx", "-g", "daemon off;"]
+# Copy frontend build
+COPY --from=frontend-builder /app/frontend/.next/standalone /app/frontend/
+COPY --from=frontend-builder /app/frontend/.next/static /app/frontend/.next/static
+COPY --from=frontend-builder /app/frontend/public /app/frontend/public
+
+# Nginx config
+RUN rm /etc/nginx/conf.d/default.conf
+COPY <<'EOF' /etc/nginx/conf.d/default.conf
+server {
+ listen 8080;
+
+ location /api {
+ proxy_pass http://127.0.0.1:8081;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+
+ location / {
+ proxy_pass http://127.0.0.1:3000;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection 'upgrade';
+ }
+}
+EOF
+
+# Supervisor config
+COPY <<'EOF' /etc/supervisord.conf
+[supervisord]
+nodaemon=true
+logfile=/dev/null
+logfile_maxbytes=0
+
+[program:backend]
+command=/app/backend/main
+autostart=true
+autorestart=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+
+[program:frontend]
+command=node /app/frontend/server.js
+environment=PORT="3000",HOSTNAME="0.0.0.0",NODE_ENV="production"
+autostart=true
+autorestart=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+
+[program:nginx]
+command=nginx -g 'daemon off;'
+autostart=true
+autorestart=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+EOF
+
+EXPOSE 8080
+
+CMD ["supervisord", "-c", "/etc/supervisord.conf"]
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..0813bae
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,31 @@
+# Build stage
+FROM golang:1.22-alpine AS builder
+
+WORKDIR /app
+
+# Copy go mod files
+COPY go.mod go.sum* ./
+RUN go mod download
+
+# Copy source code
+COPY . .
+
+# Build the binary
+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 8081
+
+# Run the binary
+CMD ["./main"]
diff --git a/backend/go.mod b/backend/go.mod
new file mode 100644
index 0000000..35c0320
--- /dev/null
+++ b/backend/go.mod
@@ -0,0 +1,7 @@
+module github.com/jordan/fs3/backend
+
+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/main.go b/backend/main.go
new file mode 100644
index 0000000..26b4694
--- /dev/null
+++ b/backend/main.go
@@ -0,0 +1,188 @@
+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"`
+ Completed bool `json:"completed"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+type TaskStore struct {
+ mu sync.RWMutex
+ tasks map[int]Task
+ nextID int
+}
+
+func NewTaskStore() *TaskStore {
+ return &TaskStore{
+ tasks: make(map[int]Task),
+ nextID: 1,
+ }
+}
+
+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(title, description string) Task {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ task := Task{
+ ID: s.nextID,
+ Title: title,
+ Description: description,
+ Completed: false,
+ CreatedAt: time.Now(),
+ }
+ s.tasks[s.nextID] = 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 jsonResponse(w http.ResponseWriter, status int, resp APIResponse) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(resp)
+}
+
+func main() {
+ store := NewTaskStore()
+
+ // Add some sample tasks
+ store.Create("Welcome to Task Manager", "This is your first task. Click on it to see details!")
+ store.Create("Add a new task", "Use the Add Task button to create new tasks")
+ store.Create("Stay organized", "Keep track of all your tasks in one place")
+
+ r := chi.NewRouter()
+
+ // Middleware
+ r.Use(middleware.Logger)
+ r.Use(middleware.Recoverer)
+ 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,
+ }))
+
+ // Routes
+ r.Route("/api", func(r chi.Router) {
+ r.Get("/tasks", func(w http.ResponseWriter, r *http.Request) {
+ tasks := store.GetAll()
+ jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: tasks})
+ })
+
+ r.Post("/tasks", func(w http.ResponseWriter, r *http.Request) {
+ var input struct {
+ Title string `json:"title"`
+ Description string `json:"description"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Error: "Invalid request body"})
+ return
+ }
+
+ if input.Title == "" {
+ jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Error: "Title is required"})
+ return
+ }
+
+ task := store.Create(input.Title, input.Description)
+ jsonResponse(w, http.StatusCreated, APIResponse{Success: true, Data: task})
+ })
+
+ r.Get("/tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
+ idStr := chi.URLParam(r, "id")
+ id, err := strconv.Atoi(idStr)
+ if err != nil {
+ jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Error: "Invalid task ID"})
+ return
+ }
+
+ task, ok := store.Get(id)
+ if !ok {
+ jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Error: "Task not found"})
+ return
+ }
+
+ jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: task})
+ })
+
+ r.Delete("/tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
+ idStr := chi.URLParam(r, "id")
+ id, err := strconv.Atoi(idStr)
+ if err != nil {
+ jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Error: "Invalid task ID"})
+ return
+ }
+
+ if !store.Delete(id) {
+ jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Error: "Task not found"})
+ return
+ }
+
+ jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: "Task deleted"})
+ })
+ })
+
+ // Health check
+ r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
+ jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: "OK"})
+ })
+
+ port := ":8081"
+ log.Printf("Backend server starting on %s", port)
+ if err := http.ListenAndServe(port, r); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..bc5858e
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,20 @@
+services:
+ frontend:
+ build:
+ context: ./frontend
+ dockerfile: Dockerfile
+ ports:
+ - "8080:3000"
+ environment:
+ - BACKEND_URL=http://backend:8081
+ depends_on:
+ - backend
+ restart: unless-stopped
+
+ backend:
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ ports:
+ - "8081:8081"
+ restart: unless-stopped
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..dc81148
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,41 @@
+# Build stage
+FROM node:20-alpine AS builder
+
+WORKDIR /app
+
+# Copy package files
+COPY package.json package-lock.json* ./
+
+# Install dependencies
+RUN npm ci || npm install
+
+# Copy source files
+COPY . .
+
+# Build the application
+RUN npm run build
+
+# Production stage
+FROM node:20-alpine AS runner
+
+WORKDIR /app
+
+ENV NODE_ENV=production
+
+# Add non-root user for security
+RUN addgroup --system --gid 1001 nodejs
+RUN adduser --system --uid 1001 nextjs
+
+# Copy built assets 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/next-env.d.ts b/frontend/next-env.d.ts
new file mode 100644
index 0000000..4f11a03
--- /dev/null
+++ b/frontend/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/frontend/next.config.js b/frontend/next.config.js
new file mode 100644
index 0000000..cab1c43
--- /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:8081/api/:path*',
+ },
+ ];
+ },
+};
+
+module.exports = nextConfig;
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..8661a77
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "fs3-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-dialog": "^1.0.5",
+ "@radix-ui/react-slot": "^1.0.2",
+ "@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",
+ "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/src/app/globals.css b/frontend/src/app/globals.css
new file mode 100644
index 0000000..5b12fd3
--- /dev/null
+++ b/frontend/src/app/globals.css
@@ -0,0 +1,45 @@
+@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: 210 40% 98%;
+ --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: 212.7 26.8% 83.9%;
+ --radius: 0.5rem;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
new file mode 100644
index 0000000..0c4df51
--- /dev/null
+++ b/frontend/src/app/layout.tsx
@@ -0,0 +1,22 @@
+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 (
+
+
{children}
+
+ );
+}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
new file mode 100644
index 0000000..477194c
--- /dev/null
+++ b/frontend/src/app/page.tsx
@@ -0,0 +1,335 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Plus, Trash2, Eye, CheckCircle2, Circle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { getTasks, createTask, deleteTask, getTask, Task } from "@/lib/api";
+
+export default function Home() {
+ const [tasks, setTasks] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Add task form state
+ const [newTitle, setNewTitle] = useState("");
+ const [newDescription, setNewDescription] = useState("");
+ const [addDialogOpen, setAddDialogOpen] = useState(false);
+ const [adding, setAdding] = useState(false);
+
+ // View task state
+ const [viewTask, setViewTask] = useState(null);
+ const [viewDialogOpen, setViewDialogOpen] = useState(false);
+ const [viewLoading, setViewLoading] = useState(false);
+
+ // Delete state
+ const [deletingId, setDeletingId] = useState(null);
+
+ useEffect(() => {
+ loadTasks();
+ }, []);
+
+ async function loadTasks() {
+ try {
+ setLoading(true);
+ const data = await getTasks();
+ setTasks(data);
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load tasks");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleAddTask(e: React.FormEvent) {
+ e.preventDefault();
+ if (!newTitle.trim()) return;
+
+ try {
+ setAdding(true);
+ const task = await createTask(newTitle.trim(), newDescription.trim());
+ setTasks((prev) => [...prev, task]);
+ setNewTitle("");
+ setNewDescription("");
+ setAddDialogOpen(false);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to add task");
+ } finally {
+ setAdding(false);
+ }
+ }
+
+ async function handleDeleteTask(id: number) {
+ try {
+ setDeletingId(id);
+ await deleteTask(id);
+ setTasks((prev) => prev.filter((t) => t.id !== id));
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to delete task");
+ } finally {
+ setDeletingId(null);
+ }
+ }
+
+ async function handleViewTask(id: number) {
+ try {
+ setViewLoading(true);
+ setViewDialogOpen(true);
+ const task = await getTask(id);
+ setViewTask(task);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load task");
+ setViewDialogOpen(false);
+ } finally {
+ setViewLoading(false);
+ }
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
Task Manager
+
+ Organize and track your tasks efficiently
+
+
+
+ {/* Add Task Dialog */}
+
+
+
+ {/* Error Alert */}
+ {error && (
+
+ {error}
+
+
+ )}
+
+ {/* Loading State */}
+ {loading && (
+
+ )}
+
+ {/* Empty State */}
+ {!loading && tasks.length === 0 && (
+
+
+
+ No tasks yet
+
+ Get started by adding your first task
+
+
+
+ )}
+
+ {/* Task Grid */}
+ {!loading && tasks.length > 0 && (
+
+ {tasks.map((task) => (
+
+
+
+
+ {task.completed ? (
+
+ ) : (
+
+ )}
+
+ {task.title}
+
+
+
+
+ {task.description || "No description"}
+
+
+
+
+
+ {new Date(task.createdAt).toLocaleDateString()}
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {/* View Task Dialog */}
+
+
+
+ );
+}
diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx
new file mode 100644
index 0000000..de31d90
--- /dev/null
+++ b/frontend/src/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/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx
new file mode 100644
index 0000000..b375b06
--- /dev/null
+++ b/frontend/src/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/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..399eb42
--- /dev/null
+++ b/frontend/src/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/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx
new file mode 100644
index 0000000..644632d
--- /dev/null
+++ b/frontend/src/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/src/components/ui/textarea.tsx b/frontend/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..0f3eac6
--- /dev/null
+++ b/frontend/src/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/src/lib/api.ts b/frontend/src/lib/api.ts
new file mode 100644
index 0000000..2099193
--- /dev/null
+++ b/frontend/src/lib/api.ts
@@ -0,0 +1,61 @@
+export interface Task {
+ id: number;
+ title: string;
+ description: string;
+ completed: boolean;
+ createdAt: string;
+}
+
+export interface APIResponse {
+ success: boolean;
+ data?: T;
+ error?: string;
+}
+
+const API_BASE = "/api";
+
+export async function getTasks(): 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 getTask(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(
+ title: string,
+ description: string
+): Promise {
+ const res = await fetch(`${API_BASE}/tasks`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ title, description }),
+ });
+ 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/src/lib/utils.ts b/frontend/src/lib/utils.ts
new file mode 100644
index 0000000..365058c
--- /dev/null
+++ b/frontend/src/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/tailwind.config.ts b/frontend/tailwind.config.ts
new file mode 100644
index 0000000..51b2922
--- /dev/null
+++ b/frontend/tailwind.config.ts
@@ -0,0 +1,64 @@
+import type { Config } from "tailwindcss";
+
+const config: Config = {
+ darkMode: ["class"],
+ content: [
+ "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
+ "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
+ "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
+ ],
+ 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)",
+ },
+ },
+ },
+ plugins: [],
+};
+
+export default config;
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..7b28589
--- /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": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}