336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
"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<Task[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(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<Task | null>(null);
|
|
const [viewDialogOpen, setViewDialogOpen] = useState(false);
|
|
const [viewLoading, setViewLoading] = useState(false);
|
|
|
|
// Delete state
|
|
const [deletingId, setDeletingId] = useState<number | null>(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 (
|
|
<main className="min-h-screen bg-gradient-to-b from-background to-background/95">
|
|
<div className="container mx-auto py-10 px-4">
|
|
{/* Header */}
|
|
<div className="flex justify-between items-center mb-8">
|
|
<div>
|
|
<h1 className="text-4xl font-bold tracking-tight">Task Manager</h1>
|
|
<p className="text-muted-foreground mt-1">
|
|
Organize and track your tasks efficiently
|
|
</p>
|
|
</div>
|
|
|
|
{/* Add Task Dialog */}
|
|
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button size="lg" className="gap-2">
|
|
<Plus className="h-5 w-5" />
|
|
Add Task
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="sm:max-w-[425px]">
|
|
<form onSubmit={handleAddTask}>
|
|
<DialogHeader>
|
|
<DialogTitle>Add New Task</DialogTitle>
|
|
<DialogDescription>
|
|
Create a new task to track. Fill in the details below.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid gap-4 py-4">
|
|
<div className="grid gap-2">
|
|
<label htmlFor="title" className="text-sm font-medium">
|
|
Title
|
|
</label>
|
|
<Input
|
|
id="title"
|
|
placeholder="Enter task title..."
|
|
value={newTitle}
|
|
onChange={(e) => setNewTitle(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label
|
|
htmlFor="description"
|
|
className="text-sm font-medium"
|
|
>
|
|
Description
|
|
</label>
|
|
<Textarea
|
|
id="description"
|
|
placeholder="Enter task description..."
|
|
value={newDescription}
|
|
onChange={(e) => setNewDescription(e.target.value)}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button type="submit" disabled={adding || !newTitle.trim()}>
|
|
{adding ? "Adding..." : "Add Task"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
|
|
{/* Error Alert */}
|
|
{error && (
|
|
<div className="bg-destructive/15 border border-destructive text-destructive px-4 py-3 rounded-lg mb-6">
|
|
{error}
|
|
<button
|
|
className="float-right font-bold"
|
|
onClick={() => setError(null)}
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Loading State */}
|
|
{loading && (
|
|
<div className="flex justify-center py-20">
|
|
<div className="animate-pulse text-muted-foreground">
|
|
Loading tasks...
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty State */}
|
|
{!loading && tasks.length === 0 && (
|
|
<Card className="border-dashed">
|
|
<CardContent className="flex flex-col items-center justify-center py-16">
|
|
<Circle className="h-12 w-12 text-muted-foreground mb-4" />
|
|
<h3 className="text-lg font-medium">No tasks yet</h3>
|
|
<p className="text-muted-foreground text-center mt-1">
|
|
Get started by adding your first task
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Task Grid */}
|
|
{!loading && tasks.length > 0 && (
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
{tasks.map((task) => (
|
|
<Card
|
|
key={task.id}
|
|
className="group hover:shadow-lg transition-all duration-200 hover:border-primary/50"
|
|
>
|
|
<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" />
|
|
)}
|
|
<CardTitle className="text-lg leading-tight">
|
|
{task.title}
|
|
</CardTitle>
|
|
</div>
|
|
</div>
|
|
<CardDescription className="pl-8 line-clamp-2">
|
|
{task.description || "No description"}
|
|
</CardDescription>
|
|
</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">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleViewTask(task.id)}
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
onClick={() => handleDeleteTask(task.id)}
|
|
disabled={deletingId === task.id}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* View Task Dialog */}
|
|
<Dialog open={viewDialogOpen} onOpenChange={setViewDialogOpen}>
|
|
<DialogContent className="sm:max-w-[525px]">
|
|
{viewLoading ? (
|
|
<div className="py-8 text-center">
|
|
<div className="animate-pulse text-muted-foreground">
|
|
Loading task...
|
|
</div>
|
|
</div>
|
|
) : viewTask ? (
|
|
<>
|
|
<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" />
|
|
)}
|
|
<DialogTitle className="text-xl">
|
|
{viewTask.title}
|
|
</DialogTitle>
|
|
</div>
|
|
</DialogHeader>
|
|
<div className="mt-4 space-y-4">
|
|
<div>
|
|
<h4 className="text-sm font-medium text-muted-foreground mb-1">
|
|
Description
|
|
</h4>
|
|
<p className="text-sm">
|
|
{viewTask.description || "No description provided"}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-6 text-sm">
|
|
<div>
|
|
<span className="text-muted-foreground">Status: </span>
|
|
<span
|
|
className={
|
|
viewTask.completed
|
|
? "text-green-500"
|
|
: "text-yellow-500"
|
|
}
|
|
>
|
|
{viewTask.completed ? "Completed" : "Pending"}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">Created: </span>
|
|
<span>
|
|
{new Date(viewTask.createdAt).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="mt-6">
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => {
|
|
handleDeleteTask(viewTask.id);
|
|
setViewDialogOpen(false);
|
|
}}
|
|
>
|
|
<Trash2 className="h-4 w-4 mr-2" />
|
|
Delete Task
|
|
</Button>
|
|
</DialogFooter>
|
|
</>
|
|
) : null}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|