forked from semi-23e/nextjs-todo-tutorial
Compare commits
9 Commits
62e2942cb9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 253d25ab5c | |||
| f7d9950bbc | |||
| d387a00dff | |||
| f379dd5d8f | |||
| 1b92843a85 | |||
| 21b87f0756 | |||
| e29e4ff796 | |||
| 67cd70bbd6 | |||
| 33bf23aac4 |
461
src/app/page.tsx
461
src/app/page.tsx
@@ -1,95 +1,368 @@
|
||||
'use client'; // ← これはNext.jsのApp Routerで、クライアントコンポーネントとして実行させる指示。useStateなどクライアント専用フックを使うために必須。
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react'; // Reactの"状態管理用"のフック。状態が変化したら再レンダリングが起きる。
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import TodoList from '@/components/TodoList';
|
||||
import FilterButtons from '@/components/FilterButtons';
|
||||
|
||||
// Todo型の定義。オブジェクトが必ずid, text, completedというプロパティを持つことを保証。
|
||||
// Todo型の定義
|
||||
type Todo = {
|
||||
id: number; // ユニークな識別子。今回はDate.now()で一意性をある程度確保。
|
||||
text: string; // TODOの内容。ユーザーが入力する文字列。
|
||||
completed: boolean; // 完了したかどうかのフラグ。チェックボックスに対応。
|
||||
id: number;
|
||||
text: string;
|
||||
completed: boolean;
|
||||
};
|
||||
|
||||
export default function Home() { // Reactコンポーネント。Next.jsのデフォルトエクスポートとしてトップページに描画される。
|
||||
// 状態変数定義。todosはTodoの配列、inputValueは入力中のテキスト。
|
||||
const [todos, setTodos] = useState<Todo[]>([]); // Todoリスト本体。初期値は空配列。
|
||||
const [inputValue, setInputValue] = useState<string>(''); // 入力欄の中身。初期値は空文字。
|
||||
const [showConfirm, setShowConfirm] = useState(false); // 削除確認ダイアログを表示するかどうか。
|
||||
const [targetId, setTargetId] = useState<number | null>(null); // 確認ダイアログで削除対象のTodoのIDを一時保持。
|
||||
const [showInputAlert, setShowInputAlert] = useState(false); // 空欄入力時の警告ダイアログ
|
||||
type FilterType = 'all' | 'active' | 'completed';
|
||||
|
||||
// 新しいTODOを追加する関数
|
||||
const addTodo = () => {
|
||||
if (inputValue.trim() === '') { // 入力が空欄だったら処理を中止。trim()で空白も除去。
|
||||
setShowInputAlert(true); // カスタムダイアログ表示
|
||||
// エラー型の定義
|
||||
type AppError = {
|
||||
type: 'STORAGE_ERROR' | 'IMPORT_ERROR' | 'VALIDATION_ERROR';
|
||||
message: string;
|
||||
};
|
||||
|
||||
// 型ガード関数
|
||||
const isTodo = (obj: unknown): obj is Todo => {
|
||||
return (
|
||||
obj !== null &&
|
||||
typeof obj === 'object' &&
|
||||
typeof (obj as any).id === 'number' &&
|
||||
typeof (obj as any).text === 'string' &&
|
||||
typeof (obj as any).completed === 'boolean'
|
||||
);
|
||||
};
|
||||
|
||||
const isTodoArray = (data: unknown): data is Todo[] => {
|
||||
return Array.isArray(data) && data.every(isTodo);
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
// Local Storage のキー
|
||||
const STORAGE_KEY = 'todo-app-data';
|
||||
|
||||
// 状態管理
|
||||
const [todos, setTodos] = useState<Todo[]>([]);
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [targetId, setTargetId] = useState<number | null>(null);
|
||||
const [showInputAlert, setShowInputAlert] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editText, setEditText] = useState('');
|
||||
const [filter, setFilter] = useState<FilterType>('all');
|
||||
|
||||
// エラー状態の管理
|
||||
const [error, setError] = useState<AppError | null>(null);
|
||||
const [showErrorDialog, setShowErrorDialog] = useState(false);
|
||||
|
||||
// フィルターに応じたTodoの数をメモ化
|
||||
const { activeCount, completedCount } = useMemo(() => ({
|
||||
activeCount: todos.filter(todo => !todo.completed).length,
|
||||
completedCount: todos.filter(todo => todo.completed).length,
|
||||
}), [todos]);
|
||||
|
||||
// エラー処理用のヘルパー関数をメモ化
|
||||
const handleError = useCallback((error: AppError) => {
|
||||
console.error('アプリケーションエラー:', error);
|
||||
setError(error);
|
||||
setShowErrorDialog(true);
|
||||
}, []);
|
||||
|
||||
// エラーダイアログを閉じる
|
||||
const closeErrorDialog = useCallback(() => {
|
||||
setShowErrorDialog(false);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// バリデーション関数をメモ化
|
||||
const validateTodoText = useCallback((text: string): boolean => {
|
||||
if (!text || text.trim().length === 0) {
|
||||
handleError({
|
||||
type: 'VALIDATION_ERROR',
|
||||
message: 'TODOの内容を入力してください'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (text.trim().length > 100) {
|
||||
handleError({
|
||||
type: 'VALIDATION_ERROR',
|
||||
message: 'TODOは100文字以内で入力してください'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [handleError]);
|
||||
|
||||
// Local Storage操作をメモ化
|
||||
const safeLocalStorageGet = useCallback((key: string): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch (error) {
|
||||
handleError({
|
||||
type: 'STORAGE_ERROR',
|
||||
message: 'データの読み込みに失敗しました'
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}, [handleError]);
|
||||
|
||||
const safeLocalStorageSet = useCallback((key: string, value: string): boolean => {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError({
|
||||
type: 'STORAGE_ERROR',
|
||||
message: 'データの保存に失敗しました'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}, [handleError]);
|
||||
|
||||
// 編集関連の関数をメモ化
|
||||
const startEdit = useCallback((todo: Todo) => {
|
||||
setEditingId(todo.id);
|
||||
setEditText(todo.text);
|
||||
}, []);
|
||||
|
||||
const saveEdit = useCallback(() => {
|
||||
if (!validateTodoText(editText)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedTodos = todos.map(todo => {
|
||||
if (todo.id === editingId) {
|
||||
return { ...todo, text: editText.trim() };
|
||||
}
|
||||
return todo;
|
||||
});
|
||||
|
||||
setTodos(updatedTodos);
|
||||
setEditingId(null);
|
||||
setEditText('');
|
||||
} catch (error) {
|
||||
handleError({
|
||||
type: 'VALIDATION_ERROR',
|
||||
message: 'TODOの更新に失敗しました'
|
||||
});
|
||||
}
|
||||
}, [editText, editingId, todos, validateTodoText, handleError]);
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setEditText('');
|
||||
}, []);
|
||||
|
||||
// TODO操作の関数をメモ化
|
||||
const addTodo = useCallback(() => {
|
||||
if (!validateTodoText(inputValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newTodo: Todo = {
|
||||
id: Date.now(),
|
||||
text: inputValue,
|
||||
text: inputValue.trim(),
|
||||
completed: false
|
||||
};
|
||||
setTodos([...todos, newTodo]);
|
||||
setTodos(prev => [...prev, newTodo]);
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
// フォームが送信されたときの処理
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault(); // ページ全体のリロードを防ぐ。SPA的挙動にする。
|
||||
addTodo(); // addTodo関数を実行。
|
||||
};
|
||||
|
||||
// Todoの完了状態をトグル(反転)する関数
|
||||
const toggleTodo = (id: number) => {
|
||||
const updatedTodos = todos.map(todo => {
|
||||
if (todo.id === id) {
|
||||
return { ...todo, completed: !todo.completed }; // 対象のcompletedだけ反転。
|
||||
}
|
||||
return todo; // それ以外はそのまま。
|
||||
} catch (error) {
|
||||
handleError({
|
||||
type: 'VALIDATION_ERROR',
|
||||
message: 'TODOの追加に失敗しました'
|
||||
});
|
||||
setTodos(updatedTodos); // 新しい配列をset。Reactはこれを検知して再描画する。
|
||||
};
|
||||
|
||||
// 削除ボタンが押されたときの仮処理。確認ダイアログを表示。
|
||||
const handleDeleteClick = (id: number) => {
|
||||
setShowConfirm(true); // ダイアログ表示
|
||||
setTargetId(id); // 対象ID記憶
|
||||
};
|
||||
|
||||
// ダイアログで「はい」が押されたときの本削除処理
|
||||
const confirmDelete = () => {
|
||||
if (targetId !== null) { // nullチェックを忘れずに
|
||||
setTodos(todos.filter(todo => todo.id !== targetId)); // 該当ID以外を残す
|
||||
setShowConfirm(false); // ダイアログを閉じる
|
||||
setTargetId(null); // 状態を初期化
|
||||
}
|
||||
};
|
||||
}, [inputValue, validateTodoText, handleError]);
|
||||
|
||||
// ダイアログで「いいえ」が押されたときの処理
|
||||
const cancelDelete = () => {
|
||||
const handleSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
addTodo();
|
||||
}, [addTodo]);
|
||||
|
||||
const toggleTodo = useCallback((id: number) => {
|
||||
try {
|
||||
setTodos(prev => prev.map(todo => {
|
||||
if (todo.id === id) {
|
||||
return { ...todo, completed: !todo.completed };
|
||||
}
|
||||
return todo;
|
||||
}));
|
||||
} catch (error) {
|
||||
handleError({
|
||||
type: 'VALIDATION_ERROR',
|
||||
message: 'TODO状態の更新に失敗しました'
|
||||
});
|
||||
}
|
||||
}, [handleError]);
|
||||
|
||||
const handleDeleteClick = useCallback((id: number) => {
|
||||
setShowConfirm(true);
|
||||
setTargetId(id);
|
||||
}, []);
|
||||
|
||||
const confirmDelete = useCallback(() => {
|
||||
if (targetId !== null) {
|
||||
try {
|
||||
setTodos(prev => prev.filter(todo => todo.id !== targetId));
|
||||
setShowConfirm(false);
|
||||
setTargetId(null);
|
||||
};
|
||||
} catch (error) {
|
||||
handleError({
|
||||
type: 'VALIDATION_ERROR',
|
||||
message: 'TODOの削除に失敗しました'
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [targetId, handleError]);
|
||||
|
||||
// 空欄入力時ダイアログを閉じる関数
|
||||
const closeInputAlert = () => {
|
||||
const cancelDelete = useCallback(() => {
|
||||
setShowConfirm(false);
|
||||
setTargetId(null);
|
||||
}, []);
|
||||
|
||||
const closeInputAlert = useCallback(() => {
|
||||
setShowInputAlert(false);
|
||||
}, []);
|
||||
|
||||
const clearCompleted = useCallback(() => {
|
||||
try {
|
||||
setTodos(prev => prev.filter(todo => !todo.completed));
|
||||
} catch (error) {
|
||||
handleError({
|
||||
type: 'VALIDATION_ERROR',
|
||||
message: '完了済みTODOの削除に失敗しました'
|
||||
});
|
||||
}
|
||||
}, [handleError]);
|
||||
|
||||
// エクスポート・インポート機能をメモ化
|
||||
const exportData = useCallback(() => {
|
||||
try {
|
||||
const dataStr = JSON.stringify(todos, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'todos.json';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
handleError({
|
||||
type: 'STORAGE_ERROR',
|
||||
message: 'データのエクスポートに失敗しました'
|
||||
});
|
||||
}
|
||||
}, [todos, handleError]);
|
||||
|
||||
// 型安全なインポート機能
|
||||
const importData = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e: ProgressEvent<FileReader>) => {
|
||||
try {
|
||||
const result = e.target?.result;
|
||||
if (typeof result !== 'string') {
|
||||
throw new Error('ファイルの読み込みに失敗しました');
|
||||
}
|
||||
|
||||
const parsedData: unknown = JSON.parse(result);
|
||||
|
||||
if (!isTodoArray(parsedData)) {
|
||||
throw new Error('無効なTODOデータ形式です');
|
||||
}
|
||||
|
||||
setTodos(parsedData);
|
||||
event.target.value = '';
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '不明なエラー';
|
||||
handleError({
|
||||
type: 'IMPORT_ERROR',
|
||||
message: `ファイルの読み込みに失敗しました: ${errorMessage}`
|
||||
});
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
handleError({
|
||||
type: 'IMPORT_ERROR',
|
||||
message: 'ファイルの読み込み中にエラーが発生しました'
|
||||
});
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
}, [handleError]);
|
||||
|
||||
// 型安全なLocal Storage読み込み
|
||||
useEffect(() => {
|
||||
const savedTodos = safeLocalStorageGet(STORAGE_KEY);
|
||||
if (savedTodos) {
|
||||
try {
|
||||
const parsedData: unknown = JSON.parse(savedTodos);
|
||||
|
||||
if (isTodoArray(parsedData)) {
|
||||
setTodos(parsedData);
|
||||
} else {
|
||||
throw new Error('保存されたデータの形式が正しくありません');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '不明なエラー';
|
||||
handleError({
|
||||
type: 'STORAGE_ERROR',
|
||||
message: `データ読み込みエラー: ${errorMessage}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [safeLocalStorageGet, handleError]);
|
||||
|
||||
// TODOが変更されるたびに保存(デバウンス処理付き)
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
safeLocalStorageSet(STORAGE_KEY, JSON.stringify(todos));
|
||||
}, 300); // 300ms後に保存
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [todos, safeLocalStorageSet]);
|
||||
|
||||
// JSX: 実際に表示されるUI定義
|
||||
return (
|
||||
<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"> {/* カード風の白背景 */}
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">TODOアプリ</h1>
|
||||
<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="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">TODOアプリ</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={exportData}
|
||||
className="text-blue-500 hover:text-blue-700 px-3 py-1 rounded transition-colors text-sm"
|
||||
>
|
||||
エクスポート
|
||||
</button>
|
||||
<label className="text-blue-500 hover:text-blue-700 px-3 py-1 rounded transition-colors cursor-pointer text-sm">
|
||||
インポート
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={importData}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 入力フォーム */}
|
||||
<form onSubmit={handleSubmit} className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue} // stateと連動
|
||||
onChange={(e) => setInputValue(e.target.value)} // 入力をリアルタイムに反映
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder="TODOを入力してEnterキー"
|
||||
className="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
maxLength={100}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -99,28 +372,29 @@ export default function Home() { // Reactコンポーネント。Next.jsのデ
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* TODOリスト */}
|
||||
<div className="space-y-2">
|
||||
{todos.map((todo) => (
|
||||
<div key={todo.id} className="flex items-center p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={todo.completed} // 完了状態を反映
|
||||
onChange={() => toggleTodo(todo.id)} // トグル動作
|
||||
className="mr-3 h-5 w-5 cursor-pointer"
|
||||
{/* フィルターボタン */}
|
||||
<FilterButtons
|
||||
filter={filter}
|
||||
totalCount={todos.length}
|
||||
activeCount={activeCount}
|
||||
completedCount={completedCount}
|
||||
onFilterChange={setFilter}
|
||||
onClearCompleted={clearCompleted}
|
||||
/>
|
||||
|
||||
{/* TODOリスト */}
|
||||
<TodoList
|
||||
todos={todos}
|
||||
filter={filter}
|
||||
editingId={editingId}
|
||||
editText={editText}
|
||||
onToggleTodo={toggleTodo}
|
||||
onStartEdit={startEdit}
|
||||
onSaveEdit={saveEdit}
|
||||
onCancelEdit={cancelEdit}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
onEditTextChange={setEditText}
|
||||
/>
|
||||
<span className={`flex-1 ${todo.completed ? 'line-through text-gray-500' : 'text-gray-800'}`}>
|
||||
{todo.text}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDeleteClick(todo.id)}
|
||||
className="text-red-500 hover:text-red-700 ml-2 px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
削除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 確認ダイアログ */}
|
||||
{showConfirm && (
|
||||
@@ -148,6 +422,29 @@ export default function Home() { // Reactコンポーネント。Next.jsのデ
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* エラーダイアログ */}
|
||||
{showErrorDialog && error && (
|
||||
<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 max-w-sm mx-4">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="bg-red-100 p-2 rounded-full mr-3">
|
||||
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">エラー</h3>
|
||||
</div>
|
||||
<p className="mb-4 text-gray-700">{error.message}</p>
|
||||
<button
|
||||
onClick={closeErrorDialog}
|
||||
className="w-full px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
||||
>
|
||||
閉じる
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
69
src/components/FilterButtons.tsx
Normal file
69
src/components/FilterButtons.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
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;
|
||||
};
|
||||
|
||||
const FilterButtons = React.memo(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>
|
||||
);
|
||||
});
|
||||
|
||||
export default FilterButtons;
|
||||
105
src/components/TodoItem.tsx
Normal file
105
src/components/TodoItem.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
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;
|
||||
};
|
||||
|
||||
const TodoItem = React.memo(function TodoItem({
|
||||
todo,
|
||||
isEditing,
|
||||
editText,
|
||||
onToggle,
|
||||
onStartEdit,
|
||||
onSaveEdit,
|
||||
onCancelEdit,
|
||||
onDelete,
|
||||
onEditTextChange,
|
||||
}: TodoItemProps) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') onSaveEdit();
|
||||
if (e.key === 'Escape') onCancelEdit();
|
||||
};
|
||||
|
||||
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={handleKeyDown}
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
export default TodoItem;
|
||||
77
src/components/TodoList.tsx
Normal file
77
src/components/TodoList.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useMemo } 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;
|
||||
};
|
||||
|
||||
const TodoList = React.memo(function TodoList({
|
||||
todos,
|
||||
filter,
|
||||
editingId,
|
||||
editText,
|
||||
onToggleTodo,
|
||||
onStartEdit,
|
||||
onSaveEdit,
|
||||
onCancelEdit,
|
||||
onDeleteClick,
|
||||
onEditTextChange,
|
||||
}: TodoListProps) {
|
||||
// フィルタリング処理をメモ化
|
||||
const filteredTodos = useMemo(() => {
|
||||
switch (filter) {
|
||||
case 'active':
|
||||
return todos.filter(todo => !todo.completed);
|
||||
case 'completed':
|
||||
return todos.filter(todo => todo.completed);
|
||||
default:
|
||||
return todos;
|
||||
}
|
||||
}, [todos, filter]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{filteredTodos.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
{filter === 'active' && 'すべて完了しました!'}
|
||||
{filter === 'completed' && '完了したTODOがありません'}
|
||||
{filter === 'all' && 'TODOがありません'}
|
||||
</p>
|
||||
) : (
|
||||
filteredTodos.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>
|
||||
);
|
||||
});
|
||||
|
||||
export default TodoList;
|
||||
Reference in New Issue
Block a user