Thực hành
45 phút
Bài 11/12

Build Project: Todo App (Part 2)

Xây dựng Frontend React với TypeScript và kết nối với Backend API

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.tsx
7│ ├── App.tsx
8│ ├── index.css
9│ ├── api/
10│ │ └── todoApi.ts
11│ ├── components/
12│ │ ├── AddTodoForm.tsx
13│ │ ├── TodoList.tsx
14│ │ ├── TodoItem.tsx
15│ │ ├── Filters.tsx
16│ │ └── CategorySidebar.tsx
17│ ├── hooks/
18│ │ └── useTodos.ts
19│ ├── types/
20│ │ └── todo.ts
21│ └── utils/
22│ └── constants.ts
23├── index.html
24├── package.json
25├── tailwind.config.js
26├── postcss.config.js
27├── tsconfig.json
28└── vite.config.ts
29
30Setup:
31- React 18
32- TypeScript
33- Tailwind CSS
34- React Query for data fetching

2.2 Install Dependencies

Bash
1npm create vite@latest todo-frontend -- --template react-ts
2cd todo-frontend
3npm install
4npm install @tanstack/react-query axios
5npm install -D tailwindcss postcss autoprefixer
6npx tailwindcss init -p

3. Type Definitions

3.1 Todo Types

File src/types/todo.ts:

TypeScript
1// TypeScript types matching our backend API:
2// - Priority enum
3// - Todo interface
4// - CreateTodoRequest
5// - UpdateTodoRequest
6// - TodoListResponse

Copilot generates:

TypeScript
1export enum Priority {
2 LOW = 'low',
3 MEDIUM = 'medium',
4 HIGH = 'high',
5}
6
7export 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}
18
19export interface CreateTodoRequest {
20 title: string;
21 description?: string;
22 priority?: Priority;
23 due_date?: string;
24 category?: string;
25}
26
27export interface UpdateTodoRequest {
28 title?: string;
29 description?: string;
30 completed?: boolean;
31 priority?: Priority;
32 due_date?: string;
33 category?: string;
34}
35
36export interface TodoListResponse {
37 items: Todo[];
38 total: number;
39 page: number;
40 page_size: number;
41 has_next: boolean;
42}
43
44export 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';
9
10const API_BASE = 'http://localhost:8000/api';
11
12const api = axios.create({
13 baseURL: API_BASE,
14 headers: {
15 'Content-Type': 'application/json',
16 },
17});
18
19export 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 },
33
34 async getTodo(id: number): Promise<Todo> {
35 const response = await api.get<Todo>(`/todos/${id}`);
36 return response.data;
37 },
38
39 async createTodo(data: CreateTodoRequest): Promise<Todo> {
40 const response = await api.post<Todo>('/todos', data);
41 return response.data;
42 },
43
44 async updateTodo(id: number, data: UpdateTodoRequest): Promise<Todo> {
45 const response = await api.put<Todo>(`/todos/${id}`, data);
46 return response.data;
47 },
48
49 async deleteTodo(id: number): Promise<void> {
50 await api.delete(`/todos/${id}`);
51 },
52
53 async toggleTodo(id: number): Promise<Todo> {
54 const response = await api.post<Todo>(`/todos/${id}/toggle`);
55 return response.data;
56 },
57
58 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 filters
3// - Create, update, delete mutations
4// - Toggle mutation
5// - Optimistic updates

Copilot generates:

TypeScript
1import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
2import { todoApi } from '../api/todoApi';
3import { CreateTodoRequest, UpdateTodoRequest, TodoFilters, Todo } from '../types/todo';
4
5export function useTodos(filters: TodoFilters = {}) {
6 const queryClient = useQueryClient();
7
8 // Query
9 const todosQuery = useQuery({
10 queryKey: ['todos', filters],
11 queryFn: () => todoApi.getTodos(filters),
12 });
13
14 // Mutations
15 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 update
41 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 } : todo
50 ),
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}
75
76export 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 textarea
4// - Priority select
5// - Category input
6// - Due date picker
7// - Submit button

Copilot generates:

tsx
1import { useState } from 'react';
2import { Priority, CreateTodoRequest } from '../types/todo';
3
4interface Props {
5 onSubmit: (data: CreateTodoRequest) => void;
6 isLoading?: boolean;
7}
8
9export 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);
16
17 const handleSubmit = (e: React.FormEvent) => {
18 e.preventDefault();
19 if (!title.trim()) return;
20
21 onSubmit({
22 title: title.trim(),
23 description: description.trim() || undefined,
24 priority,
25 category: category.trim() || undefined,
26 due_date: dueDate || undefined,
27 });
28
29 // Reset form
30 setTitle('');
31 setDescription('');
32 setPriority(Priority.MEDIUM);
33 setCategory('');
34 setDueDate('');
35 setIsExpanded(false);
36 };
37
38 return (
39 <form onSubmit={handleSubmit} className="bg-white rounded-lg shadow p-4 mb-6">
40 <div className="flex gap-2 mb-2">
41 <input
42 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 <button
50 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>
57
58 <button
59 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>
65
66 {isExpanded && (
67 <div className="mt-4 space-y-4">
68 <textarea
69 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 />
75
76 <div className="flex gap-4">
77 <div className="flex-1">
78 <label className="block text-sm text-gray-600 mb-1">Priority</label>
79 <select
80 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>
89
90 <div className="flex-1">
91 <label className="block text-sm text-gray-600 mb-1">Category</label>
92 <input
93 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>
100
101 <div className="flex-1">
102 <label className="block text-sm text-gray-600 mb-1">Due Date</label>
103 <input
104 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';
3
4interface Props {
5 todo: Todo;
6 onToggle: (id: number) => void;
7 onUpdate: (data: { id: number; data: UpdateTodoRequest }) => void;
8 onDelete: (id: number) => void;
9}
10
11const 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};
16
17export function TodoItem({ todo, onToggle, onUpdate, onDelete }: Props) {
18 const [isEditing, setIsEditing] = useState(false);
19 const [editTitle, setEditTitle] = useState(todo.title);
20
21 const handleSave = () => {
22 if (editTitle.trim() && editTitle !== todo.title) {
23 onUpdate({ id: todo.id, data: { title: editTitle.trim() } });
24 }
25 setIsEditing(false);
26 };
27
28 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 };
35
36 const formatDate = (dateStr: string | null) => {
37 if (!dateStr) return null;
38 return new Date(dateStr).toLocaleDateString();
39 };
40
41 const isOverdue = todo.due_date && new Date(todo.due_date) < new Date() && !todo.completed;
42
43 return (
44 <div
45 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 <button
51 onClick={() => onToggle(todo.id)}
52 className={`w-6 h-6 rounded-full border-2 flex items-center justify-center ${
53 todo.completed
54 ? '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>
64
65 {/* Content */}
66 <div className="flex-1 min-w-0">
67 {isEditing ? (
68 <input
69 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 autoFocus
76 />
77 ) : (
78 <div
79 className={`font-medium ${todo.completed ? 'line-through text-gray-400' : ''}`}
80 onDoubleClick={() => setIsEditing(true)}
81 >
82 {todo.title}
83 </div>
84 )}
85
86 {todo.description && (
87 <div className="text-sm text-gray-500 truncate">{todo.description}</div>
88 )}
89
90 <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>
95
96 {/* 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 )}
102
103 {/* 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>
112
113 {/* Actions */}
114 <div className="flex gap-2">
115 <button
116 onClick={() => setIsEditing(true)}
117 className="p-2 text-gray-400 hover:text-blue-500"
118 title="Edit"
119 >
120
121 </button>
122 <button
123 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';
3
4interface 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}
11
12export 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 }
20
21 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 }
29
30 return (
31 <div className="space-y-3">
32 {todos.map((todo) => (
33 <TodoItem
34 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';
2
3interface Props {
4 filters: TodoFilters;
5 onFilterChange: (filters: TodoFilters) => void;
6 categories: string[];
7}
8
9export 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 <input
16 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>
23
24 {/* Status filter */}
25 <select
26 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>
40
41 {/* Priority filter */}
42 <select
43 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>
57
58 {/* Category filter */}
59 <select
60 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>
76
77 {/* Clear filters */}
78 <button
79 onClick={() => onFilterChange({})}
80 className="px-4 py-2 text-gray-500 hover:text-gray-700"
81 >
82 Clear
83 </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';
8
9const queryClient = new QueryClient();
10
11function 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();
16
17 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>
25
26 {/* Add Form */}
27 <AddTodoForm onSubmit={createTodo} isLoading={isCreating} />
28
29 {/* Filters */}
30 <Filters filters={filters} onFilterChange={setFilters} categories={categories} />
31
32 {/* 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>
40
41 {/* Todo List */}
42 <TodoList
43 todos={todos}
44 isLoading={isLoading}
45 onToggle={toggleTodo}
46 onUpdate={updateTodo}
47 onDelete={deleteTodo}
48 />
49 </div>
50 </div>
51 );
52}
53
54function App() {
55 return (
56 <QueryClientProvider client={queryClient}>
57 <TodoApp />
58 </QueryClientProvider>
59 );
60}
61
62export 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-api
2uvicorn app.main:app --reload
3# Running on http://localhost:8000

8.2 Start Frontend

Bash
1cd todo-frontend
2npm run dev
3# Running on http://localhost:5173

8.3 Test the App

  1. Open http://localhost:5173
  2. Add a new todo
  3. Toggle complete
  4. Apply filters
  5. 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 form
3- Escape: Cancel editing
4- Ctrl+N: Focus add form
5- Ctrl+F: Focus search

9.2 Drag and Drop

Text
1Add drag and drop to reorder todos using @dnd-kit/core

9.3 Dark Mode

Text
1Add dark mode toggle with:
2- ThemeContext
3- CSS variables
4- LocalStorage persistence

10. Deployment

10.1 Backend (Render/Railway)

Bash
1# Add production requirements
2pip install gunicorn psycopg2-binary
3
4# Procfile
5web: gunicorn app.main:app -k uvicorn.workers.UvicornWorker

10.2 Frontend (Vercel/Netlify)

Bash
1npm run build
2# Deploy dist/ folder

10.3 Environment Variables

env
1# Frontend
2VITE_API_URL=https://your-api.com/api
3
4# Backend
5DATABASE_URL=postgresql://user:pass@host:5432/db

Project Complete! 🎉

Chúng ta đã build một Todo App hoàn chỉnh với:

FeatureStatus
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!