Build Project: Todo App (Part 2)
1. Frontend Overview
Trong Part 2 này, chúng ta sẽ build Frontend cho Todo App:
Frontend Build Process
Component Structure
Component Hierarchy
App
Header
Logo
Filters
Main
AddTodoForm
TodoList
TodoItem
Sidebar
Categories
Stats
2. Project Setup
2.1 Create Vite Project
Sử dụng Agent Mode:
Text
1Create a React + TypeScript project with Vite for a Todo App frontend:2 3Structure:4todo-frontend/5├── src/6│ ├── main.tsx7│ ├── App.tsx8│ ├── index.css9│ ├── api/10│ │ └── todoApi.ts11│ ├── components/12│ │ ├── AddTodoForm.tsx13│ │ ├── TodoList.tsx14│ │ ├── TodoItem.tsx15│ │ ├── Filters.tsx16│ │ └── CategorySidebar.tsx17│ ├── hooks/18│ │ └── useTodos.ts19│ ├── types/20│ │ └── todo.ts21│ └── utils/22│ └── constants.ts23├── index.html24├── package.json25├── tailwind.config.js26├── postcss.config.js27├── tsconfig.json28└── vite.config.ts29 30Setup:31- React 1832- TypeScript33- Tailwind CSS34- React Query for data fetching2.2 Install Dependencies
Bash
1npm create vite@latest todo-frontend -- --template react-ts2cd todo-frontend3npm install4npm install @tanstack/react-query axios5npm install -D tailwindcss postcss autoprefixer6npx tailwindcss init -p3. Type Definitions
3.1 Todo Types
File src/types/todo.ts:
TypeScript
1// TypeScript types matching our backend API:2// - Priority enum3// - Todo interface4// - CreateTodoRequest5// - UpdateTodoRequest6// - TodoListResponseCopilot generates:
TypeScript
1export enum Priority {2 LOW = 'low',3 MEDIUM = 'medium',4 HIGH = 'high',5}67export interface Todo {8 id: number;9 title: string;10 description: string | null;11 completed: boolean;12 priority: Priority;13 due_date: string | null;14 category: string | null;15 created_at: string;16 updated_at: string;17}1819export interface CreateTodoRequest {20 title: string;21 description?: string;22 priority?: Priority;23 due_date?: string;24 category?: string;25}2627export interface UpdateTodoRequest {28 title?: string;29 description?: string;30 completed?: boolean;31 priority?: Priority;32 due_date?: string;33 category?: string;34}3536export interface TodoListResponse {37 items: Todo[];38 total: number;39 page: number;40 page_size: number;41 has_next: boolean;42}4344export interface TodoFilters {45 page?: number;46 page_size?: number;47 completed?: boolean;48 priority?: Priority;49 category?: string;50 search?: string;51}4. API Layer
4.1 Todo API Client
File src/api/todoApi.ts:
TypeScript
1// Axios client for Todo API:2// - getTodos(filters)3// - getTodo(id)4// - createTodo(data)5// - updateTodo(id, data)6// - deleteTodo(id)7// - toggleTodo(id)8// - getCategories()Copilot generates:
TypeScript
1import axios from 'axios';2import {3 Todo,4 CreateTodoRequest,5 UpdateTodoRequest,6 TodoListResponse,7 TodoFilters,8} from '../types/todo';910const API_BASE = 'http://localhost:8000/api';1112const api = axios.create({13 baseURL: API_BASE,14 headers: {15 'Content-Type': 'application/json',16 },17});1819export const todoApi = {20 async getTodos(filters: TodoFilters = {}): Promise<TodoListResponse> {21 const params = new URLSearchParams();22 23 if (filters.page) params.append('page', filters.page.toString());24 if (filters.page_size) params.append('page_size', filters.page_size.toString());25 if (filters.completed !== undefined) params.append('completed', filters.completed.toString());26 if (filters.priority) params.append('priority', filters.priority);27 if (filters.category) params.append('category', filters.category);28 if (filters.search) params.append('search', filters.search);29 30 const response = await api.get<TodoListResponse>(`/todos?${params}`);31 return response.data;32 },3334 async getTodo(id: number): Promise<Todo> {35 const response = await api.get<Todo>(`/todos/${id}`);36 return response.data;37 },3839 async createTodo(data: CreateTodoRequest): Promise<Todo> {40 const response = await api.post<Todo>('/todos', data);41 return response.data;42 },4344 async updateTodo(id: number, data: UpdateTodoRequest): Promise<Todo> {45 const response = await api.put<Todo>(`/todos/${id}`, data);46 return response.data;47 },4849 async deleteTodo(id: number): Promise<void> {50 await api.delete(`/todos/${id}`);51 },5253 async toggleTodo(id: number): Promise<Todo> {54 const response = await api.post<Todo>(`/todos/${id}/toggle`);55 return response.data;56 },5758 async getCategories(): Promise<string[]> {59 const response = await api.get<string[]>('/todos/categories');60 return response.data;61 },62};5. Custom Hook
5.1 useTodos Hook
File src/hooks/useTodos.ts:
TypeScript
1// Custom hook using React Query for:2// - Fetching todos with filters3// - Create, update, delete mutations4// - Toggle mutation5// - Optimistic updatesCopilot generates:
TypeScript
1import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';2import { todoApi } from '../api/todoApi';3import { CreateTodoRequest, UpdateTodoRequest, TodoFilters, Todo } from '../types/todo';45export function useTodos(filters: TodoFilters = {}) {6 const queryClient = useQueryClient();7 8 // Query9 const todosQuery = useQuery({10 queryKey: ['todos', filters],11 queryFn: () => todoApi.getTodos(filters),12 });13 14 // Mutations15 const createMutation = useMutation({16 mutationFn: (data: CreateTodoRequest) => todoApi.createTodo(data),17 onSuccess: () => {18 queryClient.invalidateQueries({ queryKey: ['todos'] });19 queryClient.invalidateQueries({ queryKey: ['categories'] });20 },21 });22 23 const updateMutation = useMutation({24 mutationFn: ({ id, data }: { id: number; data: UpdateTodoRequest }) =>25 todoApi.updateTodo(id, data),26 onSuccess: () => {27 queryClient.invalidateQueries({ queryKey: ['todos'] });28 },29 });30 31 const deleteMutation = useMutation({32 mutationFn: (id: number) => todoApi.deleteTodo(id),33 onSuccess: () => {34 queryClient.invalidateQueries({ queryKey: ['todos'] });35 },36 });37 38 const toggleMutation = useMutation({39 mutationFn: (id: number) => todoApi.toggleTodo(id),40 // Optimistic update41 onMutate: async (id) => {42 await queryClient.cancelQueries({ queryKey: ['todos'] });43 44 const previousTodos = queryClient.getQueryData(['todos', filters]);45 46 queryClient.setQueryData(['todos', filters], (old: any) => ({47 ...old,48 items: old.items.map((todo: Todo) =>49 todo.id === id ? { ...todo, completed: !todo.completed } : todo50 ),51 }));52 53 return { previousTodos };54 },55 onError: (err, id, context) => {56 queryClient.setQueryData(['todos', filters], context?.previousTodos);57 },58 onSettled: () => {59 queryClient.invalidateQueries({ queryKey: ['todos'] });60 },61 });62 63 return {64 todos: todosQuery.data?.items ?? [],65 total: todosQuery.data?.total ?? 0,66 isLoading: todosQuery.isLoading,67 error: todosQuery.error,68 createTodo: createMutation.mutate,69 updateTodo: updateMutation.mutate,70 deleteTodo: deleteMutation.mutate,71 toggleTodo: toggleMutation.mutate,72 isCreating: createMutation.isPending,73 };74}7576export function useCategories() {77 return useQuery({78 queryKey: ['categories'],79 queryFn: () => todoApi.getCategories(),80 });81}6. UI Components
6.1 AddTodoForm
File src/components/AddTodoForm.tsx:
TypeScript
1// Form component with:2// - Title input (required)3// - Description textarea4// - Priority select5// - Category input6// - Due date picker7// - Submit buttonCopilot generates:
tsx
1import { useState } from 'react';2import { Priority, CreateTodoRequest } from '../types/todo';34interface Props {5 onSubmit: (data: CreateTodoRequest) => void;6 isLoading?: boolean;7}89export function AddTodoForm({ onSubmit, isLoading }: Props) {10 const [title, setTitle] = useState('');11 const [description, setDescription] = useState('');12 const [priority, setPriority] = useState<Priority>(Priority.MEDIUM);13 const [category, setCategory] = useState('');14 const [dueDate, setDueDate] = useState('');15 const [isExpanded, setIsExpanded] = useState(false);1617 const handleSubmit = (e: React.FormEvent) => {18 e.preventDefault();19 if (!title.trim()) return;2021 onSubmit({22 title: title.trim(),23 description: description.trim() || undefined,24 priority,25 category: category.trim() || undefined,26 due_date: dueDate || undefined,27 });2829 // Reset form30 setTitle('');31 setDescription('');32 setPriority(Priority.MEDIUM);33 setCategory('');34 setDueDate('');35 setIsExpanded(false);36 };3738 return (39 <form onSubmit={handleSubmit} className="bg-white rounded-lg shadow p-4 mb-6">40 <div className="flex gap-2 mb-2">41 <input42 type="text"43 placeholder="What needs to be done?"44 value={title}45 onChange={(e) => setTitle(e.target.value)}46 className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"47 disabled={isLoading}48 />49 <button50 type="submit"51 disabled={!title.trim() || isLoading}52 className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"53 >54 {isLoading ? 'Adding...' : 'Add'}55 </button>56 </div>5758 <button59 type="button"60 onClick={() => setIsExpanded(!isExpanded)}61 className="text-sm text-gray-500 hover:text-gray-700"62 >63 {isExpanded ? '- Less options' : '+ More options'}64 </button>6566 {isExpanded && (67 <div className="mt-4 space-y-4">68 <textarea69 placeholder="Description (optional)"70 value={description}71 onChange={(e) => setDescription(e.target.value)}72 className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"73 rows={2}74 />7576 <div className="flex gap-4">77 <div className="flex-1">78 <label className="block text-sm text-gray-600 mb-1">Priority</label>79 <select80 value={priority}81 onChange={(e) => setPriority(e.target.value as Priority)}82 className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"83 >84 <option value={Priority.LOW}>Low</option>85 <option value={Priority.MEDIUM}>Medium</option>86 <option value={Priority.HIGH}>High</option>87 </select>88 </div>8990 <div className="flex-1">91 <label className="block text-sm text-gray-600 mb-1">Category</label>92 <input93 type="text"94 placeholder="e.g., Work, Personal"95 value={category}96 onChange={(e) => setCategory(e.target.value)}97 className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"98 />99 </div>100101 <div className="flex-1">102 <label className="block text-sm text-gray-600 mb-1">Due Date</label>103 <input104 type="date"105 value={dueDate}106 onChange={(e) => setDueDate(e.target.value)}107 className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"108 />109 </div>110 </div>111 </div>112 )}113 </form>114 );115}6.2 TodoItem
File src/components/TodoItem.tsx:
tsx
1import { useState } from 'react';2import { Todo, Priority, UpdateTodoRequest } from '../types/todo';34interface Props {5 todo: Todo;6 onToggle: (id: number) => void;7 onUpdate: (data: { id: number; data: UpdateTodoRequest }) => void;8 onDelete: (id: number) => void;9}1011const priorityColors = {12 [Priority.LOW]: 'bg-green-100 text-green-800',13 [Priority.MEDIUM]: 'bg-yellow-100 text-yellow-800',14 [Priority.HIGH]: 'bg-red-100 text-red-800',15};1617export function TodoItem({ todo, onToggle, onUpdate, onDelete }: Props) {18 const [isEditing, setIsEditing] = useState(false);19 const [editTitle, setEditTitle] = useState(todo.title);2021 const handleSave = () => {22 if (editTitle.trim() && editTitle !== todo.title) {23 onUpdate({ id: todo.id, data: { title: editTitle.trim() } });24 }25 setIsEditing(false);26 };2728 const handleKeyDown = (e: React.KeyboardEvent) => {29 if (e.key === 'Enter') handleSave();30 if (e.key === 'Escape') {31 setEditTitle(todo.title);32 setIsEditing(false);33 }34 };3536 const formatDate = (dateStr: string | null) => {37 if (!dateStr) return null;38 return new Date(dateStr).toLocaleDateString();39 };4041 const isOverdue = todo.due_date && new Date(todo.due_date) < new Date() && !todo.completed;4243 return (44 <div45 className={`flex items-center gap-3 p-4 bg-white rounded-lg shadow hover:shadow-md transition-shadow ${46 todo.completed ? 'opacity-60' : ''47 }`}48 >49 {/* Checkbox */}50 <button51 onClick={() => onToggle(todo.id)}52 className={`w-6 h-6 rounded-full border-2 flex items-center justify-center ${53 todo.completed54 ? 'bg-green-500 border-green-500 text-white'55 : 'border-gray-300 hover:border-green-500'56 }`}57 >58 {todo.completed && (59 <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">60 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />61 </svg>62 )}63 </button>6465 {/* Content */}66 <div className="flex-1 min-w-0">67 {isEditing ? (68 <input69 type="text"70 value={editTitle}71 onChange={(e) => setEditTitle(e.target.value)}72 onBlur={handleSave}73 onKeyDown={handleKeyDown}74 className="w-full px-2 py-1 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"75 autoFocus76 />77 ) : (78 <div79 className={`font-medium ${todo.completed ? 'line-through text-gray-400' : ''}`}80 onDoubleClick={() => setIsEditing(true)}81 >82 {todo.title}83 </div>84 )}8586 {todo.description && (87 <div className="text-sm text-gray-500 truncate">{todo.description}</div>88 )}8990 <div className="flex items-center gap-2 mt-1">91 {/* Priority badge */}92 <span className={`text-xs px-2 py-0.5 rounded ${priorityColors[todo.priority]}`}>93 {todo.priority}94 </span>9596 {/* Category */}97 {todo.category && (98 <span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600">99 {todo.category}100 </span>101 )}102103 {/* Due date */}104 {todo.due_date && (105 <span className={`text-xs ${isOverdue ? 'text-red-500 font-medium' : 'text-gray-500'}`}>106 �� {formatDate(todo.due_date)}107 {isOverdue && ' (Overdue)'}108 </span>109 )}110 </div>111 </div>112113 {/* Actions */}114 <div className="flex gap-2">115 <button116 onClick={() => setIsEditing(true)}117 className="p-2 text-gray-400 hover:text-blue-500"118 title="Edit"119 >120 ✏️121 </button>122 <button123 onClick={() => onDelete(todo.id)}124 className="p-2 text-gray-400 hover:text-red-500"125 title="Delete"126 >127 ��️128 </button>129 </div>130 </div>131 );132}6.3 TodoList
File src/components/TodoList.tsx:
tsx
1import { Todo, UpdateTodoRequest } from '../types/todo';2import { TodoItem } from './TodoItem';34interface Props {5 todos: Todo[];6 isLoading: boolean;7 onToggle: (id: number) => void;8 onUpdate: (data: { id: number; data: UpdateTodoRequest }) => void;9 onDelete: (id: number) => void;10}1112export function TodoList({ todos, isLoading, onToggle, onUpdate, onDelete }: Props) {13 if (isLoading) {14 return (15 <div className="flex justify-center items-center py-12">16 <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>17 </div>18 );19 }2021 if (todos.length === 0) {22 return (23 <div className="text-center py-12 text-gray-500">24 <div className="text-4xl mb-2">��</div>25 <p>No todos yet. Add one above!</p>26 </div>27 );28 }2930 return (31 <div className="space-y-3">32 {todos.map((todo) => (33 <TodoItem34 key={todo.id}35 todo={todo}36 onToggle={onToggle}37 onUpdate={onUpdate}38 onDelete={onDelete}39 />40 ))}41 </div>42 );43}6.4 Filters
File src/components/Filters.tsx:
tsx
1import { Priority, TodoFilters } from '../types/todo';23interface Props {4 filters: TodoFilters;5 onFilterChange: (filters: TodoFilters) => void;6 categories: string[];7}89export function Filters({ filters, onFilterChange, categories }: Props) {10 return (11 <div className="bg-white rounded-lg shadow p-4 mb-6">12 <div className="flex flex-wrap gap-4">13 {/* Search */}14 <div className="flex-1 min-w-[200px]">15 <input16 type="text"17 placeholder="Search todos..."18 value={filters.search || ''}19 onChange={(e) => onFilterChange({ ...filters, search: e.target.value || undefined })}20 className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"21 />22 </div>2324 {/* Status filter */}25 <select26 value={filters.completed === undefined ? 'all' : filters.completed.toString()}27 onChange={(e) => {28 const value = e.target.value;29 onFilterChange({30 ...filters,31 completed: value === 'all' ? undefined : value === 'true',32 });33 }}34 className="px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"35 >36 <option value="all">All Status</option>37 <option value="false">Active</option>38 <option value="true">Completed</option>39 </select>4041 {/* Priority filter */}42 <select43 value={filters.priority || ''}44 onChange={(e) =>45 onFilterChange({46 ...filters,47 priority: (e.target.value as Priority) || undefined,48 })49 }50 className="px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"51 >52 <option value="">All Priorities</option>53 <option value={Priority.LOW}>Low</option>54 <option value={Priority.MEDIUM}>Medium</option>55 <option value={Priority.HIGH}>High</option>56 </select>5758 {/* Category filter */}59 <select60 value={filters.category || ''}61 onChange={(e) =>62 onFilterChange({63 ...filters,64 category: e.target.value || undefined,65 })66 }67 className="px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"68 >69 <option value="">All Categories</option>70 {categories.map((cat) => (71 <option key={cat} value={cat}>72 {cat}73 </option>74 ))}75 </select>7677 {/* Clear filters */}78 <button79 onClick={() => onFilterChange({})}80 className="px-4 py-2 text-gray-500 hover:text-gray-700"81 >82 Clear83 </button>84 </div>85 </div>86 );87}7. Main App
7.1 App Component
File src/App.tsx:
tsx
1import { useState } from 'react';2import { QueryClient, QueryClientProvider } from '@tanstack/react-query';3import { AddTodoForm } from './components/AddTodoForm';4import { TodoList } from './components/TodoList';5import { Filters } from './components/Filters';6import { useTodos, useCategories } from './hooks/useTodos';7import { TodoFilters } from './types/todo';89const queryClient = new QueryClient();1011function TodoApp() {12 const [filters, setFilters] = useState<TodoFilters>({});13 const { todos, total, isLoading, createTodo, updateTodo, deleteTodo, toggleTodo, isCreating } =14 useTodos(filters);15 const { data: categories = [] } = useCategories();1617 return (18 <div className="min-h-screen bg-gray-100">19 <div className="max-w-3xl mx-auto py-8 px-4">20 {/* Header */}21 <header className="mb-8">22 <h1 className="text-3xl font-bold text-gray-800">�� Todo App</h1>23 <p className="text-gray-500">Built with Vibe Coding + GitHub Copilot</p>24 </header>2526 {/* Add Form */}27 <AddTodoForm onSubmit={createTodo} isLoading={isCreating} />2829 {/* Filters */}30 <Filters filters={filters} onFilterChange={setFilters} categories={categories} />3132 {/* Stats */}33 <div className="flex gap-4 mb-4 text-sm text-gray-500">34 <span>{total} total</span>35 <span>•</span>36 <span>{todos.filter((t) => !t.completed).length} active</span>37 <span>•</span>38 <span>{todos.filter((t) => t.completed).length} completed</span>39 </div>4041 {/* Todo List */}42 <TodoList43 todos={todos}44 isLoading={isLoading}45 onToggle={toggleTodo}46 onUpdate={updateTodo}47 onDelete={deleteTodo}48 />49 </div>50 </div>51 );52}5354function App() {55 return (56 <QueryClientProvider client={queryClient}>57 <TodoApp />58 </QueryClientProvider>59 );60}6162export default App;7.2 Tailwind Config
File tailwind.config.js:
JavaScript
1/** @type {import('tailwindcss').Config} */2export default {3 content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],4 theme: {5 extend: {},6 },7 plugins: [],8};7.3 Global Styles
File src/index.css:
CSS
1@tailwind base;2@tailwind components;3@tailwind utilities;4 5body {6 font-family: system-ui, -apple-system, sans-serif;7}8. Running the Full Stack
8.1 Start Backend
Bash
1cd todo-api2uvicorn app.main:app --reload3# Running on http://localhost:80008.2 Start Frontend
Bash
1cd todo-frontend2npm run dev3# Running on http://localhost:51738.3 Test the App
- Open http://localhost:5173
- Add a new todo
- Toggle complete
- Apply filters
- Edit and delete todos
9. Bonus Features
9.1 Keyboard Shortcuts
Thêm keyboard shortcuts với Copilot:
Text
1Add keyboard shortcuts:2- Enter: Submit form3- Escape: Cancel editing4- Ctrl+N: Focus add form5- Ctrl+F: Focus search9.2 Drag and Drop
Text
1Add drag and drop to reorder todos using @dnd-kit/core9.3 Dark Mode
Text
1Add dark mode toggle with:2- ThemeContext3- CSS variables4- LocalStorage persistence10. Deployment
10.1 Backend (Render/Railway)
Bash
1# Add production requirements2pip install gunicorn psycopg2-binary3 4# Procfile5web: gunicorn app.main:app -k uvicorn.workers.UvicornWorker10.2 Frontend (Vercel/Netlify)
Bash
1npm run build2# Deploy dist/ folder10.3 Environment Variables
env
1# Frontend2VITE_API_URL=https://your-api.com/api3 4# Backend5DATABASE_URL=postgresql://user:pass@host:5432/dbProject Complete! 🎉
Chúng ta đã build một Todo App hoàn chỉnh với:
| Feature | Status |
|---|---|
| Backend API (FastAPI) | ✅ |
| Database (SQLAlchemy) | ✅ |
| Frontend (React + TS) | ✅ |
| State Management (React Query) | ✅ |
| Styling (Tailwind) | ✅ |
| CRUD Operations | ✅ |
| Filters & Search | ✅ |
| Optimistic Updates | ✅ |
Tiếp theo
Bài cuối cùng: Best Practices & Productivity Tips - tổng kết các best practices và tips để tối đa hóa hiệu quả với Copilot!
