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 WORKDIR /app
# Copy go mod files # Copy go mod files and download dependencies
COPY go.mod go.sum* ./ COPY go.mod go.sum* ./
RUN go mod download RUN go mod download || go mod tidy
# Copy source code # Copy source code
COPY . . COPY . .
@ -25,7 +25,7 @@ RUN apk --no-cache add ca-certificates
COPY --from=builder /app/main . COPY --from=builder /app/main .
# Expose port # Expose port
EXPOSE 8081 EXPOSE 8080
# Run the binary # Run the binary
CMD ["./main"] CMD ["./main"]

View File

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

View File

@ -17,8 +17,10 @@ type Task struct {
ID int `json:"id"` ID int `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
Completed bool `json:"completed"` Status string `json:"status"`
Priority string `json:"priority"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
} }
type TaskStore struct { type TaskStore struct {
@ -53,16 +55,26 @@ func (s *TaskStore) Get(id int) (Task, bool) {
return task, ok return task, ok
} }
func (s *TaskStore) Create(title, description string) Task { func (s *TaskStore) Create(title, description, status, priority string) Task {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if status == "" {
status = "pending"
}
if priority == "" {
priority = "medium"
}
now := time.Now()
task := Task{ task := Task{
ID: s.nextID, ID: s.nextID,
Title: title, Title: title,
Description: description, Description: description,
Completed: false, Status: status,
CreatedAt: time.Now(), Priority: priority,
CreatedAt: now,
UpdatedAt: now,
} }
s.tasks[s.nextID] = task s.tasks[s.nextID] = task
s.nextID++ s.nextID++
@ -96,9 +108,10 @@ func main() {
store := NewTaskStore() store := NewTaskStore()
// Add some sample tasks // Add some sample tasks
store.Create("Welcome to Task Manager", "This is your first task. Click on it to see details!") store.Create("Set up development environment", "Install all necessary tools and dependencies for the project", "completed", "high")
store.Create("Add a new task", "Use the Add Task button to create new tasks") store.Create("Design database schema", "Create the initial database schema for the application", "in-progress", "high")
store.Create("Stay organized", "Keep track of all your tasks in one place") 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() r := chi.NewRouter()
@ -135,6 +148,8 @@ func main() {
var input struct { var input struct {
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
} }
if err := json.NewDecoder(r.Body).Decode(&input); err != nil { if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
@ -147,7 +162,7 @@ func main() {
return 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}) jsonResponse(w, http.StatusCreated, APIResponse{Success: true, Data: task})
}) })
@ -190,7 +205,7 @@ func main() {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: "OK"}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: "OK"})
}) })
port := ":8081" port := ":8080"
log.Printf("Backend server starting on %s", port) log.Printf("Backend server starting on %s", port)
if err := http.ListenAndServe(port, r); err != nil { if err := http.ListenAndServe(port, r); err != nil {
log.Fatal(err) log.Fatal(err)

View File

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

View File

@ -7,7 +7,7 @@ const nextConfig = {
source: '/api/:path*', source: '/api/:path*',
destination: process.env.BACKEND_URL destination: process.env.BACKEND_URL
? `${process.env.BACKEND_URL}/api/:path*` ? `${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", "tailwind-merge": "^2.3.0",
"lucide-react": "^0.378.0", "lucide-react": "^0.378.0",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.1.5" "@radix-ui/react-toast": "^1.1.5"
}, },

View File

@ -1,7 +1,15 @@
"use client"; "use client";
import { useState, useEffect } from "react"; 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 { Button } from "@/components/ui/button";
import { import {
Card, Card,
@ -21,8 +29,49 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } 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"; 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() { export default function Home() {
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -31,6 +80,8 @@ export default function Home() {
// Add task form state // Add task form state
const [newTitle, setNewTitle] = useState(""); const [newTitle, setNewTitle] = useState("");
const [newDescription, setNewDescription] = useState(""); const [newDescription, setNewDescription] = useState("");
const [newStatus, setNewStatus] = useState("pending");
const [newPriority, setNewPriority] = useState("medium");
const [addDialogOpen, setAddDialogOpen] = useState(false); const [addDialogOpen, setAddDialogOpen] = useState(false);
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
@ -65,10 +116,17 @@ export default function Home() {
try { try {
setAdding(true); setAdding(true);
const task = await createTask(newTitle.trim(), newDescription.trim()); const task = await createTask(
newTitle.trim(),
newDescription.trim(),
newStatus,
newPriority
);
setTasks((prev) => [...prev, task]); setTasks((prev) => [...prev, task]);
setNewTitle(""); setNewTitle("");
setNewDescription(""); setNewDescription("");
setNewStatus("pending");
setNewPriority("medium");
setAddDialogOpen(false); setAddDialogOpen(false);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to add task"); setError(err instanceof Error ? err.message : "Failed to add task");
@ -159,6 +217,43 @@ export default function Home() {
rows={3} rows={3}
/> />
</div> </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> </div>
<DialogFooter> <DialogFooter>
<Button type="submit" disabled={adding || !newTitle.trim()}> <Button type="submit" disabled={adding || !newTitle.trim()}>
@ -173,9 +268,12 @@ export default function Home() {
{/* Error Alert */} {/* Error Alert */}
{error && ( {error && (
<div className="bg-destructive/15 border border-destructive text-destructive px-4 py-3 rounded-lg mb-6"> <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 <button
className="float-right font-bold" className="float-right font-bold -mt-6"
onClick={() => setError(null)} onClick={() => setError(null)}
> >
&times; &times;
@ -216,11 +314,7 @@ export default function Home() {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1"> <div className="flex items-start gap-3 flex-1">
{task.completed ? ( {getStatusIcon(task.status)}
<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" />
)}
<CardTitle className="text-lg leading-tight"> <CardTitle className="text-lg leading-tight">
{task.title} {task.title}
</CardTitle> </CardTitle>
@ -232,9 +326,10 @@ export default function Home() {
</CardHeader> </CardHeader>
<CardContent className="pt-2"> <CardContent className="pt-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground"> <div className="flex gap-2">
{new Date(task.createdAt).toLocaleDateString()} {getStatusBadge(task.status)}
</span> {getPriorityBadge(task.priority)}
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="ghost" variant="ghost"
@ -254,6 +349,9 @@ export default function Home() {
</Button> </Button>
</div> </div>
</div> </div>
<div className="mt-2 text-xs text-muted-foreground">
{new Date(task.createdAt).toLocaleDateString()}
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
@ -273,11 +371,7 @@ export default function Home() {
<> <>
<DialogHeader> <DialogHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{viewTask.completed ? ( {getStatusIcon(viewTask.status)}
<CheckCircle2 className="h-6 w-6 text-green-500 flex-shrink-0" />
) : (
<Circle className="h-6 w-6 text-muted-foreground flex-shrink-0" />
)}
<DialogTitle className="text-xl"> <DialogTitle className="text-xl">
{viewTask.title} {viewTask.title}
</DialogTitle> </DialogTitle>
@ -292,25 +386,33 @@ export default function Home() {
{viewTask.description || "No description provided"} {viewTask.description || "No description provided"}
</p> </p>
</div> </div>
<div className="flex gap-6 text-sm"> <div className="flex gap-4">
<div> <div>
<span className="text-muted-foreground">Status: </span> <h4 className="text-sm font-medium text-muted-foreground mb-1">
<span Status
className={ </h4>
viewTask.completed {getStatusBadge(viewTask.status)}
? "text-green-500"
: "text-yellow-500"
}
>
{viewTask.completed ? "Completed" : "Pending"}
</span>
</div> </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> <div>
<span className="text-muted-foreground">Created: </span> <span className="text-muted-foreground">Created: </span>
<span> <span>
{new Date(viewTask.createdAt).toLocaleString()} {new Date(viewTask.createdAt).toLocaleString()}
</span> </span>
</div> </div>
<div>
<span className="text-muted-foreground">Updated: </span>
<span>
{new Date(viewTask.updatedAt).toLocaleString()}
</span>
</div>
</div> </div>
</div> </div>
<DialogFooter className="mt-6"> <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; id: number;
title: string; title: string;
description: string; description: string;
completed: boolean; status: "pending" | "in-progress" | "completed";
priority: "low" | "medium" | "high";
createdAt: string; createdAt: string;
updatedAt: string;
} }
export interface APIResponse<T> { export interface APIResponse<T> {
@ -34,14 +36,16 @@ export async function getTask(id: number): Promise<Task> {
export async function createTask( export async function createTask(
title: string, title: string,
description: string description: string,
status: string = "pending",
priority: string = "medium"
): Promise<Task> { ): Promise<Task> {
const res = await fetch(`${API_BASE}/tasks`, { const res = await fetch(`${API_BASE}/tasks`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ title, description }), body: JSON.stringify({ title, description, status, priority }),
}); });
const json: APIResponse<Task> = await res.json(); const json: APIResponse<Task> = await res.json();
if (!json.success) { if (!json.success) {