diff --git a/main.py b/main.py index f39531e..5809e85 100644 --- a/main.py +++ b/main.py @@ -1,16 +1,19 @@ -from fastapi import FastAPI, Request, Form -from fastapi.responses import HTMLResponse +from fastapi import FastAPI, Request, Form, HTTPException +from fastapi.responses import HTMLResponse, JSONResponse from fastapi.templating import Jinja2Templates from fastapi.staticfiles import StaticFiles from fastapi.responses import RedirectResponse import csv import uuid +import copy app = FastAPI() app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") +last_action = {"type": None, "data": None} + # Helper functions for CSV operations def read_todos(): try: @@ -19,6 +22,8 @@ def read_todos(): return [{"id": row[0], "task": row[1], "done": row[2]} for row in reader if len(row) >= 3] except FileNotFoundError: return [] + except csv.Error: + return [] # Return an empty list if there's an error reading the CSV def write_todos(todos): with open("todos.csv", "w", newline="") as f: @@ -27,43 +32,103 @@ def write_todos(todos): writer.writerow([todo["id"], todo["task"], todo["done"]]) def initialize_todos_file(): - try: - with open("todos.csv", "x") as f: - pass # Create the file if it doesn't exist - except FileExistsError: - pass # File already exists, do nothing + # Create an empty file, overwriting any existing content + with open("todos.csv", "w", newline="") as f: + pass # This creates an empty file without writing anything + +# Add this new function to update last_action +def update_last_action(action_type, data): + global last_action + last_action = {"type": action_type, "data": data} + print(f"Last action updated: {last_action}") # Debug log @app.get("/", response_class=HTMLResponse) async def home(request: Request): todos = read_todos() return templates.TemplateResponse(request, "index.html", {"todos": todos}) +# Update the add_todo function @app.post("/add") async def add_todo(task: str = Form(...), done: bool = Form(False)): todos = read_todos() - new_todo = {"id": str(uuid.uuid4()), "task": task, "done": done} + new_todo = {"id": str(uuid.uuid4()), "task": task, "done": str(done).lower()} todos.append(new_todo) write_todos(todos) + update_last_action("add", new_todo) return RedirectResponse(url="/", status_code=303) +# Update the update_todo function @app.post("/update/{todo_id}") -async def update_todo(todo_id: str, done: bool = Form(...)): +def update_todo(todo_id: str, done: bool = Form(...)): todos = read_todos() - for todo in todos: - if todo["id"] == todo_id: - todo["done"] = str(done).lower() # Convert to lowercase string - break + todo = next((todo for todo in todos if todo["id"] == todo_id), None) + if todo is None: + raise HTTPException(status_code=404, detail="Todo not found") + old_todo = copy.deepcopy(todo) + todo["done"] = str(done).lower() write_todos(todos) + update_last_action("update", old_todo) return RedirectResponse(url="/", status_code=303) +# Update the delete_todo function @app.post("/delete/{todo_id}") -async def delete_todo(todo_id: str): +def delete_todo(todo_id: str): todos = read_todos() - todos = [todo for todo in todos if todo["id"] != todo_id] + todo = next((todo for todo in todos if todo["id"] == todo_id), None) + if todo is None: + raise HTTPException(status_code=404, detail="Todo not found") + todos.remove(todo) write_todos(todos) + update_last_action("delete", todo) return RedirectResponse(url="/", status_code=303) +# Update the clear_todos function +@app.post("/clear") +async def clear_todos(): + old_todos = read_todos() + initialize_todos_file() + update_last_action("clear", old_todos) + return JSONResponse(content={"status": "success", "todos": []}, status_code=200) + +# Update the undo_last_action function +@app.post("/undo") +async def undo_last_action(): + global last_action + todos = read_todos() + + print(f"Undo called. Last action: {last_action}") # Debug log + + if last_action["type"] is None or last_action["data"] is None: + print("No action to undo") # Debug log + return JSONResponse(content={"status": "no_action", "todos": todos}, status_code=200) + + if last_action["type"] == "add": + print(f"Undoing add action: {last_action['data']}") # Debug log + todos = [todo for todo in todos if todo["id"] != last_action["data"]["id"]] + elif last_action["type"] == "update": + print(f"Undoing update action: {last_action['data']}") # Debug log + for todo in todos: + if todo["id"] == last_action["data"]["id"]: + todo.update(last_action["data"]) + break + elif last_action["type"] == "delete": + print(f"Undoing delete action: {last_action['data']}") # Debug log + todos.append(last_action["data"]) + elif last_action["type"] == "clear": + print(f"Undoing clear action: {last_action['data']}") # Debug log + todos = last_action["data"] + + write_todos(todos) + print(f"Todos after undo: {todos}") # Debug log + last_action = {"type": None, "data": None} + return JSONResponse(content={"status": "success", "todos": todos}, status_code=200) + +# Add this new route to check the last action +@app.get("/last-action") +async def get_last_action(): + return JSONResponse(content={"last_action": last_action}, status_code=200) + if __name__ == "__main__": import uvicorn - initialize_todos_file() + initialize_todos_file() # This will ensure an empty file at startup uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 513e30b..49e0b5c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ uvicorn jinja2 python-multipart pytest +pytest-cov httpx \ No newline at end of file diff --git a/static/script.js b/static/script.js index a6a4c4d..1c89223 100644 --- a/static/script.js +++ b/static/script.js @@ -3,22 +3,73 @@ document.addEventListener('DOMContentLoaded', () => { const todoInput = document.getElementById('todo-input'); const todoList = document.getElementById('todo-list'); const darkModeToggle = document.getElementById('dark-mode-toggle'); + const clearTodosButton = document.getElementById('clear-todos'); + const undoButton = document.getElementById('undo-button'); // Function to fetch and render todos - const renderTodos = async () => { + const renderTodos = async (animateLastItem = false) => { const response = await fetch('/'); const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const newTodoList = doc.getElementById('todo-list'); - todoList.innerHTML = newTodoList.innerHTML; - - // Add 'new-item' class to the last item for animation - if (todoList.lastElementChild) { - todoList.lastElementChild.classList.add('new-item'); - setTimeout(() => todoList.lastElementChild.classList.remove('new-item'), 500); + if (newTodoList && newTodoList.children.length > 0) { + // Create a document fragment to build the new list + const fragment = document.createDocumentFragment(); + Array.from(newTodoList.children).forEach(newItem => { + const existingItem = todoList.querySelector(`[data-id="${newItem.dataset.id}"]`); + if (existingItem) { + // Update existing item + existingItem.innerHTML = newItem.innerHTML; + // Update checkbox state and 'done' class + const checkbox = existingItem.querySelector('.todo-checkbox'); + if (checkbox) { + checkbox.checked = newItem.querySelector('.todo-checkbox').checked; + existingItem.classList.toggle('done', checkbox.checked); + } + fragment.appendChild(existingItem); + } else { + // Add new item + const newItemClone = newItem.cloneNode(true); + // Set 'done' class based on checkbox state + const checkbox = newItemClone.querySelector('.todo-checkbox'); + if (checkbox) { + newItemClone.classList.toggle('done', checkbox.checked); + } + fragment.appendChild(newItemClone); + } + }); + + // Replace the entire list content + todoList.innerHTML = ''; + todoList.appendChild(fragment); + + // Animate the last item if specified + if (animateLastItem && todoList.lastElementChild) { + todoList.lastElementChild.classList.add('new-item'); + setTimeout(() => todoList.lastElementChild.classList.remove('new-item'), 500); + } + } else { + todoList.innerHTML = '
  • No todos yet. Add a new one!
  • '; } + + // Reattach event listeners for checkboxes + attachCheckboxListeners(); + }; + + // Function to attach event listeners to checkboxes + const attachCheckboxListeners = () => { + document.querySelectorAll('.todo-checkbox').forEach(checkbox => { + checkbox.addEventListener('change', (event) => { + const listItem = event.target.closest('li'); + if (event.target.checked) { + listItem.classList.add('done'); + } else { + listItem.classList.remove('done'); + } + }); + }); }; // Function to update todo status @@ -41,7 +92,8 @@ document.addEventListener('DOMContentLoaded', () => { body: `task=${encodeURIComponent(task)}&done=false` }); todoInput.value = ''; - renderTodos(); + renderTodos(true); // Animate the last item when adding a new todo + await updateUndoButtonState(); } }); @@ -58,6 +110,7 @@ document.addEventListener('DOMContentLoaded', () => { setTimeout(() => { todoItem.remove(); }, 500); + await updateUndoButtonState(); } }); @@ -70,6 +123,7 @@ document.addEventListener('DOMContentLoaded', () => { todoItem.classList.toggle('done', isDone); await updateTodoStatus(todoId, isDone); + await updateUndoButtonState(); } }); @@ -86,18 +140,96 @@ document.addEventListener('DOMContentLoaded', () => { darkModeToggle.textContent = '☀️'; } - // Initial render - renderTodos(); + // Clear all todos + clearTodosButton.addEventListener('click', async () => { + try { + const response = await fetch('/clear', { method: 'POST' }); + if (response.ok) { + const result = await response.json(); + if (result.status === 'success') { + console.log('Todos cleared successfully'); + todoList.innerHTML = ''; // Clear the list in the DOM + await renderTodos(); // Re-render the todo list from the server + await updateUndoButtonState(); + } else { + console.error('Failed to clear todos'); + } + } else { + console.error('Failed to clear todos'); + } + } catch (error) { + console.error('Error clearing todos:', error); + } + }); - // Add event listener for todo checkboxes - document.querySelectorAll('.todo-checkbox').forEach(checkbox => { - checkbox.addEventListener('change', (event) => { - const listItem = event.target.closest('li'); - if (event.target.checked) { - listItem.classList.add('done'); + // Undo last action + undoButton.addEventListener('click', async () => { + console.log('Undo button clicked'); + try { + const response = await fetch('/undo', { method: 'POST' }); + console.log('Undo response status:', response.status); + if (response.ok) { + const result = await response.json(); + console.log('Undo response:', result); + if (result.status === 'no_action') { + console.log('No action to undo'); + undoButton.disabled = true; + } else if (result.status === 'success') { + console.log('Last action undone successfully'); + console.log('Updated todos:', result.todos); + await renderTodos(false); // Re-render todos without animation + undoButton.disabled = false; // Enable the button after a successful undo + } else { + console.error('Failed to undo last action'); + } } else { - listItem.classList.remove('done'); + console.error('Failed to undo last action, status:', response.status); } - }); + } catch (error) { + console.error('Error undoing last action:', error); + } + }); + + // Add this function to check if there are actions to undo + async function checkUndoAvailability() { + try { + const response = await fetch('/last-action'); + if (response.ok) { + const result = await response.json(); + undoButton.disabled = result.last_action.type === null; + } + } catch (error) { + console.error('Error checking undo availability:', error); + } + } + + // Call this function after each action that modifies the todo list + async function updateUndoButtonState() { + await checkUndoAvailability(); + } + + // Call this function on initial load + document.addEventListener('DOMContentLoaded', async () => { + // ... (rest of the code) + await updateUndoButtonState(); }); + + // Helper function to create a todo list item + function createTodoElement(todo) { + const li = document.createElement('li'); + li.dataset.id = todo.id; + li.innerHTML = ` + + ${todo.task} + + `; + li.classList.toggle('done', todo.done === 'true'); + return li; + } + + // Initial render + renderTodos(false); // Don't animate on initial render + + // Add event listener for todo checkboxes + attachCheckboxListeners(); }); \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index d93a2ab..9180018 100644 --- a/templates/index.html +++ b/templates/index.html @@ -14,10 +14,12 @@

    My TODOs

    + +