<!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>