forked from semi-23e/nextjs-todo-tutorial
tep 5.1: コンポーネントの分割まで完了
This commit is contained in:
298
src/app/page.tsx
298
src/app/page.tsx
@@ -1,34 +1,36 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'; // useEffectを追加
|
import { useState, useEffect } from 'react';
|
||||||
|
import TodoList from '@/components/TodoList';
|
||||||
|
import FilterButtons from '@/components/FilterButtons';
|
||||||
|
|
||||||
// Todo型の定義
|
// Todo型の定義
|
||||||
type Todo = {
|
type Todo = {
|
||||||
id: number; // ユニークな識別子。今回はDate.now()で一意性をある程度確保。
|
id: number;
|
||||||
text: string; // TODOの内容。ユーザーが入力する文字列。
|
text: string;
|
||||||
completed: boolean; // 完了したかどうかのフラグ。チェックボックスに対応。
|
completed: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FilterType = 'all' | 'active' | 'completed'; // フィルターの種類。全て、未完了、完了の3種類。
|
type FilterType = 'all' | 'active' | 'completed';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
// Local Storage のキー
|
// Local Storage のキー
|
||||||
const STORAGE_KEY = 'todo-app-data';
|
const STORAGE_KEY = 'todo-app-data';
|
||||||
|
|
||||||
// Todoリスト本体の状態管理
|
// Todoリスト本体の状態管理
|
||||||
const [todos, setTodos] = useState<Todo[]>([]); // Todo配列を保持
|
const [todos, setTodos] = useState<Todo[]>([]);
|
||||||
|
|
||||||
// 入力欄の状態管理
|
// 入力欄の状態管理
|
||||||
const [inputValue, setInputValue] = useState<string>(''); // 入力中のテキスト
|
const [inputValue, setInputValue] = useState<string>('');
|
||||||
|
|
||||||
// 削除確認ダイアログの表示状態
|
// 削除確認ダイアログの表示状態
|
||||||
const [showConfirm, setShowConfirm] = useState(false); // ダイアログ表示フラグ
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
// 削除対象のTodoのID
|
// 削除対象のTodoのID
|
||||||
const [targetId, setTargetId] = useState<number | null>(null); // 削除対象ID
|
const [targetId, setTargetId] = useState<number | null>(null);
|
||||||
|
|
||||||
// 空欄入力時の警告ダイアログ表示状態
|
// 空欄入力時の警告ダイアログ表示状態
|
||||||
const [showInputAlert, setShowInputAlert] = useState(false); // 空欄警告フラグ
|
const [showInputAlert, setShowInputAlert] = useState(false);
|
||||||
|
|
||||||
// 編集機能のための状態管理
|
// 編集機能のための状態管理
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
@@ -43,105 +45,96 @@ export default function Home() {
|
|||||||
const activeCount = todos.filter(todo => !todo.completed).length;
|
const activeCount = todos.filter(todo => !todo.completed).length;
|
||||||
const completedCount = todos.filter(todo => todo.completed).length;
|
const completedCount = todos.filter(todo => todo.completed).length;
|
||||||
|
|
||||||
|
const startEdit = (todo: Todo) => {
|
||||||
// フィルターを変更する関数
|
setEditingId(todo.id);
|
||||||
const getFilteredTodos = () => { // フィルターに応じてTodoリストを返す関数
|
setEditText(todo.text);
|
||||||
switch (filter) { // フィルターの種類に応じて処理を分岐
|
|
||||||
case 'active': // 未完了のTodoのみを返す
|
|
||||||
return todos.filter(todo => !todo.completed);
|
|
||||||
case 'completed': // 完了したTodoのみを返す
|
|
||||||
return todos.filter(todo => todo.completed);
|
|
||||||
default: // 'all'の場合は全てのTodoを返す
|
|
||||||
return todos; // 'all'の場合は全てのTodoを返す
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const startEdit = (todo: Todo) => { // 編集開始関数
|
const saveEdit = () => {
|
||||||
setEditingId(todo.id); // 編集対象のIDをセット
|
if (editText.trim() === '') {
|
||||||
setEditText(todo.text); // 編集用のテキストをセット
|
alert('TODOが空です');
|
||||||
};
|
return;
|
||||||
|
|
||||||
const saveEdit = () => { // 編集内容を保存する関数
|
|
||||||
if (editText.trim() === '') { // 編集内容が空欄だったら警告ダイアログ表示
|
|
||||||
alert('TODOが空です'); // 簡易的なアラート表示
|
|
||||||
return; // 空欄の場合は保存しない
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedTodos = todos.map(todo => { // Todoリストを更新
|
const updatedTodos = todos.map(todo => {
|
||||||
if (todo.id === editingId) { // 編集対象のTodoを見つけたら
|
if (todo.id === editingId) {
|
||||||
return { ...todo, text: editText }; // 編集内容で更新
|
return { ...todo, text: editText };
|
||||||
}
|
}
|
||||||
return todo; // 他のTodoはそのまま返す
|
return todo;
|
||||||
});
|
});
|
||||||
|
|
||||||
setTodos(updatedTodos); // 更新されたTodoリストをセット
|
setTodos(updatedTodos);
|
||||||
setEditingId(null); // 編集モードを終了
|
setEditingId(null);
|
||||||
setEditText(''); // 編集用テキストをクリア
|
setEditText('');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 新しいTODOを追加する関数
|
// 新しいTODOを追加する関数
|
||||||
const addTodo = () => {
|
const addTodo = () => {
|
||||||
// 入力が空欄だったら警告ダイアログ表示
|
if (inputValue.trim() === '') {
|
||||||
if (inputValue.trim() === '') { //trim()で前後の空白を除去
|
setShowInputAlert(true);
|
||||||
setShowInputAlert(true); // 警告ダイアログを表示
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 新しいTodoオブジェクトを作成
|
|
||||||
const newTodo: Todo = { // 新規Todoオブジェクト
|
const newTodo: Todo = {
|
||||||
id: Date.now(), // 現在時刻のミリ秒をIDとして使用することで、ほかのTODOと重複しないようにする
|
id: Date.now(),
|
||||||
text: inputValue, // 入力値
|
text: inputValue,
|
||||||
completed: false // 初期状態を未完了に
|
completed: false
|
||||||
};
|
};
|
||||||
setTodos([...todos, newTodo]); // Todoリストに追加
|
setTodos([...todos, newTodo]);
|
||||||
setInputValue(''); // 入力欄をクリア
|
setInputValue('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelEdit = () => { // 編集キャンセル関数
|
const cancelEdit = () => {
|
||||||
setEditingId(null); // 編集モードを終了
|
setEditingId(null);
|
||||||
setEditText(''); // 編集用テキストをクリア
|
setEditText('');
|
||||||
};
|
};
|
||||||
|
|
||||||
// フォーム送信時の処理
|
// フォーム送信時の処理
|
||||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { // フォーム送信イベント
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault(); // ページリロード防止
|
e.preventDefault();
|
||||||
addTodo(); // Todo追加処理
|
addTodo();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Todoの完了状態をトグルする関数
|
// Todoの完了状態をトグルする関数
|
||||||
const toggleTodo = (id: number) => { // Todoの完了状態を切り替える
|
const toggleTodo = (id: number) => {
|
||||||
const updatedTodos = todos.map(todo => { // 現在のTodoリストをマッピング
|
const updatedTodos = todos.map(todo => {
|
||||||
if (todo.id === id) { // 対象のTodoを見つけたら
|
if (todo.id === id) {
|
||||||
return { ...todo, completed: !todo.completed }; // そのtodoの完了状態を反転
|
return { ...todo, completed: !todo.completed };
|
||||||
}
|
}
|
||||||
return todo; // 対象外はそのまま返す
|
return todo;
|
||||||
});
|
});
|
||||||
setTodos(updatedTodos); // 状態更新
|
setTodos(updatedTodos);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 削除ボタンが押されたときの処理(確認ダイアログ表示)
|
// 削除ボタンが押されたときの処理(確認ダイアログ表示)
|
||||||
const handleDeleteClick = (id: number) => { // 削除ボタン押下時の処理
|
const handleDeleteClick = (id: number) => {
|
||||||
setShowConfirm(true); // 削除確認ダイアログ表示
|
setShowConfirm(true);
|
||||||
setTargetId(id); // 削除対象IDをセット
|
setTargetId(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ダイアログで「はい」が押されたときの本削除処理
|
// ダイアログで「はい」が押されたときの本削除処理
|
||||||
const confirmDelete = () => { // 削除確認ダイアログで「はい」が押されたときの処理
|
const confirmDelete = () => {
|
||||||
if (targetId !== null) { // 削除対象IDがセットされているか確認
|
if (targetId !== null) {
|
||||||
setTodos(todos.filter(todo => todo.id !== targetId)); // 該当ID以外を残す
|
setTodos(todos.filter(todo => todo.id !== targetId));
|
||||||
setShowConfirm(false); // ダイアログを閉じる
|
setShowConfirm(false);
|
||||||
setTargetId(null); // 削除対象IDをリセット
|
setTargetId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ダイアログで「いいえ」が押されたときの処理
|
// ダイアログで「いいえ」が押されたときの処理
|
||||||
const cancelDelete = () => { // 削除確認ダイアログで「いいえ」が押されたときの処理
|
const cancelDelete = () => {
|
||||||
setShowConfirm(false); // ダイアログを閉じる
|
setShowConfirm(false);
|
||||||
setTargetId(null); // 削除対象IDをリセット
|
setTargetId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 空欄入力時ダイアログを閉じる関数
|
// 空欄入力時ダイアログを閉じる関数
|
||||||
const closeInputAlert = () => { //
|
const closeInputAlert = () => {
|
||||||
setShowInputAlert(false); // 警告ダイアログを閉じる
|
setShowInputAlert(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 完了済みTODOをすべて削除
|
||||||
|
const clearCompleted = () => {
|
||||||
|
setTodos(todos.filter(todo => !todo.completed));
|
||||||
};
|
};
|
||||||
|
|
||||||
// アプリ起動時にデータを読み込み
|
// アプリ起動時にデータを読み込み
|
||||||
@@ -155,12 +148,12 @@ export default function Home() {
|
|||||||
console.error('データの読み込みに失敗しました:', error);
|
console.error('データの読み込みに失敗しました:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []); // 空の依存配列で初回のみ実行
|
}, []);
|
||||||
|
|
||||||
// TODOが変更されるたびに保存
|
// TODOが変更されるたびに保存
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
|
||||||
}, [todos]); // todosが変更されるたびに実行
|
}, [todos]);
|
||||||
|
|
||||||
// データのエクスポート機能
|
// データのエクスポート機能
|
||||||
const exportData = () => {
|
const exportData = () => {
|
||||||
@@ -191,10 +184,9 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// JSX: 実際に表示されるUI定義
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen p-8 bg-gray-50"> {/* 全体の背景や余白 */}
|
<main className="min-h-screen p-8 bg-gray-50">
|
||||||
<div className="max-w-md mx-auto bg-white rounded-lg shadow-md p-6"> {/* カード風の白背景 */}
|
<div className="max-w-md mx-auto bg-white rounded-lg shadow-md p-6">
|
||||||
{/* ヘッダー部分にエクスポート・インポート機能を追加 */}
|
{/* ヘッダー部分にエクスポート・インポート機能を追加 */}
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-800">TODOアプリ</h1>
|
<h1 className="text-2xl font-bold text-gray-800">TODOアプリ</h1>
|
||||||
@@ -218,11 +210,11 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 入力フォーム */}
|
{/* 入力フォーム */}
|
||||||
<form onSubmit={handleSubmit} className="mb-4"> {/* フォーム全体のスタイル */}
|
<form onSubmit={handleSubmit} className="mb-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={inputValue} // 入力欄の値を状態と連動
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)} // 入力値変更時に状態更新
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
placeholder="TODOを入力してEnterキー"
|
placeholder="TODOを入力してEnterキー"
|
||||||
className="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
@@ -233,136 +225,38 @@ export default function Home() {
|
|||||||
追加
|
追加
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div className="flex justify-between items-center mb-4 p-4 bg-gray-100 rounded-lg"> {/* フィルターと削除ボタンのコンテナ */}
|
|
||||||
<div className="flex gap-2"> {/* フィルター用のボタン */}
|
|
||||||
<button
|
|
||||||
onClick={() => setFilter('all')}
|
|
||||||
className={`px-4 py-2 rounded transition-colors ${
|
|
||||||
filter === 'all'
|
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'bg-white text-gray-700 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
すべて ({todos.length})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setFilter('active')}
|
|
||||||
className={`px-4 py-2 rounded transition-colors ${
|
|
||||||
filter === 'active'
|
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'bg-white text-gray-700 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
未完了 ({activeCount})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setFilter('completed')}
|
|
||||||
className={`px-4 py-2 rounded transition-colors ${
|
|
||||||
filter === 'completed'
|
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'bg-white text-gray-700 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
完了 ({completedCount})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 完了済みTODOをすべて削除 */}
|
{/* フィルターボタン */}
|
||||||
{completedCount > 0 && (
|
<FilterButtons
|
||||||
<button
|
filter={filter}
|
||||||
onClick={() => setTodos(todos.filter(todo => !todo.completed))}
|
totalCount={todos.length}
|
||||||
className="text-red-500 hover:text-red-700 px-3 py-1 rounded transition-colors"
|
activeCount={activeCount}
|
||||||
>
|
completedCount={completedCount}
|
||||||
完了済みを削除
|
onFilterChange={setFilter}
|
||||||
</button>
|
onClearCompleted={clearCompleted}
|
||||||
)}
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* TODOリスト */}
|
||||||
{getFilteredTodos().length === 0 ? (
|
<TodoList
|
||||||
<p className="text-gray-500 text-center py-8">
|
todos={todos}
|
||||||
{filter === 'active' && 'すべて完了しました!'}
|
filter={filter}
|
||||||
{filter === 'completed' && '完了したTODOがありません'}
|
editingId={editingId}
|
||||||
{filter === 'all' && 'TODOがありません'}
|
editText={editText}
|
||||||
</p>
|
onToggleTodo={toggleTodo}
|
||||||
) : (
|
onStartEdit={startEdit}
|
||||||
getFilteredTodos().map((todo) => (
|
onSaveEdit={saveEdit}
|
||||||
<div key={todo.id} className="flex items-center p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
onCancelEdit={cancelEdit}
|
||||||
<input
|
onDeleteClick={handleDeleteClick}
|
||||||
type="checkbox"
|
onEditTextChange={setEditText}
|
||||||
checked={todo.completed}
|
|
||||||
onChange={() => toggleTodo(todo.id)}
|
|
||||||
className="mr-3 h-5 w-5 cursor-pointer"
|
|
||||||
/>
|
/>
|
||||||
{editingId === todo.id ? (
|
|
||||||
// 編集モード
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editText}
|
|
||||||
onChange={(e) => setEditText(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') saveEdit();
|
|
||||||
if (e.key === 'Escape') cancelEdit();
|
|
||||||
}}
|
|
||||||
className="flex-1 border border-blue-500 p-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
// 通常モード
|
|
||||||
<span
|
|
||||||
onClick={() => startEdit(todo)}
|
|
||||||
className={`flex-1 cursor-pointer p-2 rounded hover:bg-gray-200 transition-colors ${
|
|
||||||
todo.completed ? 'line-through text-gray-500' : 'text-gray-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{todo.text}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{editingId === todo.id ? (
|
|
||||||
// 編集モードのボタン
|
|
||||||
<div className="flex gap-2 ml-2">
|
|
||||||
<button
|
|
||||||
onClick={saveEdit}
|
|
||||||
className="text-green-600 hover:text-green-800 px-2 py-1 rounded transition-colors"
|
|
||||||
>
|
|
||||||
保存
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={cancelEdit}
|
|
||||||
className="text-gray-600 hover:text-gray-800 px-2 py-1 rounded transition-colors"
|
|
||||||
>
|
|
||||||
キャンセル
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// 通常モードのボタン
|
|
||||||
<div className="flex gap-2 ml-2">
|
|
||||||
<button
|
|
||||||
onClick={() => startEdit(todo)}
|
|
||||||
className="text-blue-500 hover:text-blue-700 px-2 py-1 rounded transition-colors"
|
|
||||||
>
|
|
||||||
編集
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteClick(todo.id)}
|
|
||||||
className="text-red-500 hover:text-red-700 px-2 py-1 rounded transition-colors"
|
|
||||||
>
|
|
||||||
削除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 確認ダイアログ */}
|
{/* 確認ダイアログ */}
|
||||||
{showConfirm && (
|
{showConfirm && (
|
||||||
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50">
|
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50">
|
||||||
<div className="bg-white p-6 rounded shadow">
|
<div className="bg-white p-6 rounded shadow">
|
||||||
<p className="mb-4">本当に削除しますか?</p>
|
<p className="mb-4">本当に削除しますか?</p>
|
||||||
<button onClick={confirmDelete} className="mr-2 px-4 py-2 bg-red-500 text-white rounded">はい</button> {/* 削除確定 */}
|
<button onClick={confirmDelete} className="mr-2 px-4 py-2 bg-red-500 text-white rounded">はい</button>
|
||||||
<button onClick={cancelDelete} className="px-4 py-2 bg-gray-300 rounded">いいえ</button> {/* 削除キャンセル */}
|
<button onClick={cancelDelete} className="px-4 py-2 bg-gray-300 rounded">いいえ</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -373,7 +267,7 @@ export default function Home() {
|
|||||||
<div className="bg-white p-6 rounded shadow flex flex-col items-center">
|
<div className="bg-white p-6 rounded shadow flex flex-col items-center">
|
||||||
<p className="mb-4">TODOを入力してください</p>
|
<p className="mb-4">TODOを入力してください</p>
|
||||||
<button
|
<button
|
||||||
onClick={closeInputAlert} // 警告ダイアログを閉じる
|
onClick={closeInputAlert}
|
||||||
className="w-full px-4 py-2 bg-blue-500 text-white rounded text-center"
|
className="w-full px-4 py-2 bg-blue-500 text-white rounded text-center"
|
||||||
style={{ maxWidth: "240px" }}
|
style={{ maxWidth: "240px" }}
|
||||||
>
|
>
|
||||||
|
|||||||
67
src/components/FilterButtons.tsx
Normal file
67
src/components/FilterButtons.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type FilterType = 'all' | 'active' | 'completed';
|
||||||
|
|
||||||
|
type FilterButtonsProps = {
|
||||||
|
filter: FilterType;
|
||||||
|
totalCount: number;
|
||||||
|
activeCount: number;
|
||||||
|
completedCount: number;
|
||||||
|
onFilterChange: (filter: FilterType) => void;
|
||||||
|
onClearCompleted: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FilterButtons({
|
||||||
|
filter,
|
||||||
|
totalCount,
|
||||||
|
activeCount,
|
||||||
|
completedCount,
|
||||||
|
onFilterChange,
|
||||||
|
onClearCompleted,
|
||||||
|
}: FilterButtonsProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-center mb-4 p-4 bg-gray-100 rounded-lg">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onFilterChange('all')}
|
||||||
|
className={`px-4 py-2 rounded transition-colors ${
|
||||||
|
filter === 'all'
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-white text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
すべて ({totalCount})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onFilterChange('active')}
|
||||||
|
className={`px-4 py-2 rounded transition-colors ${
|
||||||
|
filter === 'active'
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-white text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
未完了 ({activeCount})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onFilterChange('completed')}
|
||||||
|
className={`px-4 py-2 rounded transition-colors ${
|
||||||
|
filter === 'completed'
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-white text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
完了 ({completedCount})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{completedCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={onClearCompleted}
|
||||||
|
className="text-red-500 hover:text-red-700 px-3 py-1 rounded transition-colors"
|
||||||
|
>
|
||||||
|
完了済みを削除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
src/components/TodoItem.tsx
Normal file
99
src/components/TodoItem.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type Todo = {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
completed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TodoItemProps = {
|
||||||
|
todo: Todo;
|
||||||
|
isEditing: boolean;
|
||||||
|
editText: string;
|
||||||
|
onToggle: () => void;
|
||||||
|
onStartEdit: () => void;
|
||||||
|
onSaveEdit: () => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onEditTextChange: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TodoItem({
|
||||||
|
todo,
|
||||||
|
isEditing,
|
||||||
|
editText,
|
||||||
|
onToggle,
|
||||||
|
onStartEdit,
|
||||||
|
onSaveEdit,
|
||||||
|
onCancelEdit,
|
||||||
|
onDelete,
|
||||||
|
onEditTextChange,
|
||||||
|
}: TodoItemProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={todo.completed}
|
||||||
|
onChange={onToggle}
|
||||||
|
className="mr-3 h-5 w-5 cursor-pointer"
|
||||||
|
/>
|
||||||
|
{isEditing ? (
|
||||||
|
// 編集モード
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editText}
|
||||||
|
onChange={(e) => onEditTextChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') onSaveEdit();
|
||||||
|
if (e.key === 'Escape') onCancelEdit();
|
||||||
|
}}
|
||||||
|
className="flex-1 border border-blue-500 p-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// 通常モード
|
||||||
|
<span
|
||||||
|
onClick={onStartEdit}
|
||||||
|
className={`flex-1 cursor-pointer p-2 rounded hover:bg-gray-200 transition-colors ${
|
||||||
|
todo.completed ? 'line-through text-gray-500' : 'text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{todo.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isEditing ? (
|
||||||
|
// 編集モードのボタン
|
||||||
|
<div className="flex gap-2 ml-2">
|
||||||
|
<button
|
||||||
|
onClick={onSaveEdit}
|
||||||
|
className="text-green-600 hover:text-green-800 px-2 py-1 rounded transition-colors"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onCancelEdit}
|
||||||
|
className="text-gray-600 hover:text-gray-800 px-2 py-1 rounded transition-colors"
|
||||||
|
>
|
||||||
|
キャンセル
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 通常モードのボタン
|
||||||
|
<div className="flex gap-2 ml-2">
|
||||||
|
<button
|
||||||
|
onClick={onStartEdit}
|
||||||
|
className="text-blue-500 hover:text-blue-700 px-2 py-1 rounded transition-colors"
|
||||||
|
>
|
||||||
|
編集
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className="text-red-500 hover:text-red-700 px-2 py-1 rounded transition-colors"
|
||||||
|
>
|
||||||
|
削除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/components/TodoList.tsx
Normal file
74
src/components/TodoList.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import TodoItem from './TodoItem';
|
||||||
|
|
||||||
|
type Todo = {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
completed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FilterType = 'all' | 'active' | 'completed';
|
||||||
|
|
||||||
|
type TodoListProps = {
|
||||||
|
todos: Todo[];
|
||||||
|
filter: FilterType;
|
||||||
|
editingId: number | null;
|
||||||
|
editText: string;
|
||||||
|
onToggleTodo: (id: number) => void;
|
||||||
|
onStartEdit: (todo: Todo) => void;
|
||||||
|
onSaveEdit: () => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onDeleteClick: (id: number) => void;
|
||||||
|
onEditTextChange: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TodoList({
|
||||||
|
todos,
|
||||||
|
filter,
|
||||||
|
editingId,
|
||||||
|
editText,
|
||||||
|
onToggleTodo,
|
||||||
|
onStartEdit,
|
||||||
|
onSaveEdit,
|
||||||
|
onCancelEdit,
|
||||||
|
onDeleteClick,
|
||||||
|
onEditTextChange,
|
||||||
|
}: TodoListProps) {
|
||||||
|
const getFilteredTodos = () => {
|
||||||
|
switch (filter) {
|
||||||
|
case 'active':
|
||||||
|
return todos.filter(todo => !todo.completed);
|
||||||
|
case 'completed':
|
||||||
|
return todos.filter(todo => todo.completed);
|
||||||
|
default:
|
||||||
|
return todos;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{getFilteredTodos().length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-center py-8">
|
||||||
|
{filter === 'active' && 'すべて完了しました!'}
|
||||||
|
{filter === 'completed' && '完了したTODOがありません'}
|
||||||
|
{filter === 'all' && 'TODOがありません'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
getFilteredTodos().map((todo) => (
|
||||||
|
<TodoItem
|
||||||
|
key={todo.id}
|
||||||
|
todo={todo}
|
||||||
|
isEditing={editingId === todo.id}
|
||||||
|
editText={editText}
|
||||||
|
onToggle={() => onToggleTodo(todo.id)}
|
||||||
|
onStartEdit={() => onStartEdit(todo)}
|
||||||
|
onSaveEdit={onSaveEdit}
|
||||||
|
onCancelEdit={onCancelEdit}
|
||||||
|
onDelete={() => onDeleteClick(todo.id)}
|
||||||
|
onEditTextChange={onEditTextChange}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user