build: Build a full-stack task management application with the following str...
This commit is contained in:
parent
c7f7f656c2
commit
c800501726
@ -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"]
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
module github.com/jordan/fs3/backend
|
module backend
|
||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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*',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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)}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
@ -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">
|
||||||
|
|||||||
41
frontend/src/components/ui/badge.tsx
Normal file
41
frontend/src/components/ui/badge.tsx
Normal 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 };
|
||||||
160
frontend/src/components/ui/select.tsx
Normal file
160
frontend/src/components/ui/select.tsx
Normal 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,
|
||||||
|
};
|
||||||
@ -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) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user