Compare commits

...

9 Commits

4 changed files with 638 additions and 90 deletions

View File

@@ -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;
}
const newTodo: Todo = {
id: Date.now(),
text: inputValue,
completed: false
};
setTodos([...todos, newTodo]);
setInputValue('');
};
// フォームが送信されたときの処理
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); // ページ全体のリロードを防ぐ。SPA的挙動にする。
addTodo(); // addTodo関数を実行。
};
try {
const updatedTodos = todos.map(todo => {
if (todo.id === editingId) {
return { ...todo, text: editText.trim() };
}
return todo;
});
// Todoの完了状態をトグル反転する関数
const toggleTodo = (id: number) => {
const updatedTodos = todos.map(todo => {
if (todo.id === id) {
return { ...todo, completed: !todo.completed }; // 対象のcompletedだけ反転。
}
return 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); // 状態を初期化
setTodos(updatedTodos);
setEditingId(null);
setEditText('');
} catch (error) {
handleError({
type: 'VALIDATION_ERROR',
message: 'TODOの更新に失敗しました'
});
}
};
}, [editText, editingId, todos, validateTodoText, handleError]);
// ダイアログで「いいえ」が押されたときの処理
const cancelDelete = () => {
const cancelEdit = useCallback(() => {
setEditingId(null);
setEditText('');
}, []);
// TODO操作の関数をメモ化
const addTodo = useCallback(() => {
if (!validateTodoText(inputValue)) {
return;
}
try {
const newTodo: Todo = {
id: Date.now(),
text: inputValue.trim(),
completed: false
};
setTodos(prev => [...prev, newTodo]);
setInputValue('');
} catch (error) {
handleError({
type: 'VALIDATION_ERROR',
message: 'TODOの追加に失敗しました'
});
}
}, [inputValue, validateTodoText, handleError]);
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 cancelDelete = useCallback(() => {
setShowConfirm(false);
setTargetId(null);
};
}, []);
// 空欄入力時ダイアログを閉じる関数
const closeInputAlert = () => {
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>
{/* フィルターボタン */}
<FilterButtons
filter={filter}
totalCount={todos.length}
activeCount={activeCount}
completedCount={completedCount}
onFilterChange={setFilter}
onClearCompleted={clearCompleted}
/>
{/* 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"
/>
<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>
<TodoList
todos={todos}
filter={filter}
editingId={editingId}
editText={editText}
onToggleTodo={toggleTodo}
onStartEdit={startEdit}
onSaveEdit={saveEdit}
onCancelEdit={cancelEdit}
onDeleteClick={handleDeleteClick}
onEditTextChange={setEditText}
/>
{/* 確認ダイアログ */}
{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>
);

View 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
View 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;

View 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;