Kanban
Qwen 3.6 27b MTP · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 50,166 bytes · SHA-256 d07c3cb46693
<!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>
<style>
/* ============================================================
CONSTANTS & RESET
============================================================ */
:root {
--bg: #f4f5f7;
--column-bg: #ebecf0;
--card-bg: #ffffff;
--text-primary: #172b4d;
--text-secondary: #5e6c84;
--accent: #0079bf;
--accent-hover: #026aa7;
--danger: #eb5a46;
--success: #61bd4f;
--border-radius: 8px;
--shadow-card: 0 1px 2px rgba(0,0,0,0.1);
--shadow-card-hover: 0 4px 8px rgba(0,0,0,0.12);
--shadow-drag: 0 8px 24px rgba(0,0,0,0.2);
--transition-fast: 150ms ease;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-sans);
background: var(--bg);
color: var(--text-primary);
overflow: hidden;
height: 100vh;
}
/* ============================================================
HEADER BAR
============================================================ */
.board-header {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 24px;
background: #fff;
border-bottom: 1px solid #ddd;
position: sticky;
top: 0;
z-index: 100;
height: 56px;
}
.board-header h1 {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
white-space: nowrap;
}
.header-controls {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.search-box {
padding: 6px 12px;
border: 2px solid #ddd;
border-radius: var(--border-radius);
font-size: 14px;
width: 200px;
outline: none;
transition: border-color var(--transition-fast);
}
.search-box:focus {
border-color: var(--accent);
}
.filter-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 24px;
background: #fff;
border-bottom: 1px solid #eee;
min-height: 40px;
}
.filter-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
border: none;
transition: opacity var(--transition-fast);
}
.filter-chip:hover { opacity: 0.8; }
.filter-chip.active { outline: 2px solid var(--text-primary); outline-offset: 1px; }
.clear-filters-btn {
font-size: 12px;
color: var(--danger);
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.clear-filters-btn:hover { background: #fee; }
/* ============================================================
BOARD CONTAINER
============================================================ */
.board-container {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 20px 24px;
overflow-x: auto;
overflow-y: hidden;
height: calc(100vh - 96px);
}
.board-container::-webkit-scrollbar { height: 10px; }
.board-container::-webkit-scrollbar-track { background: transparent; }
.board-container::-webkit-scrollbar-thumb { background: #ccc; border-radius: 5px; }
.board-container::-webkit-scrollbar-thumb:hover { background: #aaa; }
/* ============================================================
COLUMNS
============================================================ */
.column {
min-width: 280px;
max-width: 320px;
width: 300px;
flex-shrink: 0;
background: var(--column-bg);
border-radius: var(--border-radius);
display: flex;
flex-direction: column;
max-height: calc(100vh - 140px);
transition: box-shadow var(--transition-fast);
}
.column.drag-over-column {
background: #d6d8db;
}
.column.wip-exceeded {
outline: 3px solid var(--danger);
outline-offset: -2px;
}
.column-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
cursor: grab;
user-select: none;
}
.column-header:active { cursor: grabbing; }
.column-title-group {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.column-title {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
border: none;
background: transparent;
}
.column-title:hover { background: rgba(0,0,0,0.06); }
.card-count {
font-size: 12px;
color: var(--text-secondary);
background: rgba(0,0,0,0.08);
padding: 1px 7px;
border-radius: 10px;
flex-shrink: 0;
}
.column-menu-btn {
background: none;
border: none;
cursor: pointer;
font-size: 18px;
color: var(--text-secondary);
padding: 4px 6px;
border-radius: 4px;
line-height: 1;
}
.column-menu-btn:hover { background: rgba(0,0,0,0.1); }
/* Column dropdown menu */
.column-dropdown {
position: absolute;
top: 100%;
right: 0;
background: #fff;
border-radius: var(--border-radius);
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
padding: 8px 0;
z-index: 200;
min-width: 180px;
}
.column-dropdown button {
display: block;
width: 100%;
text-align: left;
padding: 8px 16px;
border: none;
background: none;
font-size: 14px;
cursor: pointer;
color: var(--text-primary);
}
.column-dropdown button:hover { background: #f0f0f0; }
.column-dropdown button.danger { color: var(--danger); }
/* WIP limit input in dropdown */
.wip-input-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
}
.wip-input-row label { font-size: 13px; color: var(--text-secondary); }
.wip-input-row input {
width: 50px;
padding: 4px 6px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
}
/* ============================================================
CARD LIST
============================================================ */
.card-list {
flex: 1;
overflow-y: auto;
padding: 4px 8px;
min-height: 20px;
}
.card-list::-webkit-scrollbar { width: 6px; }
.card-list::-webkit-scrollbar-track { background: transparent; }
.card-list::-webkit-scrollbar-thumb { background: #ccc; border-radius: 3px; }
/* Empty column drop zone */
.card-list.empty-column::before {
content: 'Drop cards here';
display: flex;
align-items: center;
justify-content: center;
min-height: 60px;
border: 2px dashed #bbb;
border-radius: var(--border-radius);
color: var(--text-secondary);
font-size: 13px;
margin: 4px 0;
}
/* ============================================================
CARDS
============================================================ */
.card {
background: var(--card-bg);
border-radius: var(--border-radius);
padding: 10px 12px;
margin-bottom: 8px;
box-shadow: var(--shadow-card);
cursor: grab;
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
position: relative;
}
.card:hover {
box-shadow: var(--shadow-card-hover);
transform: translateY(-1px);
}
.card.dragging {
opacity: 0.3;
pointer-events: none;
}
.card.hidden-by-filter {
display: none;
}
.card-labels {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.label-chip {
height: 8px;
width: 40px;
border-radius: 4px;
display: inline-block;
}
.card-title {
font-size: 14px;
line-height: 1.4;
color: var(--text-primary);
word-break: break-word;
}
.card-description {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
line-height: 1.3;
}
.card-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
font-size: 12px;
color: var(--text-secondary);
}
.due-date {
display: inline-flex;
align-items: center;
gap: 3px;
}
.due-date.overdue { color: var(--danger); font-weight: 600; }
.card-actions {
position: absolute;
top: 8px;
right: 8px;
display: none;
gap: 2px;
}
.card:hover .card-actions { display: flex; }
.card-action-btn {
background: rgba(0,0,0,0.06);
border: none;
cursor: pointer;
font-size: 14px;
padding: 2px 5px;
border-radius: 3px;
color: var(--text-secondary);
}
.card-action-btn:hover { background: rgba(0,0,0,0.12); }
/* ============================================================
ADD CARD BUTTON & FORM
============================================================ */
.add-card-row {
padding: 4px 8px 8px;
}
.add-card-btn {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 8px;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
border-radius: var(--border-radius);
text-align: left;
}
.add-card-btn:hover { background: rgba(0,0,0,0.06); color: var(--text-primary); }
/* ============================================================
ADD COLUMN BUTTON
============================================================ */
.add-column-btn {
min-width: 280px;
padding: 12px 16px;
border: none;
background: rgba(255,255,255,0.5);
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
border-radius: var(--border-radius);
flex-shrink: 0;
}
.add-column-btn:hover { background: rgba(255,255,255,0.8); color: var(--text-primary); }
/* ============================================================
MODAL / INLINE FORMS
============================================================ */
.inline-form {
padding: 4px 8px;
}
.inline-form textarea {
width: 100%;
min-height: 60px;
border: 2px solid var(--accent);
border-radius: var(--border-radius);
padding: 8px;
font-size: 14px;
font-family: inherit;
resize: vertical;
outline: none;
}
.inline-form .form-actions {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
}
.btn-primary {
padding: 6px 14px;
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--border-radius);
font-size: 14px;
cursor: pointer;
}
.btn-primary:hover { background: var(--accent-hover); }
.btn-secondary {
padding: 6px 10px;
background: transparent;
color: var(--text-secondary);
border: none;
font-size: 14px;
cursor: pointer;
}
.btn-secondary:hover { text-decoration: underline; }
/* Label picker in form */
.label-picker {
display: flex;
gap: 6px;
margin-top: 8px;
flex-wrap: wrap;
}
.label-option {
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
border: 3px solid transparent;
transition: border-color var(--transition-fast);
}
.label-option.selected { border-color: var(--text-primary); }
.due-date-input {
margin-top: 8px;
padding: 6px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
}
/* ============================================================
DRAG GHOST (follows cursor)
============================================================ */
.drag-ghost {
position: fixed;
pointer-events: none;
z-index: 9999;
width: 280px;
transform: rotate(3deg);
box-shadow: var(--shadow-drag);
opacity: 0.95;
}
/* Drop placeholder */
.drop-placeholder {
height: 4px;
background: var(--accent);
border-radius: 2px;
margin: 4px 0;
transition: all 100ms ease;
}
/* ============================================================
TOAST NOTIFICATIONS
============================================================ */
.toast-container {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 10000;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.toast {
background: #333;
color: #fff;
padding: 10px 20px;
border-radius: var(--border-radius);
font-size: 14px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
animation: toastIn 200ms ease forwards;
}
.toast.removing { animation: toastOut 200ms ease forwards; }
@keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes toastOut { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(10px); } }
.toast .undo-btn {
color: #79b8ff;
background: none;
border: none;
font-size: 14px;
cursor: pointer;
font-weight: 600;
}
.toast .undo-btn:hover { text-decoration: underline; }
/* ============================================================
COLUMN TITLE EDITING
============================================================ */
.column-title-input {
font-size: 14px;
font-weight: 700;
padding: 2px 6px;
border: 2px solid var(--accent);
border-radius: 4px;
outline: none;
width: 150px;
}
/* ============================================================
CARD TITLE INLINE EDITING
============================================================ */
.card-title-input {
font-size: 14px;
line-height: 1.4;
color: var(--text-primary);
border: 2px solid var(--accent);
border-radius: 4px;
padding: 2px 6px;
outline: none;
width: 100%;
font-family: inherit;
}
/* ============================================================
CARD EDIT MODAL
============================================================ */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 5000;
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background: #fff;
border-radius: var(--border-radius);
padding: 24px;
width: 480px;
max-width: 90vw;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
.modal h2 { font-size: 18px; margin-bottom: 16px; }
.modal textarea {
width: 100%;
min-height: 80px;
border: 1px solid #ddd;
border-radius: var(--border-radius);
padding: 8px;
font-size: 14px;
font-family: inherit;
resize: vertical;
outline: none;
}
.modal textarea:focus { border-color: var(--accent); }
.modal .form-group { margin-top: 12px; }
.modal .form-group label { display: block; font-size: 13px; color: var(--text-secondary); margin-bottom: 4px; }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
</style>
</head>
<body>
<!-- ============================================================
HTML STRUCTURE
============================================================ -->
<div class="board-header">
<h1>Kanban Board</h1>
<div class="header-controls">
<input type="text" class="search-box" id="searchBox" placeholder="Search cards...">
</div>
</div>
<div class="filter-bar" id="filterBar"></div>
<div class="board-container" id="boardContainer"></div>
<div class="toast-container" id="toastContainer"></div>
<script>
/* ============================================================
TUNABLE CONSTANTS
============================================================ */
const CONFIG = {
STORAGE_KEY: 'kanban-board-state',
LABELS: [
{ name: 'Bug', color: '#eb5a46' },
{ name: 'Feature', color: '#61bd4f' },
{ name: 'Enhancement', color: '#f2d600' },
{ name: 'Design', color: '#c377e0' },
{ name: 'Documentation', color: '#0079bf' },
{ name: 'Urgent', color: '#ff78cb' },
],
DRAG_THRESHOLD: 5, // px before drag starts
AUTO_SCROLL_SPEED: 12, // px per frame near edge
AUTO_SCROLL_MARGIN: 60, // px from edge to trigger scroll
TOAST_DURATION: 4000, // ms before toast auto-dismisses
ANIMATION_DURATION: 200, // ms for settle animation
};
/* ============================================================
UTILITY FUNCTIONS
============================================================ */
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 9);
}
function deepClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
function formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr + 'T00:00:00');
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (d.getTime() === today.getTime()) return 'Today';
if (d.getTime() === tomorrow.getTime()) return 'Tomorrow';
if (d < today) {
const days = Math.floor((today - d) / 86400000);
return `${days}d overdue`;
}
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function isOverdue(dateStr) {
if (!dateStr) return false;
const d = new Date(dateStr + 'T00:00:00');
const today = new Date();
today.setHours(0, 0, 0, 0);
return d < today;
}
/* ============================================================
BOARD MODEL — SINGLE SOURCE OF TRUTH
============================================================ */
const defaultBoardModel = {
columns: [
{ id: generateId(), title: 'Backlog', cardIds: [], wipLimit: null },
{ id: generateId(), title: 'To Do', cardIds: [], wipLimit: null },
{ id: generateId(), title: 'In Progress', cardIds: [], wipLimit: 3 },
{ id: generateId(), title: 'Review', cardIds: [], wipLimit: null },
{ id: generateId(), title: 'Done', cardIds: [], wipLimit: null },
],
cards: {}, // keyed by cardId
filterLabels: [],
searchQuery: '',
};
let boardModel = deepClone(defaultBoardModel);
// Undo stack — stores snapshots before destructive actions
const undoStack = [];
function saveUndoSnapshot() {
undoStack.push(deepClone(boardModel));
if (undoStack.length > 50) undoStack.shift(); // cap at 50
}
function restoreFromUndo() {
if (undoStack.length === 0) return;
boardModel = undoStack.pop();
persistBoard();
renderAll();
}
/* ============================================================
PERSISTENCE
============================================================ */
function persistBoard() {
try {
localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify(boardModel));
} catch (e) { /* quota exceeded — silently ignore */ }
}
function loadBoard() {
try {
const saved = localStorage.getItem(CONFIG.STORAGE_KEY);
if (saved) {
boardModel = JSON.parse(saved);
// Ensure filterLabels and searchQuery exist for older saves
if (!boardModel.filterLabels) boardModel.filterLabels = [];
if (!boardModel.searchQuery) boardModel.searchQuery = '';
}
} catch (e) { /* corrupted save — use defaults */ }
}
/* ============================================================
TOAST SYSTEM
============================================================ */
function showToast(message, undoCallback) {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = 'toast';
const msgSpan = document.createElement('span');
msgSpan.textContent = message;
toast.appendChild(msgSpan);
if (undoCallback) {
const undoBtn = document.createElement('button');
undoBtn.className = 'undo-btn';
undoBtn.textContent = 'Undo';
undoBtn.addEventListener('click', () => {
undoCallback();
removeToast(toast);
});
toast.appendChild(undoBtn);
}
container.appendChild(toast);
setTimeout(() => removeToast(toast), CONFIG.TOAST_DURATION);
}
function removeToast(toast) {
if (!toast.parentNode) return;
toast.classList.add('removing');
setTimeout(() => toast.remove(), 200);
}
/* ============================================================
RENDERING — DERIVED ENTIRELY FROM MODEL
============================================================ */
const boardContainer = document.getElementById('boardContainer');
const filterBarEl = document.getElementById('filterBar');
const searchBoxEl = document.getElementById('searchBox');
function renderAll() {
renderFilterBar();
renderBoard();
}
function renderFilterBar() {
const activeLabels = boardModel.filterLabels;
let html = '';
CONFIG.LABELS.forEach(label => {
const isActive = activeLabels.includes(label.name);
html += `<button class="filter-chip ${isActive ? 'active' : ''}"
style="background:${label.color}20;color:${label.color};${isActive ? '' : 'opacity:0.6'}"
data-label="${label.name}">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${label.color}"></span>
${label.name}
</button>`;
});
if (activeLabels.length > 0) {
html += `<button class="clear-filters-btn" id="clearFiltersBtn">Clear filters</button>`;
}
filterBarEl.innerHTML = html;
// Bind events
filterBarEl.querySelectorAll('.filter-chip').forEach(btn => {
btn.addEventListener('click', () => {
const labelName = btn.dataset.label;
if (boardModel.filterLabels.includes(labelName)) {
boardModel.filterLabels = boardModel.filterLabels.filter(l => l !== labelName);
} else {
boardModel.filterLabels.push(labelName);
}
persistBoard();
renderAll();
});
});
const clearBtn = document.getElementById('clearFiltersBtn');
if (clearBtn) {
clearBtn.addEventListener('click', () => {
boardModel.filterLabels = [];
persistBoard();
renderAll();
});
}
}
function isCardVisible(cardId) {
const card = boardModel.cards[cardId];
if (!card) return false;
// Search filter
if (boardModel.searchQuery) {
const q = boardModel.searchQuery.toLowerCase();
if (!card.title.toLowerCase().includes(q)) return false;
}
// Label filter — only show cards that have at least one selected label
if (boardModel.filterLabels.length > 0) {
const hasMatchingLabel = card.labels.some(l => boardModel.filterLabels.includes(l));
if (!hasMatchingLabel) return false;
}
return true;
}
function renderBoard() {
let html = '';
boardModel.columns.forEach(col => {
const wipExceeded = col.wipLimit !== null && col.cardIds.length > col.wipLimit;
const visibleCards = col.cardIds.filter(id => isCardVisible(id));
const isEmpty = col.cardIds.length === 0;
html += `<div class="column ${wipExceeded ? 'wip-exceeded' : ''}" data-column-id="${col.id}">`;
// Column header
html += `<div class="column-header" data-drag-column="${col.id}">
<div class="column-title-group">
<span class="column-title" data-edit-col="${col.id}">${escapeHtml(col.title)}</span>
<span class="card-count">${col.cardIds.length}</span>
</div>
<button class="column-menu-btn" data-open-menu="${col.id}">⋯</button>
</div>`;
// Card list
html += `<div class="card-list ${isEmpty ? 'empty-column' : ''}" data-cardlist="${col.id}">`;
col.cardIds.forEach(cardId => {
const card = boardModel.cards[cardId];
if (!card) return;
const visible = isCardVisible(cardId);
html += renderCard(card, !visible);
});
html += `</div>`; // card-list
// Add card button
html += `<div class="add-card-row">
<button class="add-card-btn" data-add-card="${col.id}">+ Add a card</button>
</div>`;
html += `</div>`; // column
});
// Add column button
html += `<button class="add-column-btn" id="addColumnBtn">+ Add another column</button>`;
boardContainer.innerHTML = html;
bindBoardEvents();
}
function renderCard(card, hidden) {
const labelsHtml = card.labels.map(l => {
const labelDef = CONFIG.LABELS.find(ld => ld.name === l);
const color = labelDef ? labelDef.color : '#999';
return `<span class="label-chip" style="background:${color}" title="${escapeHtml(l)}"></span>`;
}).join('');
const descHtml = card.description ? `<div class="card-description">${escapeHtml(card.description)}</div>` : '';
let dueDateHtml = '';
if (card.dueDate) {
const overdueClass = isOverdue(card.dueDate) ? 'overdue' : '';
dueDateHtml = `<span class="due-date ${overdueClass}">📅 ${formatDate(card.dueDate)}</span>`;
}
return `<div class="card ${hidden ? 'hidden-by-filter' : ''}" data-card-id="${card.id}">
<div class="card-actions">
<button class="card-action-btn" data-edit-card="${card.id}" title="Edit">✏️</button>
<button class="card-action-btn" data-delete-card="${card.id}" title="Delete">🗑️</button>
</div>
${labelsHtml ? `<div class="card-labels">${labelsHtml}</div>` : ''}
<div class="card-title" data-inline-edit="${card.id}">${escapeHtml(card.title)}</div>
${descHtml}
<div class="card-meta">
${dueDateHtml}
</div>
</div>`;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
/* ============================================================
EVENT BINDING
============================================================ */
function bindBoardEvents() {
// Add card buttons
boardContainer.querySelectorAll('[data-add-card]').forEach(btn => {
btn.addEventListener('click', () => showAddCardForm(btn.dataset.addCard));
});
// Delete card buttons
boardContainer.querySelectorAll('[data-delete-card]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
deleteCard(btn.dataset.deleteCard);
});
});
// Edit card buttons
boardContainer.querySelectorAll('[data-edit-card]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
showEditCardModal(btn.dataset.editCard);
});
});
// Inline title editing
boardContainer.querySelectorAll('[data-inline-edit]').forEach(el => {
el.addEventListener('dblclick', (e) => {
e.stopPropagation();
startInlineTitleEdit(el, el.dataset.inlineEdit);
});
});
// Column menu buttons
boardContainer.querySelectorAll('[data-open-menu]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
toggleColumnMenu(btn.dataset.openMenu);
});
});
// Column title editing
boardContainer.querySelectorAll('[data-edit-col]').forEach(el => {
el.addEventListener('dblclick', (e) => {
e.stopPropagation();
startColumnTitleEdit(el, el.dataset.editCol);
});
});
// Add column button
const addColBtn = document.getElementById('addColumnBtn');
if (addColBtn) {
addColBtn.addEventListener('click', () => addColumn());
}
// Search box
searchBoxEl.value = boardModel.searchQuery || '';
searchBoxEl.oninput = () => {
boardModel.searchQuery = searchBoxEl.value;
persistBoard();
renderAll();
};
}
/* ============================================================
CARD CRUD
============================================================ */
function showAddCardForm(columnId) {
const columnEl = document.querySelector(`[data-column-id="${columnId}"]`);
if (!columnEl) return;
const addRow = columnEl.querySelector('.add-card-row');
addRow.innerHTML = `
<div class="inline-form">
<textarea id="newCardTitle" placeholder="Enter a title for this card..." autofocus></textarea>
<div class="form-actions">
<button class="btn-primary" id="submitNewCard">Add card</button>
<button class="btn-secondary" id="cancelNewCard">Cancel</button>
</div>
</div>`;
const textarea = document.getElementById('newCardTitle');
textarea.focus();
function submit() {
const title = textarea.value.trim();
if (!title) return;
addCard(columnId, title);
renderAll();
}
document.getElementById('submitNewCard').addEventListener('click', submit);
document.getElementById('cancelNewCard').addEventListener('click', () => renderAll());
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); }
if (e.key === 'Escape') renderAll();
});
}
function addCard(columnId, title, description = '', labels = [], dueDate = '') {
const cardId = generateId();
boardModel.cards[cardId] = {
id: cardId,
title,
description,
labels,
dueDate,
};
// Find column and append card
const col = boardModel.columns.find(c => c.id === columnId);
if (col) col.cardIds.push(cardId);
persistBoard();
}
function deleteCard(cardId) {
saveUndoSnapshot();
const card = boardModel.cards[cardId];
if (!card) return;
// Remove from all columns
boardModel.columns.forEach(col => {
col.cardIds = col.cardIds.filter(id => id !== cardId);
});
delete boardModel.cards[cardId];
persistBoard();
renderAll();
showToast('Card deleted', restoreFromUndo);
}
function showEditCardModal(cardId) {
const card = boardModel.cards[cardId];
if (!card) return;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
const labelOptionsHtml = CONFIG.LABELS.map(l => {
const selected = card.labels.includes(l.name);
return `<div class="label-option ${selected ? 'selected' : ''}"
style="background:${l.color}" data-label="${l.name}" title="${l.name}"></div>`;
}).join('');
overlay.innerHTML = `
<div class="modal">
<h2>Edit Card</h2>
<input type="text" id="editCardTitleInput" value="${escapeHtml(card.title)}"
style="width:100%;padding:8px;border:1px solid #ddd;border-radius:var(--border-radius);font-size:14px;font-family:inherit;outline:none;margin-bottom:8px;">
<textarea id="editCardDescInput">${escapeHtml(card.description)}</textarea>
<div class="form-group">
<label>Labels</label>
<div class="label-picker" id="editLabelPicker">${labelOptionsHtml}</div>
</div>
<div class="form-group">
<label>Due Date</label>
<input type="date" class="due-date-input" id="editCardDueDate" value="${card.dueDate || ''}">
</div>
<div class="modal-actions">
<button class="btn-secondary" id="cancelEditCard">Cancel</button>
<button class="btn-primary" id="saveEditCard">Save</button>
</div>
</div>`;
document.body.appendChild(overlay);
// Label picker toggle
overlay.querySelectorAll('.label-option').forEach(opt => {
opt.addEventListener('click', () => opt.classList.toggle('selected'));
});
function save() {
card.title = document.getElementById('editCardTitleInput').value.trim() || card.title;
card.description = document.getElementById('editCardDescInput').value.trim();
card.labels = Array.from(overlay.querySelectorAll('.label-option.selected')).map(el => el.dataset.label);
card.dueDate = document.getElementById('editCardDueDate').value;
persistBoard();
renderAll();
}
overlay.querySelector('#saveEditCard').addEventListener('click', save);
overlay.querySelector('#cancelEditCard').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
document.getElementById('editCardTitleInput').focus();
}
function startInlineTitleEdit(el, cardId) {
const card = boardModel.cards[cardId];
if (!card) return;
el.innerHTML = `<input class="card-title-input" value="${escapeHtml(card.title)}">`;
const input = el.querySelector('input');
input.focus();
input.select();
function commit() {
const newTitle = input.value.trim();
if (newTitle) card.title = newTitle;
persistBoard();
renderAll();
}
function cancel() {
renderAll();
}
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') cancel();
});
input.addEventListener('blur', () => setTimeout(commit, 100));
}
/* ============================================================
COLUMN CRUD
============================================================ */
function addColumn() {
const title = prompt('Column name:');
if (!title || !title.trim()) return;
boardModel.columns.push({
id: generateId(),
title: title.trim(),
cardIds: [],
wipLimit: null,
});
persistBoard();
renderAll();
}
function deleteColumn(columnId) {
if (boardModel.columns.length <= 1) return; // keep at least one
saveUndoSnapshot();
const col = boardModel.columns.find(c => c.id === columnId);
if (!col) return;
// Remove cards from model
col.cardIds.forEach(cardId => delete boardModel.cards[cardId]);
boardModel.columns = boardModel.columns.filter(c => c.id !== columnId);
persistBoard();
renderAll();
showToast('Column deleted', restoreFromUndo);
}
function startColumnTitleEdit(el, columnId) {
const col = boardModel.columns.find(c => c.id === columnId);
if (!col) return;
el.innerHTML = `<input class="column-title-input" value="${escapeHtml(col.title)}">`;
const input = el.querySelector('input');
input.focus();
input.select();
function commit() {
const newTitle = input.value.trim();
if (newTitle) col.title = newTitle;
persistBoard();
renderAll();
}
function cancel() { renderAll(); }
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') cancel();
});
input.addEventListener('blur', () => setTimeout(commit, 100));
}
/* ============================================================
COLUMN DROPDOWN MENU
============================================================ */
let activeMenuColumnId = null;
function toggleColumnMenu(columnId) {
// Close any existing menu
closeAllMenus();
const colHeader = document.querySelector(`[data-column-id="${columnId}"] .column-header`);
if (!colHeader) return;
const col = boardModel.columns.find(c => c.id === columnId);
if (!col) return;
const menu = document.createElement('div');
menu.className = 'column-dropdown';
menu.innerHTML = `
<button id="menuRenameCol">Rename</button>
<div class="wip-input-row">
<label>WIP Limit:</label>
<input type="number" min="0" max="99" value="${col.wipLimit === null ? '' : col.wipLimit}" id="menuWipInput">
<button id="menuSetWip">Set</button>
</div>
<button id="menuClearWip">Clear WIP Limit</button>
<button class="danger" id="menuDeleteCol">Delete column</button>`;
colHeader.style.position = 'relative';
colHeader.appendChild(menu);
activeMenuColumnId = columnId;
document.getElementById('menuRenameCol').addEventListener('click', () => {
closeAllMenus();
const titleEl = document.querySelector(`[data-column-id="${columnId}"] .column-title`);
if (titleEl) startColumnTitleEdit(titleEl, columnId);
});
document.getElementById('menuSetWip').addEventListener('click', () => {
const val = parseInt(document.getElementById('menuWipInput').value);
col.wipLimit = isNaN(val) || val <= 0 ? null : val;
closeAllMenus();
persistBoard();
renderAll();
});
document.getElementById('menuClearWip').addEventListener('click', () => {
col.wipLimit = null;
closeAllMenus();
persistBoard();
renderAll();
});
document.getElementById('menuDeleteCol').addEventListener('click', () => {
if (confirm(`Delete column "${col.title}" and all its cards?`)) {
deleteColumn(columnId);
}
closeAllMenus();
});
}
function closeAllMenus() {
document.querySelectorAll('.column-dropdown').forEach(m => m.remove());
activeMenuColumnId = null;
}
// Close menus on outside click
document.addEventListener('click', (e) => {
if (!e.target.closest('.column-menu-btn') && !e.target.closest('.column-dropdown')) {
closeAllMenus();
}
});
/* ============================================================
DRAG AND DROP — CARD LEVEL
Uses pointer events for pixel-precise control.
============================================================ */
let dragState = null; // null or { type: 'card'|'column', ... }
boardContainer.addEventListener('pointerdown', onPointerDown);
function onPointerDown(e) {
// Only left button
if (e.button !== 0) return;
const cardEl = e.target.closest('.card');
const colHeaderEl = e.target.closest('[data-drag-column]');
// Don't start drag from buttons/inputs inside cards
if (e.target.closest('button, input, textarea')) return;
if (cardEl && !cardEl.classList.contains('hidden-by-filter')) {
startCardDrag(e, cardEl);
} else if (colHeaderEl) {
// Check it's not clicking a button inside the header
if (!e.target.closest('.column-menu-btn') && !e.target.closest('.column-title-input')) {
startColumnDrag(e, colHeaderEl);
}
}
}
/* --- Card Drag --- */
function startCardDrag(e, cardEl) {
const cardId = cardEl.dataset.cardId;
const columnId = findParentColumnId(cardEl);
if (!columnId) return;
// Record original position for cancel
const originalModel = deepClone(boardModel);
dragState = {
type: 'card',
cardId,
originColumnId: columnId,
originIndex: getCardIndexInColumn(columnId, cardId),
startX: e.clientX,
startY: e.clientY,
hasMoved: false,
originalModel,
};
// Create ghost element (visual copy that follows cursor)
const ghost = createDragGhost(cardEl);
dragState.ghost = ghost;
document.body.appendChild(ghost);
// Mark original card as dragging (dimmed placeholder)
cardEl.classList.add('dragging');
window.addEventListener('pointermove', onCardPointerMove, { passive: false });
window.addEventListener('pointerup', onCardPointerUp);
window.addEventListener('keydown', onCardDragKeydown);
e.preventDefault();
}
function createDragGhost(cardEl) {
const ghost = cardEl.cloneNode(true);
ghost.className = 'card drag-ghost';
ghost.style.position = 'fixed';
ghost.style.zIndex = '9999';
ghost.style.pointerEvents = 'none';
ghost.style.width = cardEl.offsetWidth + 'px';
ghost.style.transform = 'rotate(3deg) scale(1.02)';
ghost.style.boxShadow = '0 8px 24px rgba(0,0,0,0.2)';
// Store width for centering
dragState._ghostWidth = cardEl.offsetWidth;
return ghost;
}
function onCardPointerMove(e) {
if (!dragState || dragState.type !== 'card') return;
const dx = e.clientX - dragState.startX;
const dy = e.clientY - dragState.startY;
// Check threshold before starting actual drag
if (!dragState.hasMoved && Math.abs(dx) < CONFIG.DRAG_THRESHOLD && Math.abs(dy) < CONFIG.DRAG_THRESHOLD) {
return;
}
dragState.hasMoved = true;
// Move ghost to follow cursor — centered horizontally, offset slightly above finger
const ghost = dragState.ghost;
if (ghost) {
const gw = dragState._ghostWidth || 280;
ghost.style.left = (e.clientX - gw / 2) + 'px';
ghost.style.top = (e.clientY - 30) + 'px';
}
// Auto-scroll
handleAutoScroll(e);
// Find drop target and show placeholder
updateDropIndicator(e);
}
function onCardPointerUp(e) {
if (!dragState || dragState.type !== 'card') return;
window.removeEventListener('pointermove', onCardPointerMove);
window.removeEventListener('pointerup', onCardPointerUp);
window.removeEventListener('keydown', onCardDragKeydown);
const ghost = dragState.ghost;
if (ghost) ghost.remove();
// Remove placeholder
removeDropIndicator();
if (!dragState.hasMoved) {
// Didn't actually drag — just a click, restore card appearance
const cardEl = document.querySelector(`[data-card-id="${dragState.cardId}"]`);
if (cardEl) cardEl.classList.remove('dragging');
dragState = null;
return;
}
// Determine drop target
const dropTarget = getDropTarget(e.clientX, e.clientY);
if (dropTarget) {
saveUndoSnapshot();
moveCardInModel(dragState.cardId, dropTarget.columnId, dropTarget.index);
persistBoard();
} else {
// No valid target — restore original model
boardModel = dragState.originalModel;
persistBoard();
}
const droppedCardId = dragState.cardId;
renderAll();
// Settle animation: briefly highlight the dropped card
requestAnimationFrame(() => {
const droppedEl = document.querySelector(`[data-card-id="${droppedCardId}"]`);
if (droppedEl) {
droppedEl.style.transition = 'transform 200ms ease, box-shadow 200ms ease';
droppedEl.style.transform = 'scale(1.03)';
droppedEl.style.boxShadow = '0 4px 12px rgba(0,121,191,0.3)';
requestAnimationFrame(() => {
droppedEl.style.transform = '';
droppedEl.style.boxShadow = '';
setTimeout(() => { droppedEl.style.transition = ''; }, 250);
});
}
});
dragState = null;
}
function onCardDragKeydown(e) {
if (!dragState || dragState.type !== 'card') return;
if (e.key === 'Escape') {
// Cancel: restore original model
boardModel = dragState.originalModel;
persistBoard();
window.removeEventListener('pointermove', onCardPointerMove);
window.removeEventListener('pointerup', onCardPointerUp);
window.removeEventListener('keydown', onCardDragKeydown);
if (dragState.ghost) dragState.ghost.remove();
removeDropIndicator();
renderAll();
dragState = null;
}
}
function getCardIndexInColumn(columnId, cardId) {
const col = boardModel.columns.find(c => c.id === columnId);
if (!col) return -1;
return col.cardIds.indexOf(cardId);
}
function findParentColumnId(el) {
let current = el;
while (current && current !== document.body) {
const colEl = current.closest('.column');
if (colEl) return colEl.dataset.columnId;
current = current.parentElement;
}
return null;
}
function moveCardInModel(cardId, targetColumnId, targetIndex) {
// Find source column and remove card
let sourceCol = null;
boardModel.columns.forEach(col => {
const idx = col.cardIds.indexOf(cardId);
if (idx !== -1) {
sourceCol = col;
col.cardIds.splice(idx, 1);
}
});
if (!sourceCol) return;
// Find target column and insert at index
const targetCol = boardModel.columns.find(c => c.id === targetColumnId);
if (!targetCol) return;
// Clamp index
const idx = Math.min(Math.max(0, targetIndex), targetCol.cardIds.length);
targetCol.cardIds.splice(idx, 0, cardId);
}
/* --- Drop Indicator (placeholder line) --- */
let dropIndicatorEl = null;
function updateDropIndicator(e) {
removeDropIndicator();
const target = getDropTarget(e.clientX, e.clientY);
if (!target) return;
// Create indicator element
const indicator = document.createElement('div');
indicator.className = 'drop-placeholder';
dropIndicatorEl = indicator;
const cardList = document.querySelector(`[data-cardlist="${target.columnId}"]`);
if (!cardList) return;
// Insert at the right position in the DOM
const cardsInColumn = Array.from(cardList.querySelectorAll('.card:not(.dragging):not(.hidden-by-filter)'));
if (cardsInColumn.length === 0 || target.index >= cardsInColumn.length) {
cardList.appendChild(indicator);
} else {
const refCard = cardsInColumn[target.index];
cardList.insertBefore(indicator, refCard);
}
// Highlight the column being dragged over
highlightDropColumn(target.columnId);
}
function removeDropIndicator() {
if (dropIndicatorEl && dropIndicatorEl.parentNode) {
dropIndicatorEl.remove();
}
dropIndicatorEl = null;
unhighlightAllColumns();
}
function highlightDropColumn(columnId) {
unhighlightAllColumns();
const colEl = document.querySelector(`[data-column-id="${columnId}"]`);
if (colEl) colEl.classList.add('drag-over-column');
}
function unhighlightAllColumns() {
document.querySelectorAll('.drag-over-column').forEach(el => el.classList.remove('drag-over-column'));
}
/* --- Drop Target Calculation --- */
function getDropTarget(x, y) {
// Find which column the cursor is over
const columns = Array.from(document.querySelectorAll('.column'));
for (const colEl of columns) {
const rect = colEl.getBoundingClientRect();
// Check if x is within column horizontally
if (x < rect.left - 10 || x > rect.right + 10) continue;
const cardList = colEl.querySelector('.card-list');
if (!cardList) continue;
const visibleCards = Array.from(cardList.querySelectorAll('.card:not(.dragging):not(.hidden-by-filter)'));
// Empty column — drop here at index 0
if (visibleCards.length === 0) {
return { columnId: colEl.dataset.columnId, index: 0 };
}
// Find the card we're closest to / between
for (let i = 0; i < visibleCards.length; i++) {
const cardRect = visibleCards[i].getBoundingClientRect();
const cardMidY = cardRect.top + cardRect.height / 2;
if (y < cardMidY) {
// Drop before this card
return { columnId: colEl.dataset.columnId, index: i };
}
}
// Past all cards — append at end
const lastCard = visibleCards[visibleCards.length - 1];
if (y >= lastCard.getBoundingClientRect().top + lastCard.getBoundingClientRect().height / 2) {
return { columnId: colEl.dataset.columnId, index: visibleCards.length };
}
// Fallback
return { columnId: colEl.dataset.columnId, index: visibleCards.length };
}
return null;
}
/* --- Auto-Scroll During Drag --- */
function handleAutoScroll(e) {
const margin = CONFIG.AUTO_SCROLL_MARGIN;
const speed = CONFIG.AUTO_SCROLL_SPEED;
const boardRect = boardContainer.getBoundingClientRect();
// Horizontal scroll (board level)
if (e.clientX > boardRect.right - margin) {
boardContainer.scrollBy(speed, 0);
} else if (e.clientX < boardRect.left + margin) {
boardContainer.scrollBy(-speed, 0);
}
// Vertical scroll (column level) — find column under cursor
const columns = document.querySelectorAll('.column');
for (const col of columns) {
const rect = col.getBoundingClientRect();
if (e.clientX >= rect.left && e.clientX <= rect.right) {
const cardList = col.querySelector('.card-list');
if (!cardList) continue;
if (e.clientY > rect.bottom - margin) {
cardList.scrollTop += speed;
} else if (e.clientY < rect.top + margin) {
cardList.scrollTop -= speed;
}
break;
}
}
}
/* --- Column Drag-to-Reorder --- */
function startColumnDrag(e, colHeaderEl) {
const columnId = colHeaderEl.dataset.dragColumn;
if (!columnId) return;
dragState = {
type: 'column',
columnId,
originIndex: boardModel.columns.findIndex(c => c.id === columnId),
startX: e.clientX,
startY: e.clientY,
hasMoved: false,
originalModel: deepClone(boardModel),
};
window.addEventListener('pointermove', onColumnPointerMove, { passive: false });
window.addEventListener('pointerup', onColumnPointerUp);
window.addEventListener('keydown', onColumnDragKeydown);
e.preventDefault();
}
function onColumnPointerMove(e) {
if (!dragState || dragState.type !== 'column') return;
const dx = e.clientX - dragState.startX;
const dy = e.clientY - dragState.startY;
if (!dragState.hasMoved && Math.abs(dx) < CONFIG.DRAG_THRESHOLD && Math.abs(dy) < CONFIG.DRAG_THRESHOLD) {
return;
}
dragState.hasMoved = true;
// Visual feedback: highlight columns being swapped over
const allColEls = Array.from(document.querySelectorAll('.column'));
for (const colEl of allColEls) {
const rect = colEl.getBoundingClientRect();
if (e.clientX > rect.left && e.clientX < rect.right) {
// Find the column index in model
const targetIndex = boardModel.columns.findIndex(c => c.id === colEl.dataset.columnId);
if (targetIndex !== dragState.originIndex) {
// Swap columns in model temporarily for visual feedback
swapColumnsInModel(dragState.originIndex, targetIndex);
renderAll();
// Re-mark the dragged column header visually
const draggedColEl = document.querySelector(`[data-column-id="${dragState.columnId}"]`);
if (draggedColEl) {
draggedColEl.style.opacity = '0.5';
draggedColEl.style.transform = 'scale(1.02)';
}
}
break;
}
}
handleAutoScroll(e);
}
function onColumnPointerUp(e) {
if (!dragState || dragState.type !== 'column') return;
window.removeEventListener('pointermove', onColumnPointerMove);
window.removeEventListener('pointerup', onColumnPointerUp);
window.removeEventListener('keydown', onColumnDragKeydown);
if (!dragState.hasMoved) {
dragState = null;
return;
}
// Commit the swap (already done in model during move)
persistBoard();
renderAll();
dragState = null;
}
function onColumnDragKeydown(e) {
if (!dragState || dragState.type !== 'column') return;
if (e.key === 'Escape') {
boardModel = dragState.originalModel;
persistBoard();
window.removeEventListener('pointermove', onColumnPointerMove);
window.removeEventListener('pointerup', onColumnPointerUp);
window.removeEventListener('keydown', onColumnDragKeydown);
renderAll();
dragState = null;
}
}
function swapColumnsInModel(fromIndex, toIndex) {
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return;
const cols = boardModel.columns;
const temp = cols[fromIndex];
cols.splice(fromIndex, 1);
cols.splice(toIndex, 0, temp);
}
/* ============================================================
INITIALIZATION
============================================================ */
function init() {
loadBoard();
// If no saved state, add some sample cards for demo
if (Object.keys(boardModel.cards).length === 0) {
const colIds = boardModel.columns.map(c => c.id);
// Sample cards for Backlog
addCard(colIds[0], 'Research competitor features', '', ['Feature'], '');
addCard(colIds[0], 'Write API documentation', '', ['Documentation'], '2025-02-15');
addCard(colIds[0], 'Fix login page crash on mobile', '', ['Bug', 'Urgent'], '2025-01-20');
// Sample cards for To Do
addCard(colIds[1], 'Design new dashboard layout', '', ['Design'], '');
addCard(colIds[1], 'Implement dark mode toggle', '', ['Enhancement'], '2025-02-01');
// Sample cards for In Progress
addCard(colIds[2], 'Set up CI/CD pipeline', 'Configure GitHub Actions for automated testing and deployment.', ['Feature'], '');
addCard(colIds[2], 'Refactor authentication module', '', ['Enhancement'], '2025-01-30');
// Sample cards for Review
addCard(colIds[3], 'Add keyboard shortcuts', 'Support Space to jump, Enter to confirm.', ['Feature'], '');
persistBoard();
}
renderAll();
}
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"full","sessionId":"67adfe99-fc12-4408-b9f1-0ddfbe0dea1f","tokensIn":826767,"tokensOut":28212,"tokensTotal":854979,"turns":27,"toolCalls":26,"failedToolCalls":0,"timestamp":"2026-07-29T22:53:44.467Z"} -->