From 296401abbcba21dad979c4621212e5096795985d Mon Sep 17 00:00:00 2001 From: rdev-worker Date: Sat, 31 Jan 2026 07:44:20 +0000 Subject: [PATCH] build: Build a full-stack task management application with the following str... --- .woodpecker.yml | 25 +- Dockerfile | 103 +++++++- backend/Dockerfile | 31 +++ backend/go.mod | 7 + backend/main.go | 188 +++++++++++++ docker-compose.yml | 20 ++ frontend/Dockerfile | 41 +++ frontend/next-env.d.ts | 5 + frontend/next.config.js | 16 ++ frontend/package.json | 32 +++ frontend/postcss.config.js | 6 + frontend/src/app/globals.css | 45 ++++ frontend/src/app/layout.tsx | 22 ++ frontend/src/app/page.tsx | 335 ++++++++++++++++++++++++ frontend/src/components/ui/button.tsx | 56 ++++ frontend/src/components/ui/card.tsx | 79 ++++++ frontend/src/components/ui/dialog.tsx | 122 +++++++++ frontend/src/components/ui/input.tsx | 25 ++ frontend/src/components/ui/textarea.tsx | 24 ++ frontend/src/lib/api.ts | 61 +++++ frontend/src/lib/utils.ts | 6 + frontend/tailwind.config.ts | 64 +++++ frontend/tsconfig.json | 26 ++ 23 files changed, 1331 insertions(+), 8 deletions(-) create mode 100644 backend/Dockerfile create mode 100644 backend/go.mod create mode 100644 backend/main.go create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/next.config.js create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/app/globals.css create mode 100644 frontend/src/app/layout.tsx create mode 100644 frontend/src/app/page.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/textarea.tsx create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.json 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 */} + + + + + +
+ + Add New Task + + Create a new task to track. Fill in the details below. + + +
+
+ + setNewTitle(e.target.value)} + required + /> +
+
+ +