Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <title>Todo App</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <style> | |
| :root { | |
| --bg: #ffffff; | |
| --bg-soft: #f7f7f8; | |
| --text: #1f2937; | |
| --muted: #6b7280; | |
| --border: #e5e7eb; | |
| --primary: #2563eb; | |
| --primary-contrast: #ffffff; | |
| --danger: #dc2626; | |
| --shadow: 0 2px 6px rgba(0,0,0,0.06), 0 1px 1px rgba(0,0,0,0.04); | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| :root { | |
| --bg: #0b0d10; | |
| --bg-soft: #141820; | |
| --text: #e5e7eb; | |
| --muted: #9aa4b2; | |
| --border: #1f2937; | |
| --primary: #60a5fa; | |
| --primary-contrast: #0b0d10; | |
| --danger: #f87171; | |
| --shadow: 0 2px 8px rgba(0,0,0,0.35), 0 1px 1px rgba(0,0,0,0.25); | |
| } | |
| } | |
| html[data-theme="light"] { | |
| --bg: #ffffff; | |
| --bg-soft: #f7f7f8; | |
| --text: #1f2937; | |
| --muted: #6b7280; | |
| --border: #e5e7eb; | |
| --primary: #2563eb; | |
| --primary-contrast: #ffffff; | |
| --danger: #dc2626; | |
| --shadow: 0 2px 6px rgba(0,0,0,0.06), 0 1px 1px rgba(0,0,0,0.04); | |
| } | |
| html[data-theme="dark"] { | |
| --bg: #0b0d10; | |
| --bg-soft: #141820; | |
| --text: #e5e7eb; | |
| --muted: #9aa4b2; | |
| --border: #1f2937; | |
| --primary: #60a5fa; | |
| --primary-contrast: #0b0d10; | |
| --danger: #f87171; | |
| --shadow: 0 2px 8px rgba(0,0,0,0.35), 0 1px 1px rgba(0,0,0,0.25); | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { height: 100%; } | |
| body { | |
| margin: 0; | |
| font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Apple Color Emoji, Segoe UI Emoji; | |
| background: linear-gradient(180deg, var(--bg), var(--bg-soft)); | |
| color: var(--text); | |
| -webkit-font-smoothing: antialiased; | |
| -moz-osx-font-smoothing: grayscale; | |
| } | |
| .container { | |
| max-width: 820px; | |
| margin: 40px auto; | |
| padding: 0 16px; | |
| } | |
| header.app-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 12px; | |
| margin-bottom: 16px; | |
| } | |
| .title { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .title h1 { | |
| margin: 0; | |
| font-weight: 800; | |
| letter-spacing: -0.02em; | |
| font-size: 28px; | |
| } | |
| .title .badge { | |
| font-size: 12px; | |
| color: var(--muted); | |
| border: 1px solid var(--border); | |
| padding: 2px 8px; | |
| border-radius: 999px; | |
| background: rgba(127,127,127,0.05); | |
| } | |
| .actions { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| button, .btn { | |
| appearance: none; | |
| border: 1px solid var(--border); | |
| background: #fff0; | |
| color: var(--text); | |
| padding: 8px 12px; | |
| border-radius: 10px; | |
| cursor: pointer; | |
| transition: background 0.15s ease, transform 0.04s ease; | |
| font-size: 14px; | |
| line-height: 1; | |
| } | |
| button:hover, .btn:hover { background: rgba(127,127,127,0.06); } | |
| button:active, .btn:active { transform: translateY(1px); } | |
| .btn-primary { | |
| background: var(--primary); | |
| color: var(--primary-contrast); | |
| border-color: transparent; | |
| } | |
| .btn-primary:hover { filter: brightness(0.95); } | |
| .btn-danger { | |
| border-color: color-mix(in oklch, var(--danger), var(--border) 60%); | |
| color: var(--danger); | |
| } | |
| .btn-ghost { | |
| border-color: transparent; | |
| background: transparent; | |
| } | |
| .icon-btn { | |
| padding: 8px; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 34px; | |
| height: 34px; | |
| } | |
| .card { | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| border-radius: 14px; | |
| box-shadow: var(--shadow); | |
| } | |
| /* Add form */ | |
| #addForm { | |
| display: flex; | |
| gap: 8px; | |
| padding: 12px; | |
| margin-bottom: 14px; | |
| } | |
| #newTodo { | |
| flex: 1 1 auto; | |
| min-width: 160px; | |
| padding: 12px 14px; | |
| border-radius: 10px; | |
| border: 1px solid var(--border); | |
| background: var(--bg-soft); | |
| color: var(--text); | |
| outline: none; | |
| transition: border 0.15s ease, background 0.15s ease; | |
| } | |
| #newTodo::placeholder { color: var(--muted); } | |
| #newTodo:focus { | |
| border-color: var(--primary); | |
| background: color-mix(in oklch, var(--bg-soft), var(--primary) 6%); | |
| } | |
| /* Toolbar */ | |
| .toolbar { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 10px; | |
| padding: 10px 12px; | |
| margin-bottom: 10px; | |
| } | |
| .filters { | |
| display: inline-flex; | |
| gap: 6px; | |
| background: var(--bg-soft); | |
| border: 1px solid var(--border); | |
| padding: 4px; | |
| border-radius: 10px; | |
| } | |
| .filters button { | |
| border: 0; | |
| background: transparent; | |
| padding: 8px 10px; | |
| border-radius: 8px; | |
| color: var(--muted); | |
| font-weight: 600; | |
| } | |
| .filters button.active { | |
| background: var(--bg); | |
| color: var(--text); | |
| border: 1px solid var(--border); | |
| box-shadow: var(--shadow); | |
| } | |
| .stats { | |
| color: var(--muted); | |
| font-size: 13px; | |
| } | |
| /* List */ | |
| #todoList { | |
| list-style: none; | |
| padding: 8px; | |
| margin: 0; | |
| display: grid; | |
| gap: 8px; | |
| } | |
| .todo-item { | |
| display: grid; | |
| grid-template-columns: auto 1fr auto auto auto; | |
| align-items: center; | |
| gap: 10px; | |
| padding: 10px 10px; | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| background: var(--bg); | |
| transition: background 0.2s ease, border 0.2s ease, box-shadow 0.2s ease; | |
| } | |
| .todo-item.drag-over { | |
| border-style: dashed; | |
| border-color: color-mix(in oklch, var(--primary), var(--border) 50%); | |
| box-shadow: 0 0 0 3px color-mix(in oklch, var(--primary), transparent 85%); | |
| } | |
| .todo-item.completed { | |
| opacity: 0.75; | |
| background: color-mix(in oklch, var(--bg), #888 3%); | |
| } | |
| .todo-item .grab { | |
| cursor: grab; | |
| user-select: none; | |
| color: var(--muted); | |
| font-size: 16px; | |
| width: 24px; | |
| text-align: center; | |
| } | |
| .todo-item.dragging { opacity: 0.6; } | |
| .checkbox { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 22px; | |
| height: 22px; | |
| } | |
| .checkbox input[type="checkbox"] { | |
| width: 18px; | |
| height: 18px; | |
| cursor: pointer; | |
| accent-color: var(--primary); | |
| } | |
| .text { | |
| min-width: 0; | |
| word-break: break-word; | |
| white-space: pre-wrap; | |
| } | |
| .text.completed { | |
| text-decoration: line-through; | |
| color: var(--muted); | |
| } | |
| .todo-item .edit-input { | |
| grid-column: 2 / span 1; | |
| width: 100%; | |
| padding: 10px 12px; | |
| border-radius: 10px; | |
| border: 1px solid var(--border); | |
| background: var(--bg-soft); | |
| color: var(--text); | |
| outline: none; | |
| } | |
| .todo-item .edit-input:focus { | |
| border-color: var(--primary); | |
| } | |
| .todo-actions { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| /* Footer */ | |
| .footer { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 8px; | |
| padding: 10px 12px; | |
| border-top: 1px dashed var(--border); | |
| color: var(--muted); | |
| font-size: 12px; | |
| } | |
| .sr-only { | |
| position: absolute ; | |
| width: 1px; height: 1px; | |
| padding: 0; margin: -1px; | |
| overflow: hidden; clip: rect(0,0,0,0); | |
| white-space: nowrap; border: 0; | |
| } | |
| /* Responsive */ | |
| @media (max-width: 520px) { | |
| header.app-header { | |
| flex-direction: column; | |
| align-items: flex-start; | |
| } | |
| .todo-item { | |
| grid-template-columns: auto 1fr auto; | |
| grid-auto-flow: row; | |
| } | |
| .todo-item .grab, | |
| .todo-item .todo-actions { justify-self: end; } | |
| .todo-item .text { grid-column: 2 / span 1; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <header class="app-header"> | |
| <div class="title"> | |
| <h1>Todo</h1> | |
| <span class="badge">Offline • Private</span> | |
| </div> | |
| <div class="actions"> | |
| <button id="themeToggle" class="icon-btn" aria-label="Toggle theme" title="Toggle theme">🌓</button> | |
| <button id="exportBtn" class="btn" title="Export tasks as JSON">Export</button> | |
| <button id="importBtn" class="btn" title="Import tasks from JSON">Import</button> | |
| <button id="clearCompletedBtn" class="btn btn-danger" title="Delete all completed tasks">Clear completed</button> | |
| </div> | |
| </header> | |
| <div class="card"> | |
| <form id="addForm" autocomplete="off"> | |
| <label class="sr-only" for="newTodo">Add a task</label> | |
| <input id="newTodo" type="text" placeholder="What needs to be done?" aria-label="Add a task" required /> | |
| <button type="submit" class="btn btn-primary">Add</button> | |
| </form> | |
| <div class="toolbar"> | |
| <div class="filters" role="group" aria-label="Filter tasks"> | |
| <button class="active" data-filter="all" aria-pressed="true">All</button> | |
| <button data-filter="active" aria-pressed="false">Active</button> | |
| <button data-filter="completed" aria-pressed="false">Completed</button> | |
| </div> | |
| <div class="stats"> | |
| <span id="itemsLeft">0 items left</span> | |
| </div> | |
| </div> | |
| <ul id="todoList" role="list" aria-live="polite" aria-label="Todo list"></ul> | |
| <div class="footer"> | |
| <div>Tip: Drag the ⠿ handle to reorder. Double-click a task to edit.</div> | |
| <div id="lastSaved">Saved</div> | |
| </div> | |
| </div> | |
| </div> | |
| <input type="file" id="importFile" accept="application/json" class="sr-only" /> | |
| <script> | |
| (function () { | |
| 'use strict'; | |
| // State and persistence | |
| const STORAGE_KEY = 'todoApp.v1'; | |
| let tasks = []; | |
| let filter = 'all'; | |
| let draggingId = null; | |
| const $ = (sel, root = document) => root.querySelector(sel); | |
| const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); | |
| // Elements | |
| const listEl = $('#todoList'); | |
| const addForm = $('#addForm'); | |
| const inputEl = $('#newTodo'); | |
| const itemsLeftEl = $('#itemsLeft'); | |
| const filtersEl = $('.filters'); | |
| const exportBtn = $('#exportBtn'); | |
| const importBtn = $('#importBtn'); | |
| const importFileEl = $('#importFile'); | |
| const clearCompletedBtn = $('#clearCompletedBtn'); | |
| const themeToggle = $('#themeToggle'); | |
| const lastSavedEl = $('#lastSaved'); | |
| // Theme handling | |
| function applyTheme(theme) { | |
| if (theme === 'light' || theme === 'dark') { | |
| document.documentElement.setAttribute('data-theme', theme); | |
| } else { | |
| document.documentElement.removeAttribute('data-theme'); | |
| } | |
| } | |
| function getPreferredTheme() { | |
| const saved = localStorage.getItem('todoApp.theme'); | |
| if (saved === 'light' || saved === 'dark') return saved; | |
| return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; | |
| } | |
| function toggleTheme() { | |
| const current = document.documentElement.getAttribute('data-theme'); | |
| const sysDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| // If current is explicit (light/dark), switch; else toggle against system | |
| const next = current === 'light' ? 'dark' : current === 'dark' ? (sysDark ? null : 'light') : (sysDark ? 'light' : 'dark'); | |
| if (next) { | |
| localStorage.setItem('todoApp.theme', next); | |
| } else { | |
| localStorage.removeItem('todoApp.theme'); | |
| } | |
| applyTheme(next || getPreferredTheme()); | |
| } | |
| // Utilities | |
| const uid = () => (crypto?.randomUUID ? crypto.randomUUID() : `id_${Date.now()}_${Math.random().toString(16).slice(2)}`); | |
| function save() { | |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks)); | |
| lastSavedEl.textContent = 'Saved ' + new Date().toLocaleTimeString(); | |
| } | |
| function load() { | |
| try { | |
| const raw = localStorage.getItem(STORAGE_KEY); | |
| if (raw) tasks = JSON.parse(raw); | |
| if (!Array.isArray(tasks)) tasks = []; | |
| } catch (e) { | |
| console.warn('Failed to parse saved tasks:', e); | |
| tasks = []; | |
| } | |
| } | |
| // Rendering | |
| function render() { | |
| listEl.innerHTML = ''; | |
| const frag = document.createDocumentFragment(); | |
| for (const t of tasks) { | |
| const li = document.createElement('li'); | |
| li.className = 'todo-item' + (t.completed ? ' completed' : ''); | |
| li.dataset.id = t.id; | |
| li.draggable = true; | |
| // Drag handle | |
| const handle = document.createElement('div'); | |
| handle.className = 'grab'; | |
| handle.title = 'Drag to reorder'; | |
| handle.setAttribute('aria-hidden', 'true'); | |
| handle.textContent = '⠿'; | |
| // Checkbox | |
| const checkboxWrap = document.createElement('label'); | |
| checkboxWrap.className = 'checkbox'; | |
| checkboxWrap.title = 'Mark as ' + (t.completed ? 'incomplete' : 'complete'); | |
| const checkbox = document.createElement('input'); | |
| checkbox.type = 'checkbox'; | |
| checkbox.checked = t.completed; | |
| checkbox.setAttribute('aria-label', 'Completed'); | |
| checkboxWrap.appendChild(checkbox); | |
| // Text | |
| const span = document.createElement('span'); | |
| span.className = 'text' + (t.completed ? ' completed' : ''); | |
| span.textContent = t.text; | |
| // Edit input (hidden until editing) | |
| const editInput = document.createElement('input'); | |
| editInput.type = 'text'; | |
| editInput.className = 'edit-input sr-only'; | |
| editInput.value = t.text; | |
| // Actions | |
| const actions = document.createElement('div'); | |
| actions.className = 'todo-actions'; | |
| const editBtn = document.createElement('button'); | |
| editBtn.className = 'icon-btn'; | |
| editBtn.title = 'Edit'; | |
| editBtn.setAttribute('aria-label', 'Edit task'); | |
| editBtn.textContent = '✏️'; | |
| const delBtn = document.createElement('button'); | |
| delBtn.className = 'icon-btn'; | |
| delBtn.title = 'Delete'; | |
| delBtn.setAttribute('aria-label', 'Delete task'); | |
| delBtn.textContent = '🗑️'; | |
| actions.append(editBtn, delBtn); | |
| li.append(handle, checkboxWrap, span, editInput, actions); | |
| frag.appendChild(li); | |
| } | |
| listEl.appendChild(frag); | |
| updateStats(); | |
| } | |
| function updateStats() { | |
| const left = tasks.filter(t => !t.completed).length; | |
| itemsLeftEl.textContent = `${left} item${left !== 1 ? 's' : ''} left`; | |
| } | |
| // Actions | |
| function addTask(text) { | |
| const trimmed = text.trim(); | |
| if (!trimmed) return; | |
| const newTask = { id: uid(), text: trimmed, completed: false, createdAt: Date.now() }; | |
| tasks.push(newTask); | |
| save(); | |
| render(); | |
| inputEl.value = ''; | |
| inputEl.focus(); | |
| } | |
| function toggleTask(id, completed) { | |
| const t = tasks.find(t => t.id === id); | |
| if (!t) return; | |
| t.completed = !!completed; | |
| save(); | |
| render(); | |
| } | |
| function deleteTask(id) { | |
| const i = tasks.findIndex(t => t.id === id); | |
| if (i !== -1) { | |
| tasks.splice(i, 1); | |
| save(); | |
| render(); | |
| } | |
| } | |
| function startEdit(id) { | |
| const li = listEl.querySelector(`[data-id="${CSS.escape(id)}"]`); | |
| if (!li) return; | |
| li.classList.add('editing'); | |
| const input = $('.edit-input', li); | |
| const text = $('.text', li); | |
| text.classList.add('sr-only'); | |
| input.classList.remove('sr-only'); | |
| input.value = text.textContent; | |
| input.setSelectionRange(0, input.value.length); | |
| input.focus(); | |
| } | |
| function commitEdit(id, newText) { | |
| const t = tasks.find(t => t.id === id); | |
| if (!t) return; | |
| const trimmed = newText.trim(); | |
| if (!trimmed) { | |
| // If emptied, delete | |
| deleteTask(id); | |
| return; | |
| } | |
| if (t.text !== trimmed) { | |
| t.text = trimmed; | |
| save(); | |
| } | |
| render(); | |
| } | |
| function cancelEdit(id) { | |
| render(); // restores view | |
| } | |
| function clearCompleted() { | |
| const before = tasks.length; | |
| tasks = tasks.filter(t => !t.completed); | |
| if (tasks.length !== before) { | |
| save(); | |
| render(); | |
| } | |
| } | |
| function setFilter(newFilter) { | |
| filter = newFilter; | |
| // Update buttons UI | |
| $$('.filters button').forEach(btn => { | |
| const active = btn.dataset.filter === filter; | |
| btn.classList.toggle('active', active); | |
| btn.setAttribute('aria-pressed', String(active)); | |
| }); | |
| applyFilterStyles(); | |
| } | |
| function applyFilterStyles() { | |
| $$('.todo-item', listEl).forEach(li => { | |
| const t = tasks.find(tt => tt.id === li.dataset.id); | |
| if (!t) return; | |
| const show = | |
| filter === 'all' || | |
| (filter === 'active' && !t.completed) || | |
| (filter === 'completed' && t.completed); | |
| li.style.display = show ? '' : 'none'; | |
| }); | |
| } | |
| function exportTasks() { | |
| const data = { | |
| version: 1, | |
| exportedAt: new Date().toISOString(), | |
| tasks | |
| }; | |
| const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `todos-${new Date().toISOString().slice(0,19).replace(/[:T]/g,'-')}.json`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| a.remove(); | |
| URL.revokeObjectURL(url); | |
| } | |
| function importTasksFromFile(file) { | |
| const reader = new FileReader(); | |
| reader.onload = () => { | |
| try { | |
| const obj = JSON.parse(reader.result); | |
| if (!obj || typeof obj !== 'object' || !Array.isArray(obj.tasks)) { | |
| alert('Invalid file format.'); | |
| return; | |
| } | |
| // Sanitize tasks | |
| const imported = obj.tasks.map(t => ({ | |
| id: String(t.id || uid()), | |
| text: String(t.text || '').trim(), | |
| completed: Boolean(t.completed), | |
| createdAt: Number.isFinite(t.createdAt) ? t.createdAt : Date.now() | |
| })).filter(t => t.text); | |
| if (!imported.length) { | |
| alert('No valid tasks found in file.'); | |
| return; | |
| } | |
| tasks = imported; | |
| save(); | |
| render(); | |
| } catch (e) { | |
| console.error(e); | |
| alert('Failed to import: ' + e.message); | |
| } | |
| }; | |
| reader.readAsText(file); | |
| } | |
| // Drag & Drop reordering | |
| function getDragAfterElement(container, y) { | |
| const els = [...container.querySelectorAll('.todo-item:not(.dragging)')]; | |
| return els.reduce((closest, child) => { | |
| const box = child.getBoundingClientRect(); | |
| const offset = y - box.top - box.height / 2; | |
| if (offset < 0 && offset > closest.offset) { | |
| return { offset, element: child }; | |
| } else { | |
| return closest; | |
| } | |
| }, { offset: Number.NEGATIVE_INFINITY, element: null }).element; | |
| } | |
| function moveTask(dragId, hoverId) { | |
| if (!dragId || !hoverId || dragId === hoverId) return; | |
| const fromIndex = tasks.findIndex(t => t.id === dragId); | |
| const toIndex = tasks.findIndex(t => t.id === hoverId); | |
| if (fromIndex === -1 || toIndex === -1) return; | |
| const [moved] = tasks.splice(fromIndex, 1); | |
| tasks.splice(toIndex, 0, moved); | |
| save(); | |
| render(); | |
| // Keep filter applied after rerender | |
| applyFilterStyles(); | |
| } | |
| // Events | |
| addForm.addEventListener('submit', (e) => { | |
| e.preventDefault(); | |
| addTask(inputEl.value); | |
| }); | |
| listEl.addEventListener('change', (e) => { | |
| const checkbox = e.target.closest('input[type="checkbox"]'); | |
| if (!checkbox) return; | |
| const li = e.target.closest('.todo-item'); | |
| if (!li) return; | |
| toggleTask(li.dataset.id, checkbox.checked); | |
| }); | |
| listEl.addEventListener('click', (e) => { | |
| const delBtn = e.target.closest('button[title="Delete"]'); | |
| if (delBtn) { | |
| const li = delBtn.closest('.todo-item'); | |
| if (li) deleteTask(li.dataset.id); | |
| return; | |
| } | |
| const editBtn = e.target.closest('button[title="Edit"]'); | |
| if (editBtn) { | |
| const li = editBtn.closest('.todo-item'); | |
| if (li) startEdit(li.dataset.id); | |
| } | |
| }); | |
| listEl.addEventListener('dblclick', (e) => { | |
| const span = e.target.closest('.text'); | |
| if (!span) return; | |
| const li = span.closest('.todo-item'); | |
| if (!li) return; | |
| startEdit(li.dataset.id); | |
| }); | |
| listEl.addEventListener('keydown', (e) => { | |
| const input = e.target.closest('.edit-input'); | |
| if (!input) return; | |
| const li = input.closest('.todo-item'); | |
| if (!li) return; | |
| if (e.key === 'Enter') { | |
| e.preventDefault(); | |
| commitEdit(li.dataset.id, input.value); | |
| } else if (e.key === 'Escape') { | |
| e.preventDefault(); | |
| cancelEdit(li.dataset.id); | |
| } | |
| }); | |
| listEl.addEventListener('focusout', (e) => { | |
| const input = e.target.closest('.edit-input'); | |
| if (!input) return; | |
| const li = input.closest('.todo-item'); | |
| if (!li) return; | |
| // Commit on blur if still in DOM and not hidden | |
| if (input === document.activeElement) return; | |
| commitEdit(li.dataset.id, input.value); | |
| }); | |
| filtersEl.addEventListener('click', (e) => { | |
| const btn = e.target.closest('button[data-filter]'); | |
| if (!btn) return; | |
| setFilter(btn.dataset.filter); | |
| }); | |
| exportBtn.addEventListener('click', exportTasks); | |
| importBtn.addEventListener('click', () => importFileEl.click()); | |
| importFileEl.addEventListener('change', (e) => { | |
| const file = e.target.files?.[0]; | |
| if (file) importTasksFromFile(file); | |
| importFileEl.value = ''; | |
| }); | |
| clearCompletedBtn.addEventListener('click', () => { | |
| const completedCount = tasks.filter(t => t.completed).length; | |
| if (!completedCount) { | |
| alert('No completed tasks to clear.'); | |
| return; | |
| } | |
| if (confirm(`Delete ${completedCount} completed task${completedCount === 1 ? '' : 's'}?`)) { | |
| clearCompleted(); | |
| } | |
| }); | |
| themeToggle.addEventListener('click', toggleTheme); | |
| // Drag events on the list container | |
| listEl.addEventListener('dragstart', (e) => { | |
| const li = e.target.closest('.todo-item'); | |
| if (!li) return; | |
| draggingId = li.dataset.id; | |
| li.classList.add('dragging'); | |
| e.dataTransfer.effectAllowed = 'move'; | |
| e.dataTransfer.setData('text/plain', draggingId); | |
| }); | |
| listEl.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| const after = getDragAfterElement(listEl, e.clientY); | |
| $$('.todo-item', listEl).forEach(li => li.classList.remove('drag-over')); | |
| if (after) { | |
| after.classList.add('drag-over'); | |
| } else { | |
| // optional visual | |
| } | |
| e.dataTransfer.dropEffect = 'move'; | |
| }); | |
| listEl.addEventListener('dragleave', (e) => { | |
| const li = e.target.closest('.todo-item'); | |
| if (li) li.classList.remove('drag-over'); | |
| }); | |
| listEl.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| $$('.todo-item', listEl).forEach(li => li.classList.remove('drag-over', 'dragging')); | |
| const after = getDragAfterElement(listEl, e.clientY); | |
| const dragId = draggingId; | |
| draggingId = null; | |
| if (!dragId) return; | |
| const hoverId = after?.dataset?.id; | |
| if (hoverId) { | |
| moveTask(dragId, hoverId); | |
| } | |
| }); | |
| listEl.addEventListener('dragend', () => { | |
| $$('.todo-item', listEl).forEach(li => li.classList.remove('drag-over', 'dragging')); | |
| draggingId = null; | |
| }); | |
| // Keyboard shortcuts | |
| document.addEventListener('keydown', (e) => { | |
| // Ctrl/Cmd + Enter to add | |
| if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { | |
| addTask(inputEl.value); | |
| } | |
| // Ctrl/Cmd + L to focus search | |
| if ((e.ctrlKey || e.metaKey) && (e.key.toLowerCase() === 'l')) { | |
| e.preventDefault(); | |
| inputEl.focus(); | |
| inputEl.select(); | |
| } | |
| }); | |
| // Init | |
| function init() { | |
| load(); | |
| applyTheme(getPreferredTheme()); | |
| render(); | |
| setFilter('all'); | |
| } | |
| init(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |