Files
tiny-todo/index.html
T
2026-03-30 21:37:57 -07:00

783 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Todo List</title>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap"
rel="stylesheet"
/>
<style>
:root {
--bg-color: #f5f7fa;
--primary-color: #0066ff;
--primary-dark: #0052cc;
--text-color: #333;
--border-color: #ddd;
--radius: 8px;
--shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.dark-mode {
--bg-color: #1e1e1e;
--primary-color: #bb86fc;
--primary-dark: #a074f2;
--text-color: #e0e0e0;
--border-color: #444;
--shadow: none;
}
body {
font-family: "Inter", sans-serif;
background: var(--bg-color);
margin: 0;
padding: 2rem;
color: var(--text-color);
}
.container {
position: relative; /* needed for absolute toggle */
max-width: 480px;
margin: 0 auto;
background: #fff;
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 2rem;
width: 100%;
}
.dark-mode .container {
background: #2b2b2b;
}
h1 {
font-weight: 600;
text-align: center;
color: var(--primary-color);
margin-bottom: 1.5rem;
}
.input-group {
display: flex;
gap: 0.5rem;
}
#newTodoInput {
flex: 1;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: var(--radius);
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
#newTodoInput:focus {
border-color: var(--primary-color);
}
#addBtn {
background: var(--primary-color);
color: #fff;
border: none;
border-radius: var(--radius);
padding: 0.75rem 1rem;
cursor: pointer;
font-size: 1rem;
transition: background 0.2s;
}
#addBtn:hover {
background: var(--primary-dark);
}
ul {
list-style: none;
padding: 0;
margin-top: 1.5rem;
}
li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
border-bottom: 1px solid var(--border-color);
border-radius: var(--radius);
background: #fafafa;
transition: background 0.2s;
gap: 0.5rem;
position: relative; /* for highlight overlay */
}
.dark-mode li {
background: #3a3a3a;
color: var(--text-color);
}
li:hover {
background: #f0f8ff;
}
.dark-mode li:hover {
background: #4a4a4a;
}
li.completed .todo-text {
text-decoration: line-through;
color: #888;
}
.drag-handle {
cursor: move;
user-select: none;
display: inline-block;
width: 1.2em;
text-align: center;
margin-right: 0.5rem;
color: #666;
font-weight: bold;
}
.drag-handle:hover {
opacity: 0.7;
}
.todo-text {
flex: 1;
margin-left: 0.75rem;
word-break: break-word;
}
.editing-input {
flex: 1;
font-size: 1rem;
padding: 0.25rem;
border: 1px solid var(--border-color);
border-radius: var(--radius);
box-sizing: border-box;
}
button.action-btn {
background: none;
border: none;
color: #007bff;
cursor: pointer;
font-size: 0.9rem;
padding: 0.25rem 0.5rem;
border-radius: var(--radius);
transition: background 0.2s;
}
button.action-btn:hover {
background: #e0e0ff;
}
button.delete-btn {
background: #ff6b6b;
color: #fff;
padding: 0.25rem 0.5rem;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.9rem;
border: none;
transition: background 0.2s;
}
button.delete-btn:hover {
background: #e55a5a;
}
.dragging {
opacity: 0.5;
}
/* Visual cue draws a line either ABOVE or BELOW the target li */
.indicator-above {
border-top: 3px solid var(--primary-color);
} /* line ABOVE the item */
.indicator-below {
border-bottom: 3px solid var(--primary-color);
} /* line BELOW the item */
.theme-toggle {
position: absolute;
top: 1rem;
right: 1rem;
background: transparent;
border: none;
color: var(--text-color);
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
line-height: 1;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.theme-toggle:hover {
background: rgba(0, 0, 0, 0.1);
}
</style>
</head>
<body>
<div class="container">
<div style="text-align: center; margin-bottom: 1.5rem">
<h1
id="todoTitle"
style="cursor: pointer; display: inline-block"
>
Todo List
</h1>
</div>
<div class="input-group">
<input
type="text"
id="newTodoInput"
placeholder="What needs to be done?"
/>
<button id="addBtn">Add</button>
</div>
<ul id="todoList"></ul>
<button
id="themeToggle"
class="theme-toggle"
aria-label="Toggle dark mode"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
id="theme-icon"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<script>
const input = document.getElementById("newTodoInput");
const addBtn = document.getElementById("addBtn");
const list = document.getElementById("todoList");
const themeToggle = document.getElementById("themeToggle");
const todoTitle = document.getElementById("todoTitle");
// Load theme preference from localStorage
if (localStorage.getItem("darkMode") === "enabled") {
document.body.classList.add("dark-mode");
// Update icon to sun for dark mode
themeToggle.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="5" fill="currentColor" />
<g fill="currentColor">
<circle cx="12" cy="2" r="1" />
<circle cx="12" cy="22" r="1" />
<circle cx="2" cy="12" r="1" />
<circle cx="22" cy="12" r="1" />
<circle cx="4.22" cy="4.22" r="1" />
<circle cx="19.78" cy="19.78" r="1" />
<circle cx="4.22" cy="19.78" r="1" />
<circle cx="19.78" cy="4.22" r="1" />
</g>
</svg>
`;
}
// Load saved title from localStorage or use default
const savedTitle = localStorage.getItem("todoListTitle");
if (savedTitle) {
todoTitle.textContent = savedTitle;
document.title = savedTitle;
}
// Make title editable by clicking
todoTitle.addEventListener("click", function () {
const currentTitle = todoTitle.textContent;
todoTitle.innerHTML = `<input type="text" id="titleInput" value="${currentTitle}" style="font-size: 1.5rem; font-weight: 600; width: 200px; border: 1px solid var(--border-color); padding: 0.25rem; border-radius: var(--radius); text-align: center;">`;
const titleInput = document.getElementById("titleInput");
titleInput.focus();
titleInput.select();
// Handle saving when pressing Enter or losing focus
titleInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
saveTitle();
}
});
titleInput.addEventListener("blur", saveTitle);
});
function saveTitle() {
const titleInput = document.getElementById("titleInput");
if (titleInput) {
const newTitle = titleInput.value.trim();
if (newTitle) {
todoTitle.textContent = newTitle;
document.title = newTitle; // Update browser tab title
localStorage.setItem("todoListTitle", newTitle); // Save to localStorage
} else {
todoTitle.textContent = "Todo List"; // Reset to default
document.title = "Todo List";
localStorage.removeItem("todoListTitle"); // Remove from localStorage
}
}
}
// Load all todos from localStorage
function loadTodos() {
const todos = JSON.parse(localStorage.getItem("todos") || "[]");
list.innerHTML = "";
todos.forEach((todo) => {
const li = document.createElement("li");
li.dataset.id = todo.id;
li.setAttribute("draggable", "true");
li.className = todo.completed ? "completed" : "";
const dragHandle = document.createElement("span");
dragHandle.className = "drag-handle";
dragHandle.textContent = "≡";
dragHandle.title = "Reorder";
li.prepend(dragHandle);
// Create checkbox for strikethrough
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.style.marginRight = "0.5rem";
checkbox.style.cursor = "pointer";
// Set checkbox state based on todo completion
if (todo.completed) {
checkbox.checked = true;
}
// Add event listener to checkbox
checkbox.addEventListener("change", function (e) {
e.stopPropagation(); // Prevent event from bubbling up to parent
// Find the todo text span
const textSpan = li.querySelector(".todo-text");
if (this.checked) {
textSpan.style.textDecoration = "line-through";
textSpan.style.color = "#888";
} else {
textSpan.style.textDecoration = "none";
textSpan.style.color = "";
}
// Update todo in localStorage
const todos = JSON.parse(
localStorage.getItem("todos") || "[]",
);
const updatedTodos = todos.map((t) =>
t.id === todo.id
? { ...t, completed: this.checked }
: t,
);
// Move checked item to correct position (place at bottom of list)
if (this.checked) {
// Save updated todos to localStorage
localStorage.setItem(
"todos",
JSON.stringify(updatedTodos),
);
// Mark this li as completed
li.classList.add("completed");
// Find the last unchecked item to place completed item after it
let lastUncheckedItem = null;
for (
let i = list.children.length - 1;
i >= 0;
i--
) {
if (
!list.children[i].classList.contains(
"completed",
)
) {
lastUncheckedItem = list.children[i];
break;
}
}
// If there are unchecked items, insert after the last unchecked item
// Otherwise, append to the end of the list
if (lastUncheckedItem) {
list.insertBefore(
li,
lastUncheckedItem.nextSibling,
);
} else {
list.appendChild(li);
}
// Get the new order from DOM and reorder todos
const newOrder = Array.from(list.children).map(
(li) => parseInt(li.dataset.id),
);
const reorderedTodos = newOrder.map((id) =>
updatedTodos.find((t) => t.id === id),
);
localStorage.setItem(
"todos",
JSON.stringify(reorderedTodos),
);
} else {
// Capture the current position of li BEFORE any changes
const currentIndex = Array.from(
list.children,
).indexOf(li);
// Find the first checked item BEFORE removing completed class
const firstCheckedItem = Array.from(
list.children,
).find((sibling) =>
sibling.classList.contains("completed"),
);
// Find the last unchecked item (excluding this li) to place unchecked item after it
let lastUncheckedItem = null;
for (
let i = list.children.length - 1;
i >= 0;
i--
) {
const sibling = list.children[i];
if (
sibling !== li &&
!sibling.classList.contains("completed")
) {
lastUncheckedItem = sibling;
break;
}
}
// Remove completed class when unchecking
li.classList.remove("completed");
// If there are checked items, insert before the first checked item
// If there are unchecked items before this one, insert after the last unchecked item
// Otherwise, append to the end of unchecked items
if (firstCheckedItem) {
const firstCheckedIndex = Array.from(
list.children,
).indexOf(firstCheckedItem);
const lastIndex = Array.from(
list.children,
).indexOf(li);
if (
lastUncheckedItem &&
lastIndex > 0 &&
Array.from(list.children).indexOf(
lastUncheckedItem,
) < lastIndex
) {
list.insertBefore(
li,
lastUncheckedItem.nextSibling,
);
} else {
list.insertBefore(li, firstCheckedItem);
}
} else if (
lastUncheckedItem &&
currentIndex > 0 &&
Array.from(list.children).indexOf(
lastUncheckedItem,
) < currentIndex
) {
list.insertBefore(
li,
lastUncheckedItem.nextSibling,
);
}
// Get the new order from DOM after reordering and save to localStorage
const newOrder = Array.from(list.children).map(
(li) => parseInt(li.dataset.id),
);
const reorderedTodos = newOrder.map((id) =>
updatedTodos.find((t) => t.id === id),
);
localStorage.setItem(
"todos",
JSON.stringify(reorderedTodos),
);
}
});
// Insert checkbox at the beginning of the li
li.insertBefore(checkbox, li.firstChild);
const span = document.createElement("span");
span.className = "todo-text";
span.textContent = todo.text;
span.style.cursor = "pointer"; // Make it clear it's editable
li.appendChild(span);
// Make the entire li clickable for editing
li.addEventListener("click", (e) => {
// Don't trigger edit if clicking on the delete button, edit button, or checkbox
if (
e.target.classList.contains("delete-btn") ||
e.target.classList.contains("action-btn") ||
e.target.type === "checkbox"
) {
return;
}
startEdit(li);
});
const delBtn = document.createElement("button");
delBtn.className = "delete-btn";
delBtn.textContent = "Delete";
delBtn.onclick = () => deleteTodo(todo.id);
li.appendChild(delBtn);
// ---------- Drag & Drop Handlers ----------
li.addEventListener("dragstart", (e) => {
e.dataTransfer.setData("text/plain", todo.id);
e.dataTransfer.effectAllowed = "move";
li.classList.add("dragging");
});
li.addEventListener("dragover", (e) => {
e.preventDefault(); // allow drop
// Remove any existing indicator classes from ALL list items
document
.querySelectorAll(
".indicator-above, .indicator-below",
)
.forEach((el) =>
el.classList.remove(
"indicator-above",
"indicator-below",
),
);
const targetLi = li; // the li we are hovering over
const rect = targetLi.getBoundingClientRect();
const offsetY = e.clientY - rect.top;
const halfway = rect.height / 2;
// If cursor is in the upper half of the item, we will INSERT ABOVE it
const insertAbove = offsetY < halfway;
// Store the decision on the element so we can reuse it later
targetLi.dataset.insertAbove = insertAbove;
// Add the appropriate visual indicator class to THIS li only
if (insertAbove) {
targetLi.classList.add("indicator-above"); // draws a line ABOVE the item
} else {
targetLi.classList.add("indicator-below"); // draws a line BELOW the item
}
});
li.addEventListener("dragleave", (e) => {
// Clean up highlight as soon as the cursor leaves this li
highlightDropTarget(li, false);
});
li.addEventListener("drop", handleDrop);
li.addEventListener("dragend", () => {
li.classList.remove("dragging");
// Always strip any lingering indicator classes
document
.querySelectorAll(
".indicator-above, .indicator-below",
)
.forEach((el) =>
el.classList.remove(
"indicator-above",
"indicator-below",
),
);
// Clean the stored flag
delete targetLi.dataset.insertAbove;
});
// --------------------------- ----------------
list.appendChild(li);
});
}
// Add a new todo
async function addTodo() {
const text = input.value.trim();
if (!text) return;
// Get existing todos from localStorage
const todos = JSON.parse(localStorage.getItem("todos") || "[]");
// Create new todo with unique ID
const newTodo = {
id: Date.now(),
text: text,
completed: false,
};
// Add new todo to the array
todos.push(newTodo);
// Save to localStorage
localStorage.setItem("todos", JSON.stringify(todos));
input.value = "";
loadTodos();
}
// Delete a todo
async function deleteTodo(id) {
if (!confirm("Delete this todo?")) return;
// Get existing todos from localStorage
let todos = JSON.parse(localStorage.getItem("todos") || "[]");
// Filter out the todo with the given ID
todos = todos.filter((t) => t.id !== id);
// Save to localStorage
localStorage.setItem("todos", JSON.stringify(todos));
loadTodos();
}
// Inline edit
function startEdit(li) {
const id = li.dataset.id;
const textSpan = li.querySelector(".todo-text");
if (!textSpan) return;
const editingInput = document.createElement("input");
editingInput.type = "text";
editingInput.value = textSpan.textContent;
editingInput.className = "editing-input";
editingInput.style.width = "100%";
editingInput.setAttribute("autofocus", "");
li.replaceChild(editingInput, textSpan);
editingInput.focus();
function commit() {
const newText = editingInput.value.trim();
if (newText !== textSpan.textContent) {
// Get existing todos from localStorage
const todos = JSON.parse(
localStorage.getItem("todos") || "[]",
);
// Update todo text
const updatedTodos = todos.map((t) =>
t.id === id ? { ...t, text: newText } : t,
);
// Save to localStorage
localStorage.setItem(
"todos",
JSON.stringify(updatedTodos),
);
loadTodos();
} else {
loadTodos();
}
}
editingInput.addEventListener("blur", commit);
editingInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") commit();
});
}
// Highlight the target li with either .indicator-above or .indicator-below
function highlightDropTarget(li, remove) {
if (!remove) {
// Caller will handle removal we just return here for clarity
return;
}
li.classList.remove("indicator-above", "indicator-below");
}
// Handle drop to reorder items
async function handleDrop(event) {
event.preventDefault();
const draggedId = event.dataTransfer.getData("text/plain");
const targetLi = event.currentTarget.closest("li");
if (!targetLi || targetLi.dataset.id === draggedId) return;
// Retrieve the insertion side we stored earlier
const insertAbove = targetLi.dataset.insertAbove === "true";
// Remove dragged element from the DOM
const draggedLi = list.querySelector(
`[data-id="${draggedId}"]`,
);
if (!draggedLi) return;
list.removeChild(draggedLi);
// Insert based on the stored decision
if (insertAbove) {
// Insert *before* the target (so dragged item appears ABOVE it)
list.insertBefore(draggedLi, targetLi);
} else {
// Insert *after* the target (so dragged item appears BELOW it)
// If target is the last child, just append
if (targetLi.nextSibling) {
list.insertBefore(draggedLi, targetLi.nextSibling);
} else {
list.appendChild(draggedLi);
}
}
// Clean up any lingering indicator classes and the stored flag
document
.querySelectorAll(".indicator-above, .indicator-below")
.forEach((el) =>
el.classList.remove(
"indicator-above",
"indicator-below",
),
);
delete targetLi.dataset.insertAbove;
// Persist the new order to localStorage
const todos = JSON.parse(localStorage.getItem("todos") || "[]");
const newOrder = Array.from(list.children).map((li) =>
parseInt(li.dataset.id),
);
const reorderedTodos = newOrder.map((id) =>
todos.find((t) => t.id === id),
);
localStorage.setItem("todos", JSON.stringify(reorderedTodos));
}
// Theme toggle
themeToggle.addEventListener("click", () => {
document.body.classList.toggle("dark-mode");
if (document.body.classList.contains("dark-mode")) {
localStorage.setItem("darkMode", "enabled");
// Update icon to sun for dark mode
themeToggle.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="5" fill="currentColor" />
<g fill="currentColor">
<circle cx="12" cy="2" r="1" />
<circle cx="12" cy="22" r="1" />
<circle cx="2" cy="12" r="1" />
<circle cx="22" cy="12" r="1" />
<circle cx="4.22" cy="4.22" r="1" />
<circle cx="19.78" cy="19.78" r="1" />
<circle cx="4.22" cy="19.78" r="1" />
<circle cx="19.78" cy="4.22" r="1" />
</g>
</svg>
`;
} else {
localStorage.setItem("darkMode", "disabled");
// Update icon to moon for light mode
themeToggle.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="theme-icon" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
}
});
// Kick off
addBtn.onclick = addTodo;
input.addEventListener("keypress", (e) => {
if (e.key === "Enter") addTodo();
});
loadTodos();
</script>
</body>
</html>