ToDoリストで「しないこと」も管理:Claude 3.5とWEBアプリ改修
前回に引き続き、ToDoリストを管理するWEBアプリに関する内容です。(マイブームになりました。)
追加で5時間くらいかけて機能追加をしたので、改めて記事にさせていただきます。例によって、ソースコードは全量公開しているので(というか、WEBアプリなので全量見れますが)、良識の範囲内でご利用いただければ幸いです。
↑これだけで動きます。
↑自ドメイン格納版。
ちなみにイメージとしては、期日管理しなくてよいタスクを、おおよその時間でざっと一旦入れておく想定です。その中で超過するものがあれば、「しないこと」に振り分けても良いと考えています。
「しないこと」を管理する
通常、ToDoリスト自体は「しないこと」を管理するものではありません。しかし、今回作成したものは、通常のタスクに加えて、「しないこと」も記録できるようになっています。
これは実験的な試みで、もともとChatGPT-4oの指摘で、マイナス時間の入力を許容しているバグ報告がありました。これを、仕様として昇格させたものです。内部的には、時間をマイナス入力すると、「しないことリスト」として認識しています。
以下、補足的に「しないこと」を記録するメリットを掲載します。
「しない(やらない)ことリスト」自体は書籍やブログでも解説されているので、いくつか参考程度に以下に共有します。
※Amazonリンクはアソシエイトリンクです。
修正ポイントなど
全体の進捗バーのデザイン、機能を変更
ステータスを表示するエリアに「しないこと」を追加し、各タスクの進捗率も加味して計算するようにしました。完了した分は緑色、進行中は黄色、赤色は「しないこと」を指しています。
優先度フィルター、絞り込み機能を追加
優先度も登録できるので、絞り込みできるフィルターを追加しました。フィルタリングした際のタスクに合わせて各ステータスが再計算されるようにしましたが、全体の進捗は変えないようにしました。
Claude 3.5からは、以下のようにも言われて、悩んでこの仕様にしました。
ただ黙って実装するだけではなく、こう意見してきて、例としてパターンで示してくれるのは怖いくらいに凄いです。
その他
・各タスクの進捗を一番右までスライドさせると、そのまま完了タスクとする仕様にしました。また、チェックボタンを押した際に、「未完了→完了→しない」と3段階で遷移するようにしました。
・バグとして、マイナス値で「しないタスク」を判定していたため、0hで登録した際に正常に動作しない問題がありました。必須入力と仕様を改め、する・しないボタンを設置しました。「しない」を選択すると内部的にはマイナスで登録しますが、見た目上は表示されません。
ちなみにこのバグ(仕様)はまだ悪さをしていて、「しないこと」として1h等で登録した後に、0hとすると「完了済タスク」として判定され、さらにこのタスクだけチェックボックスをいじれなくなります。(再び時間を0以外にすれば大丈夫です。)
・スマホ向けにもさらに最適化を進めました。ただし、解像度が低い端末ではレイアウトが崩れるため、入力エリアを二段にしたり、未完了タスク等の文言から「タスク」を削って崩れないようにしています。(ブラウザの拡大率の調整で、たいてい解消します。)
まとめ
どこまでやるかという話でもありますが、しばらくは思いついたことで、そう時間がかからないことならば遊び半分で改修していこうと思います。しかし、自分がイメージしているものが、これまで(ChatGPT-4など)よりもかなり正確に実装できるのは楽しいです。いろいろ作りたくなりますね。
ちなみに、このコードをChatGPT-4oに連携して機能追加を依頼しましたが、動かないものが生成されたり、いまいちでした。指示の仕方も悪いとは思いますが、コーディング等の性能は、ChatGPT-4oより高い気がします。最も、ベースはClaude 3.5が作っているので、フェアな条件ではないです。
コード
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ゆるToDo</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.26.0/babel.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&display=swap');
body {
font-family: 'Orbitron', sans-serif;
background-color: #0a0a0a;
color: #00ff00;
}
.cyber-glow {
text-shadow: 0 0 5px #00ff00, 0 0 10px #00ff00;
}
.cyber-input {
background-color: rgba(0, 255, 0, 0.1);
border: 1px solid #00ff00;
color: #00ff00;
}
.cyber-button {
background-color: rgba(0, 255, 0, 0.2);
border: 1px solid #00ff00;
color: #00ff00;
transition: all 0.3s ease;
}
.cyber-button:hover {
background-color: rgba(0, 255, 0, 0.4);
box-shadow: 0 0 10px #00ff00;
}
.cyber-button:not(:disabled):hover {
background-color: rgba(0, 255, 0, 0.4);
box-shadow: 0 0 10px #00ff00;
}
.cyber-button:disabled {
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
}
.cyber-button:focus {
outline: none;
box-shadow: 0 0 0 2px #00ff00, 0 0 10px #00ff00;
}
.cyber-list-item {
background-color: rgba(0, 255, 0, 0.05);
border: 1px solid rgba(0, 255, 0, 0.2);
}
.cyber-stats {
background-color: rgba(0, 255, 0, 0.1);
border: 1px solid #00ff00;
box-shadow: 0 0 5px #00ff00;
}
.progress-bar {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 10px;
background: rgba(0, 255, 0, 0.1);
outline: none;
transition: 0.2s;
border-radius: 5px;
position: relative;
}
.progress-bar::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: var(--progress);
height: 100%;
background-color: #00ff00;
border-radius: 5px 0 0 5px;
z-index: 1;
}
.progress-bar::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
background: #00ff00;
cursor: pointer;
border-radius: 50%;
box-shadow: 0 0 5px #00ff00;
position: relative;
z-index: 2;
}
.progress-bar::-moz-range-thumb {
width: 20px;
height: 20px;
background: #00ff00;
cursor: pointer;
border-radius: 50%;
box-shadow: 0 0 5px #00ff00;
position: relative;
z-index: 2;
}
.progress-bar:disabled {
opacity: 0.5;
}
.progress-bar:disabled::-webkit-slider-thumb {
display: none;
}
.progress-bar:disabled::-moz-range-thumb {
display: none;
}
.progress-bar::-webkit-slider-runnable-track {
background: transparent;
}
.progress-bar::-moz-range-track {
background: transparent;
}
.progress-bar::-moz-range-progress {
background-color: #00ff00;
border-radius: 5px;
}
.task-text {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
max-height: 3em;
line-height: 1.5em;
}
.cyber-checkbox {
appearance: none;
-webkit-appearance: none;
width: 1.5em;
height: 1.5em;
border: 2px solid rgba(0, 255, 0, 0.3);
border-radius: 50%;
outline: none;
cursor: pointer;
transition: all 0.3s ease;
}
.cyber-checkbox:checked {
background-color: #00ff00;
box-shadow: 0 0 5px #00ff00, 0 0 10px #00ff00;
}
.cyber-checkbox:hover {
border-color: #00ff00;
box-shadow: 0 0 5px #00ff00;
}
@media (max-width: 640px) {
body {
font-size: 14px;
}
.task-text {
max-width: none;
white-space: normal;
}
}
.cyber-subtitle {
color: #00ffff;
text-shadow: 0 0 5px #00ffff;
letter-spacing: 2px;
}
.cyber-bracket {
color: #ff00ff;
text-shadow: 0 0 5px #ff00ff;
margin: 0 5px;
}
@keyframes pulse {
0% { opacity: 0.5; }
50% { opacity: 1; }
100% { opacity: 0.5; }
}
.cyber-subtitle {
animation: pulse 2s infinite;
}
</style>
</head>
<body class="min-h-screen text-base sm:text-lg">
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef } = React;
// CyberAlert コンポーネント
const CyberAlert = ({ message, isVisible, onClose }) => {
if (!isVisible) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-gray-900 border-2 border-green-500 p-6 rounded-lg shadow-lg max-w-sm w-full mx-4">
<p className="text-green-500 mb-4">{message}</p>
<div className="flex justify-end">
<button
onClick={onClose}
className="bg-green-500 hover:bg-green-600 text-black px-4 py-2 rounded transition duration-300 ease-in-out transform hover:scale-105"
>
OK
</button>
</div>
</div>
</div>
);
};
// Inputコンポーネント
const Input = ({ className, value, onChange, allowZero = false, ...props }) => {
const handleFocus = (e) => {
if (!allowZero && e.target.value === '0') {
e.target.value = '';
}
};
const handleBlur = (e) => {
if (!allowZero && e.target.value === '') {
e.target.value = '0';
onChange(e);
}
};
return (
<input
className={`cyber-input rounded px-2 py-1 ${className}`}
value={value}
onChange={onChange}
onFocus={handleFocus}
onBlur={handleBlur}
{...props}
/>
);
};
const NewTaskInput = ({ className, value, onChange, ...props }) => (
<input
className={`cyber-input rounded px-2 py-1 ${className}`}
value={value}
onChange={onChange}
{...props}
/>
);
const Button = ({ className, children, disabled, ...props }) => (
<button
className={`cyber-button px-3 py-1 rounded ${className} ${
disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-green-600 hover:shadow-lg'
}`}
disabled={disabled}
{...props}
>
{children}
</button>
);
const Select = ({ value, onValueChange, children }) => (
<select value={value} onChange={(e) => onValueChange(e.target.value)} className="cyber-input rounded px-2 py-1">
{children}
</select>
);
const ProgressBar = ({ value, onChange, disabled }) => (
<div className="flex items-center space-x-2">
<input
type="range"
min="0"
max="100"
value={value}
onChange={onChange}
disabled={disabled}
className="progress-bar"
style={{ "--progress": `${value}%` }}
/>
<span className="text-sm">{value}%</span>
</div>
);
// TodoAppコンポーネント
const TodoApp = () => {
const [todos, setTodos] = useState([]);
const [newTodo, setNewTodo] = useState('');
const [newEstimate, setNewEstimate] = useState('');
const [newPriority, setNewPriority] = useState('中');
const fileInputRef = useRef(null);
const [newIsNegative, setNewIsNegative] = useState(false);
const [alertMessage, setAlertMessage] = useState('');
const [isAlertVisible, setIsAlertVisible] = useState(false);
const [priorityFilter, setPriorityFilter] = useState('all');
useEffect(() => {
const storedTodos = localStorage.getItem('todos');
if (storedTodos) {
setTodos(JSON.parse(storedTodos));
}
}, []);
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
// addTodo 関数
const addTodo = () => {
const estimateValue = parseFloat(newEstimate);
if (newTodo.trim() !== '' && estimateValue && estimateValue !== 0) {
const finalEstimate = newIsNegative ? -Math.abs(estimateValue) : estimateValue;
setTodos([...todos, {
id: Date.now(),
text: newTodo,
completed: newIsNegative,
estimate: finalEstimate,
priority: newPriority,
progress: newIsNegative ? 100 : 0
}]);
setNewTodo('');
setNewEstimate('');
setNewPriority('中');
setNewIsNegative(false);
} else {
setAlertMessage('タスク名と0以外の時間を入力してください。');
setIsAlertVisible(true);
}
};
const toggleTodo = (id) => {
setTodos(todos.map(todo => {
if (todo.id === id) {
if (!todo.completed && todo.estimate >= 0) {
// 未完了 → 完了済み
return { ...todo, completed: true, progress: 100 };
} else if (todo.completed && todo.estimate >= 0) {
// 完了済み → しないタスク
return { ...todo, completed: true, estimate: -Math.abs(todo.estimate), progress: 100 };
} else {
// しないタスク → 未完了
return { ...todo, completed: false, estimate: Math.abs(todo.estimate), progress: 0 };
}
}
return todo;
}));
};
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
const updateEstimate = (id, newEstimate) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, estimate: newEstimate === '' ? 0 : parseFloat(newEstimate) } : todo
));
};
const updatePriority = (id, newPriority) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, priority: newPriority } : todo
));
};
const updateProgress = (id, newProgress) => {
setTodos(todos.map(todo =>
todo.id === id && todo.estimate >= 0 ? { ...todo, progress: newProgress, completed: newProgress === 100 } : todo
));
};
const priorityColors = {
'高': 'text-red-500',
'中': 'text-yellow-500',
'低': 'text-blue-500'
};
const getRemainingTasksCount = () => {
return filteredTodos.filter(todo => !todo.completed && todo.estimate >= 0).length;
};
const getCompletedTasksCount = () => {
return filteredTodos.filter(todo => todo.completed && todo.estimate >= 0).length;
};
const getNotDoingTasksCount = () => {
return filteredTodos.filter(todo => todo.estimate < 0).length;
};
/*優先度フィルターに応じてタスク数を切り替える実装前
const getRemainingTasksCount = () => {
return todos.filter(todo => !todo.completed && todo.estimate >= 0).length;
};
const getCompletedTasksCount = () => {
return todos.filter(todo => todo.completed && todo.estimate >= 0).length;
};
const getNotDoingTasksCount = () => {
return todos.filter(todo => todo.estimate < 0).length;
};*/
const calculateRemainingTime = () => {
return todos
.filter(todo => !todo.completed && todo.estimate >= 0)
.reduce((total, todo) => total + (todo.estimate || 0), 0)
.toFixed(1);
};
const calculateCompletedTime = () => {
return todos
.filter(todo => todo.completed && todo.estimate >= 0)
.reduce((total, todo) => total + todo.estimate, 0)
.toFixed(1);
};
const calculateNotDoingTime = () => {
return Math.abs(todos
.filter(todo => todo.estimate < 0)
.reduce((total, todo) => total + todo.estimate, 0)
.toFixed(1));
};
const exportTodos = () => {
const dataStr = JSON.stringify(todos);
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
const exportFileDefaultName = 'todos.json';
const linkElement = document.createElement('a');
linkElement.setAttribute('href', dataUri);
linkElement.setAttribute('download', exportFileDefaultName);
linkElement.click();
};
const importTodos = (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const importedTodos = JSON.parse(e.target.result);
setTodos(importedTodos);
} catch (error) {
alert('無効なJSONファイルです。');
}
};
reader.readAsText(file);
}
};
const calculateOverallProgress = () => {
if (todos.length === 0) return { done: 0, inProgress: 0, notDoing: 0 };
const totalTasks = todos.length;
const notDoingTasks = todos.filter(todo => todo.estimate < 0);
const completedTasks = todos.filter(todo => todo.completed && todo.estimate >= 0);
const inProgressTasks = todos.filter(todo => !todo.completed && todo.estimate >= 0);
const notDoingPercentage = (notDoingTasks.length / totalTasks) * 100;
const completedPercentage = (completedTasks.length / totalTasks) * 100;
const inProgressPercentage = inProgressTasks.reduce((sum, todo) => {
return sum + (todo.progress / totalTasks);
}, 0);
return {
done: Math.round(completedPercentage),
inProgress: Math.round(inProgressPercentage),
notDoing: Math.round(notDoingPercentage)
};
};
const handleKeyPress = (event, action) => {
if (event.key === 'Enter') {
action();
}
};
const isAddButtonDisabled = () => {
return !newTodo.trim() || !newEstimate || parseFloat(newEstimate) <= 0;
};
const filteredTodos = priorityFilter === 'all'
? todos
: todos.filter(todo => todo.priority === priorityFilter);
return (
<div className="max-w-full sm:max-w-4xl mx-auto mt-4 sm:mt-10 p-2 sm:p-6 bg-gray-900 rounded-lg shadow-lg border border-green-500">
<div className="text-center mb-6">
<h1 className="text-2xl sm:text-3xl font-bold cyber-glow">ゆるToDo</h1>
<p className="text-xs sm:text-sm mt-2 cyber-subtitle">
<span className="cyber-bracket">[</span>
すること・しないことも管理
<span className="cyber-bracket">]</span>
</p>
</div>
<div className="flex flex-wrap mb-4 space-y-2 sm:space-y-0 sm:space-x-2">
<NewTaskInput
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="新しいタスクを入力..."
className="flex-grow min-w-0 text-sm sm:text-base"
/>
<div className="flex w-full sm:w-auto space-x-2">
<NewTaskInput
type="number"
value={newEstimate}
onChange={(e) => setNewEstimate(e.target.value)}
placeholder="時間(必須)"
className="w-20 sm:w-16 text-sm sm:text-base"
min="0.1"
step="0.1"
required
/>
<Select
value={newPriority}
onValueChange={setNewPriority}
className="w-20 sm:w-16 text-sm sm:text-base"
>
<option value="高">高</option>
<option value="中">中</option>
<option value="低">低</option>
</Select>
<Button
onClick={() => setNewIsNegative(!newIsNegative)}
onKeyPress={(e) => handleKeyPress(e, () => setNewIsNegative(!newIsNegative))}
className={`w-1/3 sm:w-auto ${newIsNegative ? 'bg-red-500' : ''}`}
tabIndex="0"
>
{newIsNegative ? 'しない' : 'する'}
</Button>
<Button
onClick={addTodo}
className="whitespace-nowrap text-sm sm:text-base"
disabled={isAddButtonDisabled()}
>
+ 追加
</Button>
</div>
</div>
<div className="cyber-stats mb-4 p-3 rounded">
{priorityFilter !== 'all' && (
<p className="text-sm sm:text-base mb-2">
現在、優先度「{priorityFilter}」のタスクのみ表示しています。
</p>
)}
<div className="grid grid-cols-3 gap-4 text-xs sm:text-sm">
<div className="bg-gray-800 p-3 rounded">
<h3 className="text-base font-bold mb-2 text-green-400">
<span className="hidden sm:inline">未完了タスク</span>
<span className="sm:hidden">未完了</span>
</h3>
<div className="flex items-center mb-1">
<span className="mr-2">📋</span>
<span className="font-bold text-lg">{getRemainingTasksCount()}</span>
</div>
<div className="flex items-center">
<span className="mr-2">⏱️</span>
<span className="font-bold text-lg">{calculateRemainingTime()} h</span>
</div>
</div>
<div className="bg-gray-800 p-3 rounded">
<h3 className="text-base font-bold mb-2 text-blue-400">
<span className="hidden sm:inline">完了済タスク</span>
<span className="sm:hidden">完了済</span>
</h3>
<div className="flex items-center mb-1">
<span className="mr-2">✅</span>
<span className="font-bold text-lg">{getCompletedTasksCount()}</span>
</div>
<div className="flex items-center">
<span className="mr-2">⏱️</span>
<span className="font-bold text-lg">{calculateCompletedTime()} h</span>
</div>
</div>
<div className="bg-gray-800 p-3 rounded">
<h3 className="text-base font-bold mb-2 text-red-400">
<span className="hidden sm:inline">しないタスク</span>
<span className="sm:hidden">しない</span>
</h3>
<div className="flex items-center mb-1">
<span className="mr-2">🚫</span>
<span className="font-bold text-lg">{getNotDoingTasksCount()}</span>
</div>
<div className="flex items-center">
<span className="mr-2">⏱️</span>
<span className="font-bold text-lg">{calculateNotDoingTime()} h</span>
</div>
</div>
</div>
<div className="mt-3">
<div className="text-sm mb-1">
全体の進捗: {calculateOverallProgress().done + calculateOverallProgress().inProgress}% 完了 / {calculateOverallProgress().notDoing}% しない
</div>
<div className="w-full bg-gray-700 rounded-full h-2.5 relative overflow-hidden">
<div
className="absolute top-0 left-0 bg-green-600 h-full"
style={{width: `${calculateOverallProgress().done}%`}}
></div>
<div
className="absolute top-0 left-0 bg-yellow-400 h-full"
style={{width: `${calculateOverallProgress().inProgress}%`, left: `${calculateOverallProgress().done}%`}}
></div>
<div
className="absolute top-0 right-0 bg-red-600 h-full"
style={{width: `${calculateOverallProgress().notDoing}%`}}
></div>
</div>
</div>
</div>
<div className="mb-4">
<label htmlFor="priority-filter" className="mr-2 text-sm sm:text-base">優先度フィルター:</label>
<select
id="priority-filter"
value={priorityFilter}
onChange={(e) => setPriorityFilter(e.target.value)}
className="cyber-input rounded px-2 py-1 text-sm sm:text-base"
>
<option value="all">全て</option>
<option value="高">高</option>
<option value="中">中</option>
<option value="低">低</option>
</select>
</div>
<ul className="space-y-2 mb-4">
{filteredTodos.map(todo => (
<li key={todo.id} className={`cyber-list-item p-2 sm:p-3 rounded text-xs sm:text-sm ${
todo.estimate < 0 ? 'bg-red-900' : todo.completed ? 'bg-green-900' : ''
}`}>
<div className="flex items-center mb-2">
<button
onClick={() => toggleTodo(todo.id)}
className={`mr-2 flex-shrink-0 w-6 h-6 rounded-full border-2 ${
todo.estimate < 0 ? 'border-red-500 bg-red-500' :
todo.completed ? 'border-green-500 bg-green-500' : 'border-yellow-500'
}`}
>
{todo.estimate < 0 ? '✗' : todo.completed ? '✓' : ''}
</button>
<span
className={`task-text flex-grow mr-2 ${todo.completed || todo.estimate < 0 ? 'line-through' : ''} ${
todo.estimate < 0 ? 'text-red-500' : todo.completed ? 'text-green-500' : ''
}`}
title={todo.text}
>
{todo.text}
</span>
<div className="flex items-center space-x-2 ml-auto">
<div className="flex items-center">
<span className="mr-1">⏱️</span>
<Input
type="number"
value={Math.abs(todo.estimate)}
onChange={(e) => updateEstimate(todo.id, todo.estimate < 0 ? -Math.abs(e.target.value) : Math.abs(e.target.value))}
className="w-16 sm:w-16 text-sm"
min="0"
step="0.1"
/>
<span className="ml-1 text-sm">h</span>
</div>
<Select
value={todo.priority}
onValueChange={(value) => updatePriority(todo.id, value)}
className="w-16 sm:w-14 text-sm"
>
<option value="高">高</option>
<option value="中">中</option>
<option value="低">低</option>
</Select>
<span className={`flex-shrink-0 ${priorityColors[todo.priority]}`}>⚑</span>
<Button onClick={() => deleteTodo(todo.id)} className="text-red-500 hover:text-red-700 flex-shrink-0 text-sm">
✗
</Button>
</div>
</div>
<ProgressBar
value={todo.progress}
onChange={(e) => updateProgress(todo.id, parseInt(e.target.value))}
disabled={todo.completed || todo.estimate < 0}
/>
</li>
))}
</ul>
<div className="flex justify-between mb-4">
<Button onClick={exportTodos} className="mr-2">
エクスポート
</Button>
<div>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={importTodos}
accept=".json"
/>
<Button onClick={() => fileInputRef.current.click()}>
インポート
</Button>
</div>
</div>
<div className="text-center text-xs sm:text-sm text-gray-500 mb-2">
© 2024 ゆるToDo All Rights Reserved.
<br />
制作: たぬ | コーディング協力: Claude 3.5
<br />
<span className="text-xs">Version 1.3.1 "Cyber ToDo" (2024-06-24 0:52)</span>
</div>
<div className="text-center text-xs text-gray-400 mt-4">
<p>This application uses React, ReactDOM, Babel, and Tailwind CSS, which are licensed under the MIT License.</p>
<p>
<a href="https://opensource.org/licenses/MIT" className="underline hover:text-gray-300" target="_blank" rel="noopener noreferrer">
MIT License
</a>
</p>
</div>
<div className="text-xs text-gray-400 mt-2">
<p>• データはローカルストレージに保存されます</p>
<p>• 本アプリの使用は自己責任でお願いします</p>
</div>
<CyberAlert
message={alertMessage}
isVisible={isAlertVisible}
onClose={() => setIsAlertVisible(false)}
/>
</div>
);
};
ReactDOM.render(<TodoApp />, document.getElementById('root'));
</script>
</body>
</html>
この記事が気に入ったらサポートをしてみませんか?