<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Kanban Board</title> <link rel="manifest" href="/manifest.json"> <meta name="theme-color" content="#007bff"> <style> :root { --bg: #ffffff; --surface: #f8f9fa; --card: #ffffff; --text: #212529; --text-secondary: #6c757d; --border: #dee2e6; --primary: #007bff; --primary-hover: #0056b3; --success: #28a745; --warning: #ffc107; --danger: #dc3545; --shadow: 0 2px 4px rgba(0,0,0,0.1); --shadow-lg: 0 10px 20px rgba(0,0,0,0.2); --radius: 8px; --transition: all 0.2s ease; } @media (prefers-color-scheme: dark) { :root { --bg: #121212; --surface: #1e1e1e; --card: #2d2d2d; --text: #e0e0e0; --text-secondary: #b0b0b0; --border: #404040; --primary: #4dabf7; --primary-hover: #339af0; --success: #4caf50; --warning: #ffeb3b; --danger: #f44336; --shadow: 0 2px 4px rgba(0,0,0,0.5); --shadow-lg: 0 10px 20px rgba(0,0,0,0.5); } } * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--surface); color: var(--text); line-height: 1.5; overflow-x: hidden; transition: var(--transition); } #topbar { display: flex; align-items: center; padding: 1rem; background: var(--card); border-bottom: 1px solid var(--border); gap: 1rem; position: sticky; top: 0; z-index: 100; } #search { flex: 1; padding: 0.75rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); } #search::placeholder { color: var(--text-secondary); } #refresh, #export { padding: 0.75rem 1rem; border: 1px solid var(--border); background: var(--bg); color: var(--text); border-radius: var(--radius); cursor: pointer; transition: var(--transition); } #refresh:hover, #export:hover { background: var(--primary); color: white; border-color: var(--primary); } #main { display: flex; height: calc(100vh - 70px); overflow: hidden; } #sidebar { width: 280px; background: var(--card); border-right: 1px solid var(--border); padding: 2rem 1rem; transition: transform 0.3s ease; position: relative; z-index: 50; } #sidebar.hidden { transform: translateX(-100%); } #sidebar-toggle { position: absolute; right: -40px; top: 1rem; background: var(--primary); color: white; border: none; width: 40px; height: 40px; border-radius: 0 20px 20px 0; cursor: pointer; display: flex; align-items: center; justify-content: center; } #dark-toggle { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem; border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; width: 100%; background: var(--bg); transition: var(--transition); } #dark-toggle:hover { background: var(--primary); color: white; border-color: var(--primary); } #content { flex: 1; padding: 2rem; overflow-y: auto; display: flex; gap: 2rem; } @media (max-width: 768px) { #sidebar { position: fixed; left: 0; top: 70px; height: calc(100vh - 70px); transform: translateX(-100%); } #sidebar.open { transform: translateX(0); } #sidebar-toggle { display: block; } #content { padding: 1rem; } #topbar { padding: 0.75rem; } } .column { flex: 1; min-width: 300px; background: var(--bg); border-radius: var(--radius); padding: 1.5rem; box-shadow: var(--shadow); } .column h2 { margin-bottom: 1rem; color: var(--text); font-size: 1.25rem; } .tasks { min-height: 200px; } .task { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; margin-bottom: 1rem; cursor: move; box-shadow: var(--shadow); transition: var(--transition); position: relative; } .task:hover { box-shadow: var(--shadow-lg); transform: translateY(-2px); } .task.dragging { opacity: 0.5; transform: rotate(5deg); } .task-header { font-weight: 600; margin-bottom: 0.5rem; } .task-meta { font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 0.5rem; } .task-tags { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 0.5rem; } .tag { background: var(--primary); color: white; padding: 0.25rem 0.5rem; border-radius: 12px; font-size: 0.75rem; } .task-desc { margin-bottom: 0.5rem; white-space: pre-wrap; } .subtask { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; margin-left: 1rem; } .subtask input[type="checkbox"] { margin: 0; } .due { font-size: 0.875rem; } .due.past { color: var(--danger); } .due.soon { color: var(--warning); } .actions { display: flex; gap: 0.5rem; position: absolute; top: 0.5rem; right: 0.5rem; opacity: 0; transition: var(--transition); } .task:hover .actions { opacity: 1; } .btn { padding: 0.25rem 0.5rem; border: 1px solid var(--border); background: var(--bg); color: var(--text); border-radius: 4px; cursor: pointer; font-size: 0.75rem; transition: var(--transition); } .btn:hover { background: var(--primary); color: white; border-color: var(--primary); } #add-task { position: fixed; bottom: 2rem; right: 2rem; width: 56px; height: 56px; background: var(--primary); color: white; border: none; border-radius: 50%; font-size: 1.5rem; cursor: pointer; box-shadow: var(--shadow-lg); transition: var(--transition); z-index: 200; } #add-task:hover { transform: scale(1.1); } @media (max-width: 768px) { #add-task { bottom: 1rem; right: 1rem; } } #modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 300; align-items: center; justify-content: center; } #modal-content { background: var(--card); padding: 2rem; border-radius: var(--radius); max-width: 500px; width: 90%; max-height: 90vh; overflow-y: auto; box-shadow: var(--shadow-lg); } #modal input, #modal textarea { width: 100%; padding: 0.75rem; border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 1rem; background: var(--bg); color: var(--text); } #modal textarea { resize: vertical; min-height: 100px; } #tags { font-size: 0.875rem; } .modal-actions { display: flex; gap: 1rem; justify-content: flex-end; } .modal-actions .btn { padding: 0.75rem 1.5rem; font-size: 1rem; flex: 1; } #shortcuts-tooltip { position: absolute; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; max-width: 300px; font-size: 0.875rem; box-shadow: var(--shadow-lg); display: none; z-index: 400; } kbd { background: var(--text-secondary); color: var(--bg); padding: 0.125rem 0.25rem; border-radius: 3px; font-size: 0.75rem; font-family: monospace; } .empty { text-align: center; color: var(--text-secondary); padding: 2rem; font-style: italic; } @media (hover: none) { .task .actions { opacity: 1; } } </style> </head> <body> <div id="topbar"> <input id="search" placeholder="Search tasks..."> <button id="refresh" title="Refresh (R)">↻</button> <button id="export" title="Export (E)">Export</button> </div> <div id="main"> <div id="sidebar" class="hidden"> <button id="sidebar-toggle" title="Close sidebar">◀</button> <div style="margin-top: 3rem;"> <div id="dark-toggle" title="Toggle dark mode (D)"> <span id="dark-icon">☀</span> Dark Mode </div> <div style="margin-top: 1rem; padding: 1rem; background: var(--bg); border-radius: var(--radius); border: 1px solid var(--border);"> <h3>Keyboard Shortcuts <kbd>?</kbd></h3> <p><kbd>N</kbd> New task</p> <p><kbd>R</kbd> Refresh</p> <p><kbd>E</kbd> Export</p> <p><kbd>D</kbd> Dark mode</p> <p><kbd>?</kbd> Show this</p> </div> </div> </div> <div id="content"> <div class="column" data-status="todo"> <h2>To Do</h2> <div class="tasks"></div> </div> <div class="column" data-status="inprogress"> <h2>In Progress</h2> <div class="tasks"></div> </div> <div class="column" data-status="done"> <h2>Done</h2> <div class="tasks"></div> </div> </div> </div> <button id="add-task" title="New task (N)">+</button> <div id="modal"> <div id="modal-content"> <h2 id="modal-title">New Task</h2> <input id="task-title" placeholder="Task title"> <textarea id="task-desc" placeholder="Description (use - [ ] for subtasks)"></textarea> <input id="task-due" type="date"> <input id="tags" placeholder="Tags (comma-separated)"> <div class="modal-actions"> <button id="modal-cancel" class="btn">Cancel</button> <button id="modal-save" class="btn">Save</button> </div> </div> </div> <div id="shortcuts-tooltip"> <h4>Keyboard Shortcuts</h4> <p><kbd>N</kbd> New task</p> <p><kbd>R</kbd> Refresh</p> <p><kbd>E</kbd> Export</p> <p><kbd>D</kbd> Dark mode</p> <p><kbd>?</kbd> Toggle help</p> </div> <script> let tasks = []; let etag = null; let cacheTime = 0; const CACHE_DURATION = 5 * 60 * 1000; // 5min let editingId = null; let deferredPrompt; let sidebarOpen = false; // Dark mode const darkModeKey = 'kanban-dark-mode'; const isDark = localStorage.getItem(darkModeKey) === 'true' || (!localStorage.getItem(darkModeKey) && window.matchMedia('(prefers-color-scheme: dark)').matches); if (isDark) document.body.classList.add('dark'); document.documentElement.style.setProperty('--bg-dark', isDark ? '#121212' : '#ffffff'); // etc if needed function toggleDark() { const isDarkNow = document.body.classList.toggle('dark'); localStorage.setItem(darkModeKey, isDarkNow); document.getElementById('dark-icon').textContent = isDarkNow ? '🌙' : '☀'; } // PWA window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredPrompt = e; showInstallPrompt(); }); function showInstallPrompt() { if (deferredPrompt) { deferredPrompt.prompt(); deferredPrompt.userChoice.then(() => deferredPrompt = null); } } // Relative time function relativeTime(ts) { const now = Date.now(); const diff = now - ts; const mins = Math.floor(diff / 60000); const hours = Math.floor(diff / 3600000); const days = Math.floor(diff / 86400000); if (mins < 1) return 'just now'; if (mins < 60) return `${mins}m ago`; if (hours < 24) return `${hours}h ago`; if (days < 7) return `${days}d ago`; return new Date(ts).toLocaleDateString(); } // Load tasks async function loadTasks(force = false) { const now = Date.now(); if (!force && now - cacheTime < CACHE_DURATION && tasks.length) return; try { const headers = etag ? { 'If-None-Match': etag } : {}; const res = await fetch('/tasks', { headers }); if (res.status === 304) return; if (res.ok) { tasks = await res.json(); etag = res.headers.get('ETag'); cacheTime = now; render(); } } catch (e) { console.error('Load failed:', e); } } // Save task async function saveTask(task) { const method = editingId ? 'PUT' : 'POST'; const url = editingId ? `/tasks/${editingId}` : '/tasks'; const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(task) }); if (res.ok) { loadTasks(true); closeModal(); } } // Parse subtasks from desc function parseSubtasks(desc) { const lines = desc.split('\n'); return lines.map(line => { const match = line.match(/^\s*-?\s*\[\s*(\s|x)\s*\]\s*(.*)/i); if (match) { return { title: match[2].trim(), done: match[1].toLowerCase() === 'x' }; } return null; }).filter(Boolean); } // Render subtasks function renderSubtasks(subtasks) { return subtasks.map(st => `<div class="subtask"><input type="checkbox" ${st.done ? 'checked' : ''} onchange="toggleSubtask(this)"> ${st.title}</div>` ).join(''); } window.toggleSubtask = function(cb) { // Client-side only, sync on save const taskEl = cb.closest('.task'); const task = tasks.find(t => t.id == taskEl.dataset.id); const idx = Array.from(taskEl.querySelectorAll('.subtask input')).indexOf(cb); if (task.subtasks[idx]) task.subtasks[idx].done = cb.checked; // Debounce save? }; // Render function render(filter = '') { const lowerFilter = filter.toLowerCase(); const filtered = tasks.filter(t => t.title.toLowerCase().includes(lowerFilter) || t.desc.toLowerCase().includes(lowerFilter) || t.tags.some(tag => tag.toLowerCase().includes(lowerFilter)) ); document.querySelectorAll('.tasks').forEach(tasksEl => tasksEl.innerHTML = ''); ['todo', 'inprogress', 'done'].forEach(status => { const col = document.querySelector(`[data-status="${status}"] .tasks`); const colTasks = filtered.filter(t => t.status === status); if (colTasks.length) { colTasks.forEach(task => { const dueClass = task.due ? (new Date(task.due) < new Date() ? 'past' : (new Date(task.due) < new Date(Date.now() + 24*60*60*1000) ? 'soon' : '')) : ''; const tagsHtml = task.tags ? task.tags.map(tag => `<span class="tag">${tag}</span>`).join('') : ''; const subtasksHtml = task.subtasks ? renderSubtasks(task.subtasks) : ''; col.innerHTML += ` <div class="task" data-id="${task.id}" draggable="true" ondragstart="dragStart(this)" ondragover="dragOver(event)" ondrop="drop(event, this)"> <div class="task-header">${task.title}</div> <div class="task-meta"> ${task.subtasks ? `(${task.subtasks.filter(s => !s.done).length}/${task.subtasks.length})` : ''} • ${relativeTime(task.updated)} ${task.created !== task.updated ? `| ${relativeTime(task.created)}` : ''} </div> ${tagsHtml ? `<div class="task-tags">${tagsHtml}</div>` : ''} ${task.due ? `<div class="due ${dueClass}">Due: ${new Date(task.due).toLocaleDateString()}</div>` : ''} <div class="task-desc">${task.desc || ''}${subtasksHtml}</div> <div class="actions"> <button class="btn" onclick="editTask(${task.id})">Edit</button> <button class="btn" style="background:var(--danger);color:white;border-color:var(--danger);" onclick="deleteTask(${task.id})">Delete</button> </div> </div>`; }); } else { col.innerHTML = '<div class="empty">No tasks</div>'; } }); } // Drag & drop let draggedTask; function dragStart(el) { draggedTask = el; el.classList.add('dragging'); } function dragOver(e) { e.preventDefault(); } function drop(e, el) { e.preventDefault(); const status = el.closest('.column').dataset.status; const task = tasks.find(t => t.id == draggedTask.dataset.id); if (task.status !== status) { task.status = status; saveTask(task); } draggedTask.classList.remove('dragging'); draggedTask = null; } // Modal function openModal(task = null) { editingId = task ? task.id : null; document.getElementById('modal-title').textContent = editingId ? 'Edit Task' : 'New Task'; document.getElementById('task-title').value = task?.title || ''; document.getElementById('task-desc').value = task?.desc || ''; document.getElementById('task-due').value = task?.due || ''; document.getElementById('tags').value = task?.tags?.join(', ') || ''; document.getElementById('modal').style.display = 'flex'; } function closeModal() { document.getElementById('modal').style.display = 'none'; editingId = null; } document.getElementById('modal-save').onclick = () => { const subtasks = parseSubtasks(document.getElementById('task-desc').value); const task = { title: document.getElementById('task-title').value, desc: document.getElementById('task-desc').value.replace(/^\s*-?\s*\[ \s*x \s*\]\s*/gim, '[x] ').replace(/^\s*-?\s*\[ \s* \s*\]\s*/gim, '[ ] '), // Normalize due: document.getElementById('task-due').value || undefined, tags: document.getElementById('tags').value.split(',').map(t => t.trim()).filter(Boolean), subtasks, status: editingId ? tasks.find(t => t.id === editingId).status : 'todo' }; if (editingId) task.id = editingId; saveTask(task); }; // Delete window.deleteTask = async (id) => { await fetch(`/tasks/${id}`, { method: 'DELETE' }); loadTasks(true); }; // Edit window.editTask = (id) => { const task = tasks.find(t => t.id === id); openModal(task); }; // Export document.getElementById('export').onclick = () => { const json = JSON.stringify(tasks, null, 2); const csv = 'ID,Title,Status,Created,Updated,Due,Tags\n' + tasks.map(t => `${t.id},"${t.title.replace(/"/g,'""')}",${t.status},${new Date(t.created).toISOString()},${new Date(t.updated).toISOString()},${t.due || ''},"${t.tags?.join(';') || ''}"` ).join('\n'); const blob = new Blob([`JSON:\n${json}\n\nCSV:\n${csv}`], {type: 'text/plain'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `kanban-${new Date().toISOString().slice(0,10)}.txt`; a.click(); }; // Events document.getElementById('refresh').onclick = () => loadTasks(true); document.getElementById('add-task').onclick = () => openModal(); document.getElementById('modal-cancel').onclick = closeModal; document.getElementById('search').oninput = (e) => render(e.target.value); document.getElementById('dark-toggle').onclick = toggleDark; // Sidebar mobile const sidebar = document.getElementById('sidebar'); const sidebarToggle = document.getElementById('sidebar-toggle'); sidebarToggle.onclick = () => { sidebar.classList.toggle('open'); sidebarOpen = sidebar.classList.contains('open'); }; let touchStartX = 0; document.addEventListener('touchstart', e => { if (window.innerWidth <= 768 && !sidebarOpen) touchStartX = e.touches[0].clientX; }); document.addEventListener('touchmove', e => { if (window.innerWidth <= 768 && !sidebarOpen && touchStartX < 50 && e.touches[0].clientX - touchStartX > 100) { sidebar.classList.add('open'); sidebarOpen = true; } }); // Keyboard document.addEventListener('keydown', e => { if (e.target.matches('input, textarea')) return; switch(e.key.toLowerCase()) { case 'n': openModal(); break; case 'r': loadTasks(true); break; case 'e': document.getElementById('export').click(); break; case 'd': toggleDark(); break; case '?': const tooltip = document.getElementById('shortcuts-tooltip'); tooltip.style.display = tooltip.style.display === 'block' ? 'none' : 'block'; e.preventDefault(); break; } }); // Hover kbd for tooltip document.querySelectorAll('kbd').forEach(kbd => { kbd.onmouseenter = () => { const tooltip = document.getElementById('shortcuts-tooltip'); tooltip.style.display = 'block'; tooltip.style.left = kbd.offsetLeft + kbd.offsetWidth + 10 + 'px'; tooltip.style.top = kbd.offsetTop + 'px'; }; kbd.onmouseleave = () => document.getElementById('shortcuts-tooltip').style.display = 'none'; }); // Init if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js'); } loadTasks(); setInterval(() => loadTasks(false), 300000); // 5min poll as fallback </script> </body> </html>