forked from semi-23e/nextjs-todo-tutorial
エラーハンドリングまでを追記
This commit is contained in:
300
src/app/page.tsx
300
src/app/page.tsx
@@ -13,75 +13,143 @@ type Todo = {
|
|||||||
|
|
||||||
type FilterType = 'all' | 'active' | 'completed';
|
type FilterType = 'all' | 'active' | 'completed';
|
||||||
|
|
||||||
|
// エラー型の定義
|
||||||
|
type AppError = {
|
||||||
|
type: 'STORAGE_ERROR' | 'IMPORT_ERROR' | 'VALIDATION_ERROR';
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
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リスト本体の状態管理
|
// 状態管理
|
||||||
const [todos, setTodos] = useState<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
|
|
||||||
const [targetId, setTargetId] = useState<number | null>(null);
|
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);
|
||||||
|
|
||||||
// 編集中のテキスト
|
|
||||||
const [editText, setEditText] = useState('');
|
const [editText, setEditText] = useState('');
|
||||||
|
|
||||||
// フィルター状態の管理(全て、未完了、完了)
|
|
||||||
const [filter, setFilter] = useState<FilterType>('all');
|
const [filter, setFilter] = useState<FilterType>('all');
|
||||||
|
|
||||||
|
// エラー状態の管理
|
||||||
|
const [error, setError] = useState<AppError | null>(null);
|
||||||
|
const [showErrorDialog, setShowErrorDialog] = useState(false);
|
||||||
|
|
||||||
// フィルターに応じたTodoの数をカウント
|
// フィルターに応じたTodoの数をカウント
|
||||||
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 handleError = (error: AppError) => {
|
||||||
|
console.error('アプリケーションエラー:', error);
|
||||||
|
setError(error);
|
||||||
|
setShowErrorDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// エラーダイアログを閉じる
|
||||||
|
const closeErrorDialog = () => {
|
||||||
|
setShowErrorDialog(false);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// バリデーション関数
|
||||||
|
const validateTodoText = (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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Local Storage操作をtry-catchで包む
|
||||||
|
const safeLocalStorageGet = (key: string): string | null => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key);
|
||||||
|
} catch (error) {
|
||||||
|
handleError({
|
||||||
|
type: 'STORAGE_ERROR',
|
||||||
|
message: 'データの読み込みに失敗しました'
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeLocalStorageSet = (key: string, value: string): boolean => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
handleError({
|
||||||
|
type: 'STORAGE_ERROR',
|
||||||
|
message: 'データの保存に失敗しました'
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const startEdit = (todo: Todo) => {
|
const startEdit = (todo: Todo) => {
|
||||||
setEditingId(todo.id);
|
setEditingId(todo.id);
|
||||||
setEditText(todo.text);
|
setEditText(todo.text);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveEdit = () => {
|
const saveEdit = () => {
|
||||||
if (editText.trim() === '') {
|
if (!validateTodoText(editText)) {
|
||||||
alert('TODOが空です');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedTodos = todos.map(todo => {
|
try {
|
||||||
if (todo.id === editingId) {
|
const updatedTodos = todos.map(todo => {
|
||||||
return { ...todo, text: editText };
|
if (todo.id === editingId) {
|
||||||
}
|
return { ...todo, text: editText.trim() };
|
||||||
return todo;
|
}
|
||||||
});
|
return todo;
|
||||||
|
});
|
||||||
|
|
||||||
setTodos(updatedTodos);
|
setTodos(updatedTodos);
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setEditText('');
|
setEditText('');
|
||||||
|
} catch (error) {
|
||||||
|
handleError({
|
||||||
|
type: 'VALIDATION_ERROR',
|
||||||
|
message: 'TODOの更新に失敗しました'
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 新しいTODOを追加する関数
|
// 新しいTODOを追加する関数(エラーハンドリング追加)
|
||||||
const addTodo = () => {
|
const addTodo = () => {
|
||||||
if (inputValue.trim() === '') {
|
if (!validateTodoText(inputValue)) {
|
||||||
setShowInputAlert(true);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newTodo: Todo = {
|
try {
|
||||||
id: Date.now(),
|
const newTodo: Todo = {
|
||||||
text: inputValue,
|
id: Date.now(),
|
||||||
completed: false
|
text: inputValue.trim(),
|
||||||
};
|
completed: false
|
||||||
setTodos([...todos, newTodo]);
|
};
|
||||||
setInputValue('');
|
setTodos([...todos, newTodo]);
|
||||||
|
setInputValue('');
|
||||||
|
} catch (error) {
|
||||||
|
handleError({
|
||||||
|
type: 'VALIDATION_ERROR',
|
||||||
|
message: 'TODOの追加に失敗しました'
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelEdit = () => {
|
const cancelEdit = () => {
|
||||||
@@ -97,13 +165,20 @@ export default function Home() {
|
|||||||
|
|
||||||
// Todoの完了状態をトグルする関数
|
// Todoの完了状態をトグルする関数
|
||||||
const toggleTodo = (id: number) => {
|
const toggleTodo = (id: number) => {
|
||||||
const updatedTodos = todos.map(todo => {
|
try {
|
||||||
if (todo.id === id) {
|
const updatedTodos = todos.map(todo => {
|
||||||
return { ...todo, completed: !todo.completed };
|
if (todo.id === id) {
|
||||||
}
|
return { ...todo, completed: !todo.completed };
|
||||||
return todo;
|
}
|
||||||
});
|
return todo;
|
||||||
setTodos(updatedTodos);
|
});
|
||||||
|
setTodos(updatedTodos);
|
||||||
|
} catch (error) {
|
||||||
|
handleError({
|
||||||
|
type: 'VALIDATION_ERROR',
|
||||||
|
message: 'TODO状態の更新に失敗しました'
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 削除ボタンが押されたときの処理(確認ダイアログ表示)
|
// 削除ボタンが押されたときの処理(確認ダイアログ表示)
|
||||||
@@ -115,9 +190,16 @@ export default function Home() {
|
|||||||
// ダイアログで「はい」が押されたときの本削除処理
|
// ダイアログで「はい」が押されたときの本削除処理
|
||||||
const confirmDelete = () => {
|
const confirmDelete = () => {
|
||||||
if (targetId !== null) {
|
if (targetId !== null) {
|
||||||
setTodos(todos.filter(todo => todo.id !== targetId));
|
try {
|
||||||
setShowConfirm(false);
|
setTodos(todos.filter(todo => todo.id !== targetId));
|
||||||
setTargetId(null);
|
setShowConfirm(false);
|
||||||
|
setTargetId(null);
|
||||||
|
} catch (error) {
|
||||||
|
handleError({
|
||||||
|
type: 'VALIDATION_ERROR',
|
||||||
|
message: 'TODOの削除に失敗しました'
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,52 +216,112 @@ export default function Home() {
|
|||||||
|
|
||||||
// 完了済みTODOをすべて削除
|
// 完了済みTODOをすべて削除
|
||||||
const clearCompleted = () => {
|
const clearCompleted = () => {
|
||||||
setTodos(todos.filter(todo => !todo.completed));
|
try {
|
||||||
|
setTodos(todos.filter(todo => !todo.completed));
|
||||||
|
} catch (error) {
|
||||||
|
handleError({
|
||||||
|
type: 'VALIDATION_ERROR',
|
||||||
|
message: '完了済みTODOの削除に失敗しました'
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// アプリ起動時にデータを読み込み
|
// アプリ起動時にデータを読み込み(エラーハンドリング強化)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedTodos = localStorage.getItem(STORAGE_KEY);
|
const savedTodos = safeLocalStorageGet(STORAGE_KEY);
|
||||||
if (savedTodos) {
|
if (savedTodos) {
|
||||||
try {
|
try {
|
||||||
const parsedTodos = JSON.parse(savedTodos);
|
const parsedTodos = JSON.parse(savedTodos);
|
||||||
setTodos(parsedTodos);
|
|
||||||
|
// データの妥当性をチェック
|
||||||
|
if (Array.isArray(parsedTodos)) {
|
||||||
|
const validTodos = parsedTodos.filter(todo =>
|
||||||
|
todo &&
|
||||||
|
typeof todo.id === 'number' &&
|
||||||
|
typeof todo.text === 'string' &&
|
||||||
|
typeof todo.completed === 'boolean'
|
||||||
|
);
|
||||||
|
setTodos(validTodos);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('データの読み込みに失敗しました:', error);
|
handleError({
|
||||||
|
type: 'STORAGE_ERROR',
|
||||||
|
message: '保存されたデータの形式が正しくありません'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// TODOが変更されるたびに保存
|
// TODOが変更されるたびに保存(エラーハンドリング追加)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
|
safeLocalStorageSet(STORAGE_KEY, JSON.stringify(todos));
|
||||||
}, [todos]);
|
}, [todos]);
|
||||||
|
|
||||||
// データのエクスポート機能
|
// データのエクスポート機能(エラーハンドリング追加)
|
||||||
const exportData = () => {
|
const exportData = () => {
|
||||||
const dataStr = JSON.stringify(todos, null, 2);
|
try {
|
||||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
const dataStr = JSON.stringify(todos, null, 2);
|
||||||
const url = URL.createObjectURL(dataBlob);
|
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||||
const link = document.createElement('a');
|
const url = URL.createObjectURL(dataBlob);
|
||||||
link.href = url;
|
const link = document.createElement('a');
|
||||||
link.download = 'todos.json';
|
link.href = url;
|
||||||
link.click();
|
link.download = 'todos.json';
|
||||||
URL.revokeObjectURL(url);
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
handleError({
|
||||||
|
type: 'STORAGE_ERROR',
|
||||||
|
message: 'データのエクスポートに失敗しました'
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// データのインポート機能
|
// データのインポート機能(エラーハンドリング強化)
|
||||||
const importData = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const importData = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
try {
|
try {
|
||||||
const importedTodos = JSON.parse(e.target?.result as string);
|
const importedData = JSON.parse(e.target?.result as string);
|
||||||
setTodos(importedTodos);
|
|
||||||
|
// インポートデータの妥当性をチェック
|
||||||
|
if (!Array.isArray(importedData)) {
|
||||||
|
throw new Error('無効なデータ形式です');
|
||||||
|
}
|
||||||
|
|
||||||
|
const validTodos = importedData.filter(todo =>
|
||||||
|
todo &&
|
||||||
|
typeof todo.id === 'number' &&
|
||||||
|
typeof todo.text === 'string' &&
|
||||||
|
typeof todo.completed === 'boolean'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (validTodos.length === 0) {
|
||||||
|
throw new Error('有効なTODOデータが見つかりません');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTodos(validTodos);
|
||||||
|
|
||||||
|
// ファイル入力をリセット
|
||||||
|
event.target.value = '';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('ファイルの読み込みに失敗しました');
|
handleError({
|
||||||
|
type: 'IMPORT_ERROR',
|
||||||
|
message: 'ファイルの読み込みに失敗しました。正しいJSONファイルを選択してください。'
|
||||||
|
});
|
||||||
|
event.target.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => {
|
||||||
|
handleError({
|
||||||
|
type: 'IMPORT_ERROR',
|
||||||
|
message: 'ファイルの読み込み中にエラーが発生しました'
|
||||||
|
});
|
||||||
|
event.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -187,7 +329,7 @@ export default function Home() {
|
|||||||
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>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -217,6 +359,7 @@ export default function Home() {
|
|||||||
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"
|
||||||
|
maxLength={100}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -276,6 +419,29 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user