Initial commit

This commit is contained in:
ryang3d
2026-03-30 21:37:57 -07:00
commit 424d85a16b
6 changed files with 2009 additions and 0 deletions
+782
View File
@@ -0,0 +1,782 @@
<!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>