React Development · Lesson 12 of 15
Project: Task Manager
What You'll Build
A full-featured Task Manager (like a simplified Linear or Trello) with:
- User authentication (login/register)
- Projects with multiple task lists
- Drag-and-drop task reordering
- Task filtering, search, and sorting
- Optimistic UI updates (instant feedback, sync in background)
- Keyboard shortcuts
- Responsive design with Tailwind CSS
Tech stack:
- React 19 + TypeScript
- Vite (fast dev server)
- TanStack Query (server state)
- Zustand (UI state)
- React Hook Form + Zod (forms)
- React Router v6 (routing)
- Tailwind CSS (styling)
@dnd-kit(drag and drop)
Live preview of what you'll build:
┌─────────────────────────────────────────────────────────┐
│ 📋 TaskFlow [Search...] [Alice ▼] [+ New] │
├──────────┬──────────────────────────────────────────────┤
│ Projects │ 🏃 In Progress ✅ Done 📦 Backlog│
│ │ ┌─────────────────┐ ┌──────────┐ │
│ ▶ Design │ │ Design login UI │ │ Set up │ │
│ ▼ Dev │ │ @alice 🔴 High │ │ Vite │ │
│ ▶ API │ └─────────────────┘ └──────────┘ │
│ ▶ UI │ ┌─────────────────┐ │
│ ▶ Docs │ │ Write API docs │ │
│ │ │ @bob 🟡 Medium │ │
│ │ └─────────────────┘ │
└──────────┴──────────────────────────────────────────────┘Phase 1: Project Scaffolding
1.1 Create the Project
Bash
pnpm create vite@latest taskflow -- --template react-ts
cd taskflow1.2 Install Dependencies
Bash
# Core
pnpm add react-router-dom @tanstack/react-query @tanstack/react-query-devtools
pnpm add zustand react-hook-form zod @hookform/resolvers
# UI
pnpm add tailwind-merge clsx lucide-react @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
# Dev
pnpm add -D tailwindcss postcss autoprefixer @types/node
npx tailwindcss init -p1.3 Tailwind Setup
JavaScript
// tailwind.config.js
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
brand: {
50: "#eff6ff",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
},
},
},
},
};CSS
/* src/index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-gray-50 text-gray-900 antialiased;
}
}1.4 Folder Structure
src/
├── components/
│ ├── ui/ # Button, Input, Badge, Modal, Avatar
│ └── layout/ # AppShell, Sidebar, Header
├── features/
│ ├── auth/ # Login, Register, useAuth
│ ├── projects/ # ProjectList, ProjectForm
│ └── tasks/ # TaskBoard, TaskCard, TaskForm, DragDrop
├── hooks/ # useKeyboardShortcut, useDebounce
├── lib/ # cn(), api.ts, validators
├── store/ # Zustand stores
├── types/ # Global TypeScript types
└── router/ # Route definitions1.5 Utility: cn() Helper
TSX
// src/lib/cn.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}Phase 2: TypeScript Types
TSX
// src/types/index.ts
export type Priority = "low" | "medium" | "high" | "urgent";
export type TaskStatus = "backlog" | "in_progress" | "in_review" | "done";
export interface User {
id: string;
name: string;
email: string;
avatar: string | null;
}
export interface Project {
id: string;
name: string;
description: string;
color: string;
ownerId: string;
members: User[];
createdAt: string;
updatedAt: string;
}
export interface Task {
id: string;
title: string;
description: string | null;
status: TaskStatus;
priority: Priority;
projectId: string;
assigneeId: string | null;
assignee: User | null;
dueDate: string | null;
order: number;
labels: string[];
createdAt: string;
updatedAt: string;
}
export interface CreateTaskDto {
title: string;
description?: string;
status: TaskStatus;
priority: Priority;
projectId: string;
assigneeId?: string;
dueDate?: string;
labels?: string[];
}
export interface UpdateTaskDto extends Partial<CreateTaskDto> {
order?: number;
}
export interface ApiError {
message: string;
code: string;
statusCode: number;
}Phase 3: API Layer
TSX
// src/lib/api.ts
const BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8000";
class ApiClient {
private token: string | null = null;
setToken(token: string | null) {
this.token = token;
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const headers: HeadersInit = {
"Content-Type": "application/json",
...(this.token && { Authorization: `Bearer ${this.token}` }),
...options.headers,
};
const response = await fetch(`${BASE_URL}${endpoint}`, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({
message: "An unexpected error occurred",
code: "UNKNOWN",
statusCode: response.status,
}));
throw error;
}
// 204 No Content
if (response.status === 204) return undefined as T;
return response.json();
}
get<T>(endpoint: string) {
return this.request<T>(endpoint);
}
post<T>(endpoint: string, body: unknown) {
return this.request<T>(endpoint, {
method: "POST",
body: JSON.stringify(body),
});
}
put<T>(endpoint: string, body: unknown) {
return this.request<T>(endpoint, {
method: "PUT",
body: JSON.stringify(body),
});
}
patch<T>(endpoint: string, body: unknown) {
return this.request<T>(endpoint, {
method: "PATCH",
body: JSON.stringify(body),
});
}
delete<T>(endpoint: string) {
return this.request<T>(endpoint, { method: "DELETE" });
}
}
export const api = new ApiClient();Task API Functions
TSX
// src/features/tasks/api/taskApi.ts
import { api } from "@/lib/api";
import type { Task, CreateTaskDto, UpdateTaskDto } from "@/types";
export const taskApi = {
getByProject: (projectId: string) =>
api.get<Task[]>(`/projects/${projectId}/tasks`),
getById: (id: string) =>
api.get<Task>(`/tasks/${id}`),
create: (data: CreateTaskDto) =>
api.post<Task>("/tasks", data),
update: (id: string, data: UpdateTaskDto) =>
api.patch<Task>(`/tasks/${id}`, data),
reorder: (projectId: string, taskIds: string[]) =>
api.post<void>(`/projects/${projectId}/tasks/reorder`, { taskIds }),
delete: (id: string) =>
api.delete<void>(`/tasks/${id}`),
};Phase 4: Authentication
4.1 Auth Store (Zustand)
TSX
// src/store/authStore.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { api } from "@/lib/api";
import type { User } from "@/types";
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
register: (name: string, email: string, password: string) => Promise<void>;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false,
login: async (email, password) => {
const { token, user } = await api.post<{ token: string; user: User }>(
"/auth/login",
{ email, password }
);
api.setToken(token);
set({ user, token, isAuthenticated: true });
},
register: async (name, email, password) => {
const { token, user } = await api.post<{ token: string; user: User }>(
"/auth/register",
{ name, email, password }
);
api.setToken(token);
set({ user, token, isAuthenticated: true });
},
logout: () => {
api.setToken(null);
set({ user: null, token: null, isAuthenticated: false });
},
}),
{
name: "auth-storage",
onRehydrateStorage: () => (state) => {
// Restore token to API client when app loads
if (state?.token) api.setToken(state.token);
},
}
)
);4.2 Login Form with Validation
TSX
// src/features/auth/components/LoginForm.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/store/authStore";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
const loginSchema = z.object({
email: z.string().email("Enter a valid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
type LoginFormData = z.infer<typeof loginSchema>;
export function LoginForm() {
const navigate = useNavigate();
const login = useAuthStore((s) => s.login);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setError,
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginFormData) => {
try {
await login(data.email, data.password);
navigate("/dashboard");
} catch (err: any) {
setError("root", {
message: err.message ?? "Login failed. Check your credentials.",
});
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white p-8 rounded-xl shadow-sm border border-gray-200 w-full max-w-md">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Welcome back</h1>
<p className="text-gray-500 mt-1">Sign in to your TaskFlow account</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Input
label="Email"
type="email"
placeholder="alice@example.com"
error={errors.email?.message}
{...register("email")}
/>
<Input
label="Password"
type="password"
placeholder="••••••••"
error={errors.password?.message}
{...register("password")}
/>
{errors.root && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{errors.root.message}
</div>
)}
<Button
type="submit"
className="w-full"
loading={isSubmitting}
>
Sign In
</Button>
</form>
<p className="mt-4 text-center text-sm text-gray-500">
Don't have an account?{" "}
<a href="/register" className="text-brand-600 hover:underline font-medium">
Sign up free
</a>
</p>
</div>
</div>
);
}Phase 5: Reusable UI Components
Button Component
TSX
// src/components/ui/Button.tsx
import { forwardRef } from "react";
import { cn } from "@/lib/cn";
import { Loader2 } from "lucide-react";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "ghost" | "danger";
size?: "sm" | "md" | "lg";
loading?: boolean;
icon?: React.ReactNode;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = "primary",
size = "md",
loading = false,
icon,
children,
disabled,
className,
...props
},
ref
) => {
return (
<button
ref={ref}
disabled={disabled || loading}
className={cn(
"inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed",
{
"bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800":
variant === "primary",
"bg-white text-gray-700 border border-gray-300 hover:bg-gray-50":
variant === "secondary",
"text-gray-700 hover:bg-gray-100": variant === "ghost",
"bg-red-600 text-white hover:bg-red-700": variant === "danger",
},
{
"h-8 px-3 text-sm": size === "sm",
"h-10 px-4 text-sm": size === "md",
"h-12 px-6 text-base": size === "lg",
},
className
)}
{...props}
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : icon}
{children}
</button>
);
}
);
Button.displayName = "Button";Input Component
TSX
// src/components/ui/Input.tsx
import { forwardRef } from "react";
import { cn } from "@/lib/cn";
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
hint?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, hint, className, id, ...props }, ref) => {
const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
return (
<div className="space-y-1.5">
{label && (
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700">
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={cn(
"w-full px-3 py-2 text-sm border rounded-lg bg-white transition-colors",
"placeholder:text-gray-400",
"focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent",
error
? "border-red-300 focus:ring-red-500"
: "border-gray-300 hover:border-gray-400",
className
)}
{...props}
/>
{error && <p className="text-sm text-red-600">{error}</p>}
{hint && !error && <p className="text-sm text-gray-500">{hint}</p>}
</div>
);
}
);
Input.displayName = "Input";Badge Component
TSX
// src/components/ui/Badge.tsx
import { cn } from "@/lib/cn";
import type { Priority, TaskStatus } from "@/types";
interface PriorityBadgeProps {
priority: Priority;
}
const priorityConfig: Record<Priority, { label: string; className: string }> = {
low: { label: "Low", className: "bg-gray-100 text-gray-700" },
medium: { label: "Medium", className: "bg-yellow-100 text-yellow-800" },
high: { label: "High", className: "bg-orange-100 text-orange-800" },
urgent: { label: "Urgent", className: "bg-red-100 text-red-700" },
};
export function PriorityBadge({ priority }: PriorityBadgeProps) {
const config = priorityConfig[priority];
return (
<span
className={cn(
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
config.className
)}
>
{config.label}
</span>
);
}Phase 6: Task Board with React Query
6.1 Task Hooks
TSX
// src/features/tasks/hooks/useTasks.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { taskApi } from "../api/taskApi";
import type { CreateTaskDto, UpdateTaskDto } from "@/types";
import { toast } from "@/lib/toast";
export const taskKeys = {
all: ["tasks"] as const,
byProject: (projectId: string) => [...taskKeys.all, "project", projectId] as const,
detail: (id: string) => [...taskKeys.all, "detail", id] as const,
};
export function useProjectTasks(projectId: string) {
return useQuery({
queryKey: taskKeys.byProject(projectId),
queryFn: () => taskApi.getByProject(projectId),
staleTime: 30_000, // 30 seconds before refetch
});
}
export function useCreateTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateTaskDto) => taskApi.create(data),
onMutate: async (newTask) => {
// Cancel outgoing refetches to avoid overwriting optimistic update
await queryClient.cancelQueries({
queryKey: taskKeys.byProject(newTask.projectId),
});
// Snapshot the previous value
const previousTasks = queryClient.getQueryData(
taskKeys.byProject(newTask.projectId)
);
// Optimistically add the task
queryClient.setQueryData(
taskKeys.byProject(newTask.projectId),
(old: any) => [
...(old ?? []),
{
id: `temp-${Date.now()}`,
...newTask,
assignee: null,
order: 999,
labels: newTask.labels ?? [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
]
);
return { previousTasks };
},
onError: (err: any, newTask, context) => {
// Rollback on error
queryClient.setQueryData(
taskKeys.byProject(newTask.projectId),
context?.previousTasks
);
toast.error(err.message ?? "Failed to create task");
},
onSuccess: (task) => {
queryClient.invalidateQueries({
queryKey: taskKeys.byProject(task.projectId),
});
toast.success("Task created");
},
});
}
export function useUpdateTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateTaskDto }) =>
taskApi.update(id, data),
onSuccess: (updatedTask) => {
queryClient.invalidateQueries({
queryKey: taskKeys.byProject(updatedTask.projectId),
});
},
onError: (err: any) => {
toast.error(err.message ?? "Failed to update task");
},
});
}
export function useDeleteTask(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => taskApi.delete(id),
onMutate: async (deletedId) => {
await queryClient.cancelQueries({ queryKey: taskKeys.byProject(projectId) });
const prev = queryClient.getQueryData(taskKeys.byProject(projectId));
queryClient.setQueryData(
taskKeys.byProject(projectId),
(old: any) => old?.filter((t: any) => t.id !== deletedId) ?? []
);
return { prev };
},
onError: (err: any, _, context) => {
queryClient.setQueryData(taskKeys.byProject(projectId), context?.prev);
toast.error(err.message ?? "Failed to delete task");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: taskKeys.byProject(projectId) });
},
});
}6.2 Task Board Component
TSX
// src/features/tasks/components/TaskBoard.tsx
import { useState, useMemo } from "react";
import { Plus } from "lucide-react";
import { useProjectTasks } from "../hooks/useTasks";
import { TaskColumn } from "./TaskColumn";
import { TaskForm } from "./TaskForm";
import { Button } from "@/components/ui/Button";
import type { Task, TaskStatus } from "@/types";
const COLUMNS: { id: TaskStatus; label: string; color: string }[] = [
{ id: "backlog", label: "Backlog", color: "bg-gray-400" },
{ id: "in_progress", label: "In Progress", color: "bg-blue-500" },
{ id: "in_review", label: "In Review", color: "bg-yellow-500" },
{ id: "done", label: "Done", color: "bg-green-500" },
];
interface TaskBoardProps {
projectId: string;
}
export function TaskBoard({ projectId }: TaskBoardProps) {
const { data: tasks, isLoading, isError } = useProjectTasks(projectId);
const [search, setSearch] = useState("");
const [priorityFilter, setPriorityFilter] = useState<string>("all");
const [isCreating, setIsCreating] = useState(false);
const filteredTasks = useMemo(() => {
if (!tasks) return [];
return tasks.filter((task) => {
const matchesSearch =
!search || task.title.toLowerCase().includes(search.toLowerCase());
const matchesPriority =
priorityFilter === "all" || task.priority === priorityFilter;
return matchesSearch && matchesPriority;
});
}, [tasks, search, priorityFilter]);
const tasksByStatus = useMemo(() => {
const grouped: Record<TaskStatus, Task[]> = {
backlog: [],
in_progress: [],
in_review: [],
done: [],
};
filteredTasks.forEach((task) => {
grouped[task.status].push(task);
});
// Sort by order within each column
Object.values(grouped).forEach((col) =>
col.sort((a, b) => a.order - b.order)
);
return grouped;
}, [filteredTasks]);
if (isLoading) return <TaskBoardSkeleton />;
if (isError) return <BoardError onRetry={() => {}} />;
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="flex items-center gap-3 p-4 border-b border-gray-200 bg-white">
<input
type="search"
placeholder="Search tasks..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 max-w-xs px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
<select
value={priorityFilter}
onChange={(e) => setPriorityFilter(e.target.value)}
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="all">All Priorities</option>
<option value="urgent">Urgent</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
<div className="ml-auto">
<Button icon={<Plus className="h-4 w-4" />} onClick={() => setIsCreating(true)}>
New Task
</Button>
</div>
</div>
{/* Board */}
<div className="flex gap-4 p-4 flex-1 overflow-x-auto">
{COLUMNS.map((column) => (
<TaskColumn
key={column.id}
column={column}
tasks={tasksByStatus[column.id]}
projectId={projectId}
/>
))}
</div>
{/* Create Task Modal */}
{isCreating && (
<TaskForm
projectId={projectId}
onClose={() => setIsCreating(false)}
/>
)}
</div>
);
}
function TaskBoardSkeleton() {
return (
<div className="flex gap-4 p-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="w-72 shrink-0">
<div className="h-8 bg-gray-200 rounded animate-pulse mb-4" />
{[1, 2, 3].map((j) => (
<div key={j} className="h-24 bg-gray-100 rounded-lg animate-pulse mb-2" />
))}
</div>
))}
</div>
);
}6.3 Task Card
TSX
// src/features/tasks/components/TaskCard.tsx
import { useState } from "react";
import { MoreHorizontal, Trash2, Edit } from "lucide-react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { PriorityBadge } from "@/components/ui/Badge";
import { useDeleteTask } from "../hooks/useTasks";
import type { Task } from "@/types";
import { cn } from "@/lib/cn";
import { format } from "date-fns";
interface TaskCardProps {
task: Task;
onClick: () => void;
}
export function TaskCard({ task, onClick }: TaskCardProps) {
const [menuOpen, setMenuOpen] = useState(false);
const { mutate: deleteTask } = useDeleteTask(task.projectId);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: task.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const isOverdue =
task.dueDate && new Date(task.dueDate) < new Date() && task.status !== "done";
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={cn(
"bg-white rounded-lg border border-gray-200 p-3 cursor-grab active:cursor-grabbing",
"hover:border-gray-300 hover:shadow-sm transition-all group",
isDragging && "opacity-50 shadow-lg rotate-2"
)}
onClick={onClick}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-gray-900 line-clamp-2">
{task.title}
</p>
<button
onClick={(e) => {
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-gray-100 rounded transition-opacity"
>
<MoreHorizontal className="h-4 w-4 text-gray-500" />
</button>
</div>
{task.description && (
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
{task.description}
</p>
)}
<div className="flex items-center gap-2 mt-3">
<PriorityBadge priority={task.priority} />
{task.dueDate && (
<span
className={cn(
"text-xs",
isOverdue ? "text-red-600 font-medium" : "text-gray-500"
)}
>
{isOverdue ? "⚠ " : ""}
{format(new Date(task.dueDate), "MMM d")}
</span>
)}
{task.assignee && (
<div className="ml-auto">
{task.assignee.avatar ? (
<img
src={task.assignee.avatar}
alt={task.assignee.name}
className="h-6 w-6 rounded-full"
title={task.assignee.name}
/>
) : (
<div className="h-6 w-6 rounded-full bg-brand-100 text-brand-700 text-xs flex items-center justify-center font-medium">
{task.assignee.name[0].toUpperCase()}
</div>
)}
</div>
)}
</div>
{/* Context menu */}
{menuOpen && (
<div
className="absolute right-0 top-8 z-10 bg-white border border-gray-200 rounded-lg shadow-lg py-1 w-36"
onClick={(e) => e.stopPropagation()}
>
<button
className="w-full px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
onClick={() => { setMenuOpen(false); onClick(); }}
>
<Edit className="h-3.5 w-3.5" /> Edit
</button>
<button
className="w-full px-3 py-1.5 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
onClick={() => {
if (confirm("Delete this task?")) deleteTask(task.id);
setMenuOpen(false);
}}
>
<Trash2 className="h-3.5 w-3.5" /> Delete
</button>
</div>
)}
</div>
);
}Phase 7: Drag and Drop
TSX
// src/features/tasks/components/TaskColumn.tsx
import { useDroppable } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { TaskCard } from "./TaskCard";
import { cn } from "@/lib/cn";
import type { Task, TaskStatus } from "@/types";
interface TaskColumnProps {
column: { id: TaskStatus; label: string; color: string };
tasks: Task[];
projectId: string;
}
export function TaskColumn({ column, tasks, projectId }: TaskColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id: column.id });
return (
<div className="w-72 shrink-0 flex flex-col">
{/* Column header */}
<div className="flex items-center gap-2 mb-3">
<div className={cn("h-2.5 w-2.5 rounded-full", column.color)} />
<h3 className="text-sm font-semibold text-gray-700">{column.label}</h3>
<span className="ml-auto text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
{tasks.length}
</span>
</div>
{/* Drop zone */}
<div
ref={setNodeRef}
className={cn(
"flex-1 flex flex-col gap-2 min-h-16 rounded-lg p-1 transition-colors",
isOver && "bg-brand-50 ring-2 ring-brand-200"
)}
>
<SortableContext
items={tasks.map((t) => t.id)}
strategy={verticalListSortingStrategy}
>
{tasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onClick={() => {/* open task detail */}}
/>
))}
</SortableContext>
{tasks.length === 0 && !isOver && (
<div className="flex-1 flex items-center justify-center text-xs text-gray-400 border-2 border-dashed border-gray-200 rounded-lg py-8">
Drop tasks here
</div>
)}
</div>
</div>
);
}
// Wrapping everything with DndContext in the board
// src/features/tasks/components/TaskBoard.tsx (DnD addition)
import {
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
closestCorners,
} from "@dnd-kit/core";
// Inside TaskBoard component, add drag handlers:
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 }, // Don't start drag on click
})
);
const [activeTask, setActiveTask] = useState<Task | null>(null);
const updateTask = useUpdateTask();
const handleDragStart = ({ active }: DragStartEvent) => {
const task = tasks?.find((t) => t.id === active.id);
setActiveTask(task ?? null);
};
const handleDragEnd = ({ active, over }: DragEndEvent) => {
setActiveTask(null);
if (!over || active.id === over.id) return;
const newStatus = over.id as TaskStatus;
const isColumn = COLUMNS.some((c) => c.id === newStatus);
if (isColumn) {
updateTask.mutate({
id: active.id as string,
data: { status: newStatus },
});
}
};Phase 8: Router Setup
TSX
// src/router/index.tsx
import { createBrowserRouter, Navigate } from "react-router-dom";
import { AppShell } from "@/components/layout/AppShell";
import { LoginPage } from "@/features/auth/pages/LoginPage";
import { RegisterPage } from "@/features/auth/pages/RegisterPage";
import { DashboardPage } from "@/features/projects/pages/DashboardPage";
import { ProjectPage } from "@/features/projects/pages/ProjectPage";
import { useAuthStore } from "@/store/authStore";
function RequireAuth({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
return isAuthenticated ? children : <Navigate to="/login" replace />;
}
export const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
{ path: "/register", element: <RegisterPage /> },
{
path: "/",
element: <RequireAuth><AppShell /></RequireAuth>,
children: [
{ index: true, element: <Navigate to="/dashboard" replace /> },
{ path: "dashboard", element: <DashboardPage /> },
{ path: "projects/:projectId", element: <ProjectPage /> },
],
},
]);Phase 9: App Entry Point
TSX
// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { router } from "./router";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // 1 minute
retry: (failureCount, error: any) => {
if (error?.statusCode === 401 || error?.statusCode === 403) return false;
return failureCount < 3;
},
},
mutations: {
retry: false,
},
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</StrictMode>
);Phase 10: What to Build Next
You have the foundation. Here's what to add to make it production-ready:
Feature | Key Concepts Practiced
──────────────────┼──────────────────────────────────────────
Task Detail Modal │ Portal, React.memo, complex form state
Comments │ Real-time updates, WebSocket, optimistic UI
File Attachments │ Multipart upload, progress tracking
Notifications │ Context, polling or WebSocket, toast system
Team Management │ RBAC, admin-only routes, conditional UI
Dark Mode │ CSS variables, context, localStorage
Export to PDF/CSV │ Browser APIs, Blob, URL.createObjectURL
Keyboard Shortcuts│ useEffect, event delegation, useKeyboardRunning the Project
Bash
# Development
pnpm dev
# Type checking
pnpm type-check
# Build for production
pnpm build
# Preview production build locally
pnpm previewOpen http://localhost:3000 — you have a working task management app.