build: Build a full-stack task management application with the following str...

This commit is contained in:
rdev-worker 2026-01-31 09:25:08 +00:00
parent c7f7f656c2
commit c800501726
10 changed files with 370 additions and 47 deletions

View File

@ -3,9 +3,9 @@ FROM golang:1.22-alpine AS builder
WORKDIR /app
# Copy go mod files
# Copy go mod files and download dependencies
COPY go.mod go.sum* ./
RUN go mod download
RUN go mod download || go mod tidy
# Copy source code
COPY . .
@ -25,7 +25,7 @@ RUN apk --no-cache add ca-certificates
COPY --from=builder /app/main .
# Expose port
EXPOSE 8081
EXPOSE 8080
# Run the binary
CMD ["./main"]

View File

@ -1,4 +1,4 @@
module github.com/jordan/fs3/backend
module backend
go 1.22

View File

@ -17,8 +17,10 @@ type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Completed bool `json:"completed"`
Status string `json:"status"`
Priority string `json:"priority"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type TaskStore struct {
@ -53,16 +55,26 @@ func (s *TaskStore) Get(id int) (Task, bool) {
return task, ok
}
func (s *TaskStore) Create(title, description string) Task {
func (s *TaskStore) Create(title, description, status, priority string) Task {
s.mu.Lock()
defer s.mu.Unlock()
if status == "" {
status = "pending"
}
if priority == "" {
priority = "medium"
}
now := time.Now()
task := Task{
ID: s.nextID,
Title: title,
Description: description,
Completed: false,
CreatedAt: time.Now(),
Status: status,
Priority: priority,
CreatedAt: now,
UpdatedAt: now,
}
s.tasks[s.nextID] = task
s.nextID++
@ -96,9 +108,10 @@ 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")
store.Create("Set up development environment", "Install all necessary tools and dependencies for the project", "completed", "high")
store.Create("Design database schema", "Create the initial database schema for the application", "in-progress", "high")
store.Create("Write unit tests", "Add comprehensive unit tests for all core functionality", "pending", "medium")
store.Create("Deploy to production", "Set up CI/CD pipeline and deploy to cloud", "pending", "low")
r := chi.NewRouter()
@ -135,6 +148,8 @@ func main() {
var input struct {
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
@ -147,7 +162,7 @@ func main() {
return
}
task := store.Create(input.Title, input.Description)
task := store.Create(input.Title, input.Description, input.Status, input.Priority)
jsonResponse(w, http.StatusCreated, APIResponse{Success: true, Data: task})
})
@ -190,7 +205,7 @@ func main() {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: "OK"})
})
port := ":8081"
port := ":8080"
log.Printf("Backend server starting on %s", port)
if err := http.ListenAndServe(port, r); err != nil {
log.Fatal(err)

View File

@ -6,7 +6,7 @@ services:
ports:
- "8080:3000"
environment:
- BACKEND_URL=http://backend:8081
- BACKEND_URL=http://backend:8080
depends_on:
- backend
restart: unless-stopped
@ -16,5 +16,5 @@ services:
context: ./backend
dockerfile: Dockerfile
ports:
- "8081:8081"
- "8081:8080"
restart: unless-stopped

View File

@ -7,7 +7,7 @@ const nextConfig = {
source: '/api/:path*',
destination: process.env.BACKEND_URL
? `${process.env.BACKEND_URL}/api/:path*`
: 'http://backend:8081/api/:path*',
: 'http://backend:8080/api/:path*',
},
];
},

View File

@ -17,6 +17,7 @@
"tailwind-merge": "^2.3.0",
"lucide-react": "^0.378.0",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.1.5"
},

View File

@ -1,7 +1,15 @@
"use client";
import { useState, useEffect } from "react";
import { Plus, Trash2, Eye, CheckCircle2, Circle } from "lucide-react";
import {
Plus,
Trash2,
Eye,
CheckCircle2,
Circle,
Clock,
AlertCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
@ -21,8 +29,49 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { getTasks, createTask, deleteTask, getTask, Task } from "@/lib/api";
function getStatusIcon(status: string) {
switch (status) {
case "completed":
return <CheckCircle2 className="h-5 w-5 text-green-500" />;
case "in-progress":
return <Clock className="h-5 w-5 text-blue-500" />;
default:
return <Circle className="h-5 w-5 text-muted-foreground" />;
}
}
function getStatusBadge(status: string) {
switch (status) {
case "completed":
return <Badge variant="success">Completed</Badge>;
case "in-progress":
return <Badge variant="info">In Progress</Badge>;
default:
return <Badge variant="secondary">Pending</Badge>;
}
}
function getPriorityBadge(priority: string) {
switch (priority) {
case "high":
return <Badge variant="destructive">High</Badge>;
case "medium":
return <Badge variant="warning">Medium</Badge>;
default:
return <Badge variant="outline">Low</Badge>;
}
}
export default function Home() {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
@ -31,6 +80,8 @@ export default function Home() {
// Add task form state
const [newTitle, setNewTitle] = useState("");
const [newDescription, setNewDescription] = useState("");
const [newStatus, setNewStatus] = useState("pending");
const [newPriority, setNewPriority] = useState("medium");
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [adding, setAdding] = useState(false);
@ -65,10 +116,17 @@ export default function Home() {
try {
setAdding(true);
const task = await createTask(newTitle.trim(), newDescription.trim());
const task = await createTask(
newTitle.trim(),
newDescription.trim(),
newStatus,
newPriority
);
setTasks((prev) => [...prev, task]);
setNewTitle("");
setNewDescription("");
setNewStatus("pending");
setNewPriority("medium");
setAddDialogOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to add task");
@ -159,6 +217,43 @@ export default function Home() {
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<label htmlFor="status" className="text-sm font-medium">
Status
</label>
<Select value={newStatus} onValueChange={setNewStatus}>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="in-progress">
In Progress
</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<label htmlFor="priority" className="text-sm font-medium">
Priority
</label>
<Select
value={newPriority}
onValueChange={setNewPriority}
>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={adding || !newTitle.trim()}>
@ -173,9 +268,12 @@ export default function Home() {
{/* Error Alert */}
{error && (
<div className="bg-destructive/15 border border-destructive text-destructive px-4 py-3 rounded-lg mb-6">
{error}
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
<span>{error}</span>
</div>
<button
className="float-right font-bold"
className="float-right font-bold -mt-6"
onClick={() => setError(null)}
>
&times;
@ -216,11 +314,7 @@ export default function Home() {
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
{task.completed ? (
<CheckCircle2 className="h-5 w-5 text-green-500 mt-0.5 flex-shrink-0" />
) : (
<Circle className="h-5 w-5 text-muted-foreground mt-0.5 flex-shrink-0" />
)}
{getStatusIcon(task.status)}
<CardTitle className="text-lg leading-tight">
{task.title}
</CardTitle>
@ -232,9 +326,10 @@ export default function Home() {
</CardHeader>
<CardContent className="pt-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{new Date(task.createdAt).toLocaleDateString()}
</span>
<div className="flex gap-2">
{getStatusBadge(task.status)}
{getPriorityBadge(task.priority)}
</div>
<div className="flex gap-2">
<Button
variant="ghost"
@ -254,6 +349,9 @@ export default function Home() {
</Button>
</div>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{new Date(task.createdAt).toLocaleDateString()}
</div>
</CardContent>
</Card>
))}
@ -273,11 +371,7 @@ export default function Home() {
<>
<DialogHeader>
<div className="flex items-center gap-3">
{viewTask.completed ? (
<CheckCircle2 className="h-6 w-6 text-green-500 flex-shrink-0" />
) : (
<Circle className="h-6 w-6 text-muted-foreground flex-shrink-0" />
)}
{getStatusIcon(viewTask.status)}
<DialogTitle className="text-xl">
{viewTask.title}
</DialogTitle>
@ -292,25 +386,33 @@ export default function Home() {
{viewTask.description || "No description provided"}
</p>
</div>
<div className="flex gap-6 text-sm">
<div className="flex gap-4">
<div>
<span className="text-muted-foreground">Status: </span>
<span
className={
viewTask.completed
? "text-green-500"
: "text-yellow-500"
}
>
{viewTask.completed ? "Completed" : "Pending"}
</span>
<h4 className="text-sm font-medium text-muted-foreground mb-1">
Status
</h4>
{getStatusBadge(viewTask.status)}
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-1">
Priority
</h4>
{getPriorityBadge(viewTask.priority)}
</div>
</div>
<div className="flex gap-6 text-sm">
<div>
<span className="text-muted-foreground">Created: </span>
<span>
{new Date(viewTask.createdAt).toLocaleString()}
</span>
</div>
<div>
<span className="text-muted-foreground">Updated: </span>
<span>
{new Date(viewTask.updatedAt).toLocaleString()}
</span>
</div>
</div>
</div>
<DialogFooter className="mt-6">

View File

@ -0,0 +1,41 @@
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",
success:
"border-transparent bg-green-500/20 text-green-400 hover:bg-green-500/30",
warning:
"border-transparent bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30",
info: "border-transparent bg-blue-500/20 text-blue-400 hover:bg-blue-500/30",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@ -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<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@ -2,8 +2,10 @@ export interface Task {
id: number;
title: string;
description: string;
completed: boolean;
status: "pending" | "in-progress" | "completed";
priority: "low" | "medium" | "high";
createdAt: string;
updatedAt: string;
}
export interface APIResponse<T> {
@ -34,14 +36,16 @@ export async function getTask(id: number): Promise<Task> {
export async function createTask(
title: string,
description: string
description: string,
status: string = "pending",
priority: string = "medium"
): Promise<Task> {
const res = await fetch(`${API_BASE}/tasks`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ title, description }),
body: JSON.stringify({ title, description, status, priority }),
});
const json: APIResponse<Task> = await res.json();
if (!json.success) {