Kanban
Qwen 3.6 27b MTP · composer

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 46,298 bytes · SHA-256 d85cd141ba6c
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kanban Board</title>
<style>
/* ── css: layout ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: #f4f5f7;
color: #172b4d;
overflow: hidden;
height: 100vh;
}
#board-header {
position: sticky;
top: 0;
z-index: 100;
background: linear-gradient(135deg, #6B7BF7 0%, #4A5AF7 100%);
padding: 12px 24px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.header-left h1 {
color: white;
font-size: 20px;
font-weight: 700;
letter-spacing: -0.3px;
}
.header-controls {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
#search-input {
padding: 6px 12px;
border-radius: 6px;
border: none;
background: rgba(255,255,255,0.2);
color: white;
font-size: 13px;
width: 180px;
outline: none;
transition: background 0.2s;
}
#search-input::placeholder { color: rgba(255,255,255,0.7); }
#search-input:focus { background: rgba(255,255,255,0.3); }
#filter-bar {
display: flex;
gap: 6px;
align-items: center;
}
.filter-chip {
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.15s;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.filter-chip.active {
border-color: white;
box-shadow: 0 0 0 2px rgba(255,255,255,0.4);
transform: scale(1.05);
}
#board-container {
height: calc(100vh - 56px);
overflow-x: auto;
overflow-y: hidden;
padding: 20px;
scroll-behavior: smooth;
}
#board-container::-webkit-scrollbar { height: 8px; }
#board-container::-webkit-scrollbar-track { background: #ebecf0; }
#board-container::-webkit-scrollbar-thumb { background: #c4c9d2; border-radius: 4px; }
#board {
display: flex;
gap: 14px;
height: 100%;
min-width: fit-content;
align-items: flex-start;
}
.btn {
padding: 6px 14px;
border-radius: 6px;
border: none;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.15s;
}
.btn-primary {
background: rgba(255,255,255,0.2);
color: white;
}
.btn-primary:hover { background: rgba(255,255,255,0.35); }
/* ── css: columns-cards ── */
.column {
min-width: 280px;
max-width: 280px;
background: #ebecf0;
border-radius: 12px;
display: flex;
flex-direction: column;
max-height: calc(100vh - 96px);
transition: box-shadow 0.2s, transform 0.15s;
}
.column.drag-over {
background: #e2e4ea;
}
.column.wip-exceeded {
box-shadow: 0 0 0 3px rgba(255, 87, 87, 0.6);
}
.column-header {
padding: 12px 14px 8px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: grab;
user-select: none;
position: relative;
}
.column-header:active { cursor: grabbing; }
.column-title-group {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.column-title {
font-size: 13px;
font-weight: 700;
color: #5e6c84;
text-transform: uppercase;
letter-spacing: 0.3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.column-title-input {
font-size: 13px;
font-weight: 700;
color: #5e6c84;
border: none;
background: white;
padding: 2px 6px;
border-radius: 4px;
outline: none;
width: 100%;
}
.card-count {
font-size: 12px;
color: #8993a4;
background: rgba(0,0,0,0.06);
padding: 2px 7px;
border-radius: 10px;
font-weight: 600;
}
.column-menu-btn {
background: none;
border: none;
cursor: pointer;
color: #8993a4;
padding: 4px;
border-radius: 4px;
font-size: 16px;
line-height: 1;
}
.column-menu-btn:hover { background: rgba(0,0,0,0.08); color: #5e6c84; }
.column-body {
flex: 1;
overflow-y: auto;
padding: 4px 8px 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.column-body::-webkit-scrollbar { width: 6px; }
.column-body::-webkit-scrollbar-track { background: transparent; }
.column-body::-webkit-scrollbar-thumb { background: #c4c9d2; border-radius: 3px; }
.card-footer {
padding: 8px 14px 12px;
}
.add-card-btn {
width: 100%;
padding: 7px;
border: none;
background: rgba(0,0,0,0.04);
color: #5e6c84;
font-size: 13px;
cursor: pointer;
border-radius: 8px;
text-align: left;
transition: all 0.15s;
}
.add-card-btn:hover { background: rgba(0,0,0,0.08); color: #172b4d; }
/* Cards */
.card {
background: white;
border-radius: 8px;
padding: 10px 12px;
cursor: grab;
box-shadow: 0 1px 2px rgba(0,0,0,0.06), 0 0 1px rgba(0,0,0,0.04);
transition: transform 0.15s ease, box-shadow 0.15s ease;
position: relative;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1), 0 0 1px rgba(0,0,0,0.06);
}
.card.dragging {
opacity: 0;
}
.card-title {
font-size: 14px;
font-weight: 500;
color: #172b4d;
margin-bottom: 4px;
line-height: 1.4;
word-break: break-word;
}
.card-title-input {
font-size: 14px;
font-weight: 500;
color: #172b4d;
border: none;
background: #f4f5f7;
padding: 4px 6px;
border-radius: 4px;
outline: none;
width: 100%;
}
.card-description {
font-size: 12px;
color: #8993a4;
line-height: 1.4;
margin-bottom: 6px;
word-break: break-word;
}
.card-labels {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.label-chip {
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.card-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: #8993a4;
}
.due-date {
display: flex;
align-items: center;
gap: 3px;
}
.due-date.overdue { color: #eb5a46; 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.05);
border: none;
cursor: pointer;
padding: 3px 5px;
border-radius: 4px;
font-size: 12px;
color: #8993a4;
}
.card-action-btn:hover { background: rgba(0,0,0,0.1); }
/* Drop placeholder */
.drop-placeholder {
height: 60px;
border: 2px dashed #b0bec5;
border-radius: 8px;
background: rgba(176, 190, 197, 0.1);
transition: all 0.1s ease;
}
/* Empty column drop zone */
.empty-drop-zone {
min-height: 80px;
border: 2px dashed #c4c9d2;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #b0bec5;
font-size: 13px;
}
/* Dragging card (floating) */
.drag-ghost {
position: fixed;
pointer-events: none;
z-index: 9999;
transform: rotate(2deg);
box-shadow: 0 8px 30px rgba(0,0,0,0.25);
border-radius: 8px;
background: white;
padding: 10px 12px;
min-width: 240px;
max-width: 280px;
}
/* Column drag ghost */
.column-ghost {
position: fixed;
pointer-events: none;
z-index: 9999;
opacity: 0.85;
transform: rotate(1deg);
box-shadow: 0 8px 30px rgba(0,0,0,0.25);
}
/* Column placeholder */
.column-placeholder {
min-width: 280px;
max-width: 280px;
height: 200px;
border: 2px dashed #b0bec5;
border-radius: 12px;
background: rgba(176, 190, 197, 0.1);
}
/* Toast */
#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: #363b42;
color: white;
padding: 10px 20px;
border-radius: 8px;
font-size: 13px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
display: flex;
align-items: center;
gap: 12px;
animation: toastIn 0.25s ease;
}
.toast-undo {
background: none;
border: none;
color: #61bdff;
cursor: pointer;
font-weight: 600;
font-size: 13px;
padding: 2px 8px;
border-radius: 4px;
}
.toast-undo:hover { background: rgba(97, 189, 255, 0.1); }
@keyframes toastIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Modal */
#modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
}
#modal-content {
background: white;
border-radius: 12px;
padding: 24px;
min-width: 360px;
max-width: 500px;
box-shadow: 0 8px 30px rgba(0,0,0,0.2);
}
.modal-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 16px;
}
.modal-field {
margin-bottom: 14px;
}
.modal-field label {
display: block;
font-size: 12px;
font-weight: 600;
color: #5e6c84;
margin-bottom: 4px;
text-transform: uppercase;
}
.modal-field input, .modal-field textarea {
width: 100%;
padding: 8px 10px;
border: 2px solid #dfe1e6;
border-radius: 6px;
font-size: 14px;
outline: none;
transition: border-color 0.15s;
}
.modal-field input:focus, .modal-field textarea:focus {
border-color: #6B7BF7;
}
.modal-field textarea { resize: vertical; min-height: 60px; }
.label-selector {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.label-option {
padding: 4px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.15s;
}
.label-option.selected {
border-color: #333;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
.btn-cancel {
background: #f4f5f7;
color: #5e6c84;
}
.btn-cancel:hover { background: #ebecf0; }
.btn-save {
background: #6B7BF7;
color: white;
}
.btn-save:hover { background: #5a6ae6; }
.btn-danger {
background: #eb5a46;
color: white;
}
.btn-danger:hover { background: #cf513d; }
/* Column menu dropdown */
.column-dropdown {
position: absolute;
top: 100%;
right: 0;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
z-index: 200;
min-width: 160px;
padding: 4px;
}
.dropdown-item {
padding: 8px 12px;
font-size: 13px;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.dropdown-item:hover { background: #f4f5f7; }
.dropdown-item.danger { color: #eb5a46; }
</style>
</head>
<body>
<div id="board-header">
<div class="header-left">
<h1>Kanban Board</h1>
</div>
<div class="header-controls">
<input type="text" id="search-input" placeholder="Search cards…">
<div id="filter-bar"></div>
<button id="add-column-btn" class="btn btn-primary">+ Add Column</button>
</div>
</div>
<div id="board-container">
<div id="board"></div>
</div>
<div id="toast-container"></div>
<div id="modal-overlay" style="display:none">
<div id="modal-content"></div>
</div>
<script>
'use strict';
// ── const: STORAGE_KEY ──
// ============================================================
// CONSTANTS & CONFIGURATION
// ============================================================
const STORAGE_KEY = 'kanban-board-state';
// ── const: TOAST_DURATION ──
const TOAST_DURATION = 4000;
// ── const: UNDO_TIMEOUT ──
const UNDO_TIMEOUT = 5000;
// ── const: DRAG_SCROLL_MARGIN ──
const DRAG_SCROLL_MARGIN = 60;
// ── const: DRAG_SCROLL_SPEED ──
const DRAG_SCROLL_SPEED = 12;
// ── const: CARD_MIN_HEIGHT ──
const CARD_MIN_HEIGHT = 50;
// ── const: LABEL_PALETTE ──
const LABEL_PALETTE = [
{ id: 'bug', name: 'Bug', color: '#eb5a46' },
{ id: 'feature', name: 'Feature', color: '#61bd4f' },
{ id: 'enhancement', name: 'Enhancement', color: '#0079bf' },
{ id: 'design', name: 'Design', color: '#c377e0' },
{ id: 'docs', name: 'Docs', color: '#f2d600' },
{ id: 'urgent', name: 'Urgent', color: '#ff9f1a' },
];
// ── const: DEFAULT_COLUMNS ──
const DEFAULT_COLUMNS = [
{ title: 'Backlog', wipLimit: null },
{ title: 'To Do', wipLimit: null },
{ title: 'In Progress', wipLimit: 3 },
{ title: 'Review', wipLimit: null },
{ title: 'Done', wipLimit: null },
];
// ── const: DEFAULT_CARDS ──
const DEFAULT_CARDS = [
{ titles: ['Research competitors', 'Define user personas'], labels: [['design'], []] },
{ titles: ['Set up CI/CD pipeline', 'Write API docs', 'Create wireframes'], labels: [['docs'], [], ['design']] },
{ titles: ['Implement auth flow', 'Build dashboard UI'], labels: [['feature'], ['feature']] },
{ titles: ['Fix login redirect bug'], labels: [['bug']] },
{ titles: ['Deploy to production'], labels: [] },
];
// ── function: generateId ──
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
// ── function: deepClone ──
function deepClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
// ── function: createDefaultBoard ──
function createDefaultBoard() {
const columns = DEFAULT_COLUMNS.map((colDef, ci) => ({
id: generateId(),
title: colDef.title,
wipLimit: colDef.wipLimit,
cards: [],
}));
for (let ci = 0; ci < columns.length && ci < DEFAULT_CARDS.length; ci++) {
const cardGroup = DEFAULT_CARDS[ci];
for (let i = 0; i < cardGroup.titles.length; i++) {
columns[ci].cards.push({
id: generateId(),
title: cardGroup.titles[i],
description: '',
labelIds: Array.isArray(cardGroup.labels[i]) ? cardGroup.labels[i] : [],
dueDate: '',
});
}
}
return {
columns,
filterLabels: [],
searchQuery: '',
};
}
// ── let: boardState ──
// ============================================================
// STATE MANAGEMENT & PERSISTENCE
// ============================================================
let boardState = null;
// ── let: undoStack ──
let undoStack = [];
// ── function: saveBoard ──
function saveBoard() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(boardState));
} catch (e) { /* quota exceeded — ignore */ }
}
// ── function: loadBoard ──
function loadBoard() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
boardState = JSON.parse(raw);
// Ensure filterLabels and searchQuery exist for older saves
if (!boardState.filterLabels) boardState.filterLabels = [];
if (!boardState.searchQuery) boardState.searchQuery = '';
// Validate card data — ensure labelIds is always an array
if (Array.isArray(boardState.columns)) {
boardState.columns.forEach(col => {
if (Array.isArray(col.cards)) {
col.cards.forEach(card => {
if (!Array.isArray(card.labelIds)) card.labelIds = [];
});
}
});
}
return true;
}
} catch (e) { /* corrupted — ignore */ }
return false;
}
// ── function: pushUndo ──
function pushUndo(action, snapshot) {
undoStack.push({ action, state: deepClone(snapshot), timestamp: Date.now() });
// Keep only last 20 undos
if (undoStack.length > 20) undoStack.shift();
}
// ── function: performUndo ──
function performUndo() {
const entry = undoStack.pop();
if (!entry) return false;
boardState = deepClone(entry.state);
saveBoard();
renderAll();
return true;
}
// ── function: showToast ──
// ============================================================
// TOAST NOTIFICATIONS & UNDO
// ============================================================
function showToast(message, undoFn) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = 'toast';
const msgSpan = document.createElement('span');
msgSpan.textContent = message;
toast.appendChild(msgSpan);
if (undoFn) {
const undoBtn = document.createElement('button');
undoBtn.className = 'toast-undo';
undoBtn.textContent = 'Undo';
undoBtn.addEventListener('click', () => {
undoFn();
toast.remove();
});
toast.appendChild(undoBtn);
}
container.appendChild(toast);
const timer = setTimeout(() => {
if (toast.parentNode) {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s';
setTimeout(() => toast.remove(), 300);
}
}, TOAST_DURATION);
}
// ── function: openModal ──
// ============================================================
// MODAL SYSTEM
// ============================================================
function openModal(html) {
const overlay = document.getElementById('modal-overlay');
const content = document.getElementById('modal-content');
content.innerHTML = html;
overlay.style.display = 'flex';
// Focus first input
const firstInput = content.querySelector('input, textarea');
if (firstInput) setTimeout(() => firstInput.focus(), 50);
}
// ── function: closeModal ──
function closeModal() {
document.getElementById('modal-overlay').style.display = 'none';
}
// ── function: openCardModal ──
function openCardModal(columnId, cardData) {
const isEdit = !!cardData;
const labelsHtml = LABEL_PALETTE.map(l => {
const selected = (cardData && cardData.labelIds.includes(l.id)) ? 'selected' : '';
return `<div class="label-option ${selected}" data-label-id="${l.id}" style="background:${l.color};color:white">${l.name}</div>`;
}).join('');
openModal(`
<div class="modal-title">${isEdit ? 'Edit Card' : 'New Card'}</div>
<div class="modal-field">
<label>Title</label>
<input type="text" id="card-modal-title" value="${escapeHtml(cardData?.title || '')}" />
</div>
<div class="modal-field">
<label>Description</label>
<textarea id="card-modal-desc">${escapeHtml(cardData?.description || '')}</textarea>
</div>
<div class="modal-field">
<label>Labels</label>
<div class="label-selector" id="modal-labels">${labelsHtml}</div>
</div>
<div class="modal-field">
<label>Due Date</label>
<input type="date" id="card-modal-date" value="${cardData?.dueDate || ''}" />
</div>
<div class="modal-actions">
${isEdit ? `<button class="btn btn-danger" id="card-modal-delete">Delete</button>` : ''}
<button class="btn btn-cancel" onclick="closeModal()">Cancel</button>
<button class="btn btn-save" id="card-modal-save">${isEdit ? 'Save' : 'Add Card'}</button>
</div>
`);
// Label toggle handlers
document.querySelectorAll('#modal-labels .label-option').forEach(el => {
el.addEventListener('click', () => el.classList.toggle('selected'));
});
document.getElementById('card-modal-save').addEventListener('click', () => {
const title = document.getElementById('card-modal-title').value.trim();
if (!title) return;
const desc = document.getElementById('card-modal-desc').value.trim();
const date = document.getElementById('card-modal-date').value;
const labelIds = [...document.querySelectorAll('#modal-labels .label-option.selected')]
.map(el => el.dataset.labelId);
if (isEdit) {
pushUndo('edit card', boardState);
const col = boardState.columns.find(c => c.id === columnId);
const card = col.cards.find(c => c.id === cardData.id);
card.title = title;
card.description = desc;
card.dueDate = date;
card.labelIds = labelIds;
} else {
pushUndo('add card', boardState);
const col = boardState.columns.find(c => c.id === columnId);
col.cards.push({
id: generateId(),
title, description: desc, dueDate: date, labelIds,
});
}
saveBoard();
renderAll();
closeModal();
});
if (isEdit) {
document.getElementById('card-modal-delete').addEventListener('click', () => {
pushUndo('delete card', boardState);
const col = boardState.columns.find(c => c.id === columnId);
col.cards = col.cards.filter(c => c.id !== cardData.id);
saveBoard();
renderAll();
closeModal();
showToast('Card deleted', performUndo);
});
}
}
// ── function: escapeHtml ──
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ── function: renderAll ──
// ============================================================
// RENDERING — derives entirely from boardState
// ============================================================
function renderAll() {
renderFilterBar();
renderBoard();
}
// ── function: getFilteredCards ──
function getFilteredCards(cards) {
const query = (boardState.searchQuery || '').toLowerCase().trim();
return cards.filter(card => {
// Label filter: card must have at least one selected label
if (boardState.filterLabels.length > 0) {
const hasLabel = boardState.filterLabels.some(fl => card.labelIds.includes(fl));
if (!hasLabel) return false;
}
// Search filter
if (query && !card.title.toLowerCase().includes(query)) return false;
return true;
});
}
// ── function: renderFilterBar ──
function renderFilterBar() {
const bar = document.getElementById('filter-bar');
bar.innerHTML = '';
LABEL_PALETTE.forEach(label => {
const chip = document.createElement('div');
chip.className = 'filter-chip' + (boardState.filterLabels.includes(label.id) ? ' active' : '');
chip.style.background = label.color;
chip.style.color = 'white';
chip.textContent = label.name;
chip.addEventListener('click', () => {
if (boardState.filterLabels.includes(label.id)) {
boardState.filterLabels = boardState.filterLabels.filter(l => l !== label.id);
} else {
boardState.filterLabels.push(label.id);
}
saveBoard();
renderAll();
});
bar.appendChild(chip);
});
}
// ── function: renderBoard ──
function renderBoard() {
const board = document.getElementById('board');
board.innerHTML = '';
boardState.columns.forEach(col => {
const colEl = createColumnElement(col);
board.appendChild(colEl);
});
}
// ── function: createColumnElement ──
function createColumnElement(col) {
const filteredCards = getFilteredCards(col.cards);
const isWipExceeded = col.wipLimit !== null && col.cards.length > col.wipLimit;
const el = document.createElement('div');
el.className = 'column' + (isWipExceeded ? ' wip-exceeded' : '');
el.dataset.columnId = col.id;
// Header
const header = document.createElement('div');
header.className = 'column-header';
header.innerHTML = `
<div class="column-title-group">
<span class="column-title">${escapeHtml(col.title)}</span>
<span class="card-count">${col.cards.length}</span>
</div>
<button class="column-menu-btn" title="Column options">⋯</button>
`;
// Column drag handle (entire header)
header.addEventListener('mousedown', (e) => {
if (e.target.classList.contains('column-menu-btn')) return;
startColumnDrag(e, col.id);
});
el.appendChild(header);
// Body (cards container)
const body = document.createElement('div');
body.className = 'column-body';
body.dataset.columnId = col.id;
if (filteredCards.length === 0 && col.cards.length > 0) {
// Cards exist but filtered out — show empty state
const emptyMsg = document.createElement('div');
emptyMsg.className = 'empty-drop-zone';
emptyMsg.textContent = 'No matching cards';
body.appendChild(emptyMsg);
} else if (col.cards.length === 0) {
// Truly empty column
const emptyZone = document.createElement('div');
emptyZone.className = 'empty-drop-zone';
emptyZone.textContent = 'Drop cards here';
body.appendChild(emptyZone);
}
filteredCards.forEach(card => {
body.appendChild(createCardElement(card, col.id));
});
el.appendChild(body);
// Footer with add button
const footer = document.createElement('div');
footer.className = 'card-footer';
const addBtn = document.createElement('button');
addBtn.className = 'add-card-btn';
addBtn.textContent = '+ Add a card';
addBtn.addEventListener('click', () => openCardModal(col.id, null));
footer.appendChild(addBtn);
el.appendChild(footer);
// Column menu
const menuBtn = header.querySelector('.column-menu-btn');
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
closeAllDropdowns();
showColumnMenu(el, col.id);
});
return el;
}
// ── function: createCardElement ──
function createCardElement(card, columnId) {
const el = document.createElement('div');
el.className = 'card';
el.dataset.cardId = card.id;
el.dataset.columnId = columnId;
// Labels
let labelsHtml = '';
if (card.labelIds && card.labelIds.length > 0) {
const chips = card.labelIds.map(lid => {
const label = LABEL_PALETTE.find(l => l.id === lid);
return label ? `<span class="label-chip" style="background:${label.color};color:white">${label.name}</span>` : '';
}).filter(Boolean).join('');
if (chips) labelsHtml = `<div class="card-labels">${chips}</div>`;
}
// Due date
let dueDateHtml = '';
if (card.dueDate) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const due = new Date(card.dueDate + 'T00:00:00');
const isOverdue = due < today;
dueDateHtml = `<span class="due-date ${isOverdue ? 'overdue' : ''}">📅 ${card.dueDate}</span>`;
}
el.innerHTML = `
<div class="card-actions">
<button class="card-action-btn card-edit-btn" title="Edit">✏️</button>
<button class="card-action-btn card-delete-btn" title="Delete">🗑️</button>
</div>
${labelsHtml}
<div class="card-title">${escapeHtml(card.title)}</div>
${card.description ? `<div class="card-description">${escapeHtml(card.description)}</div>` : ''}
<div class="card-meta">
${dueDateHtml}
</div>
`;
// Card drag start
el.addEventListener('mousedown', (e) => {
if (e.target.closest('.card-actions')) return;
e.preventDefault();
startCardDrag(e, card.id, columnId);
});
// Edit button
el.querySelector('.card-edit-btn').addEventListener('click', (e) => {
e.stopPropagation();
const col = boardState.columns.find(c => c.id === columnId);
const cardData = col.cards.find(c => c.id === card.id);
openCardModal(columnId, cardData);
});
// Delete button
el.querySelector('.card-delete-btn').addEventListener('click', (e) => {
e.stopPropagation();
pushUndo('delete card', boardState);
const col = boardState.columns.find(c => c.id === columnId);
col.cards = col.cards.filter(c => c.id !== card.id);
saveBoard();
renderAll();
showToast('Card deleted', performUndo);
});
// Inline title editing on double-click
el.querySelector('.card-title').addEventListener('dblclick', (e) => {
e.stopPropagation();
startInlineEdit(el, card, columnId, 'title');
});
return el;
}
// ── function: startInlineEdit ──
function startInlineEdit(cardEl, card, columnId, field) {
const titleEl = cardEl.querySelector('.card-title');
const input = document.createElement('input');
input.className = 'card-title-input';
input.value = card.title;
titleEl.replaceWith(input);
input.focus();
input.select();
const commit = () => {
const newTitle = input.value.trim();
if (newTitle && newTitle !== card.title) {
pushUndo('edit card', boardState);
const col = boardState.columns.find(c => c.id === columnId);
const c = col.cards.find(cd => cd.id === card.id);
c.title = newTitle;
saveBoard();
}
renderAll();
};
input.addEventListener('blur', commit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') { input.value = card.title; input.blur(); }
});
}
// ── function: showColumnMenu ──
function showColumnMenu(columnEl, columnId) {
closeAllDropdowns();
const col = boardState.columns.find(c => c.id === columnId);
const menuBtn = columnEl.querySelector('.column-menu-btn');
const dropdown = document.createElement('div');
dropdown.className = 'column-dropdown';
// Rename item
const renameItem = document.createElement('div');
renameItem.className = 'dropdown-item';
renameItem.textContent = '✏️ Rename';
renameItem.addEventListener('click', () => {
closeAllDropdowns();
const titleSpan = columnEl.querySelector('.column-title');
const input = document.createElement('input');
input.className = 'column-title-input';
input.value = col.title;
titleSpan.replaceWith(input);
input.focus();
input.select();
const commit = () => {
const newTitle = input.value.trim() || col.title;
if (newTitle !== col.title) {
pushUndo('rename column', boardState);
col.title = newTitle;
saveBoard();
}
renderAll();
};
input.addEventListener('blur', commit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') { input.value = col.title; input.blur(); }
});
});
// WIP limit item
const wipItem = document.createElement('div');
wipItem.className = 'dropdown-item';
wipItem.textContent = col.wipLimit !== null ? `WIP Limit: ${col.wipLimit}` : 'Set WIP Limit…';
wipItem.addEventListener('click', () => {
closeAllDropdowns();
const val = prompt(`Set WIP limit for "${col.title}" (leave empty to remove):`, col.wipLimit !== null ? String(col.wipLimit) : '');
if (val === null) return; // cancelled
pushUndo('change wip', boardState);
col.wipLimit = val.trim() === '' ? null : parseInt(val, 10) || null;
saveBoard();
renderAll();
});
// Delete item
const deleteItem = document.createElement('div');
deleteItem.className = 'dropdown-item danger';
deleteItem.textContent = '🗑️ Delete Column';
if (boardState.columns.length <= 1) {
deleteItem.style.opacity = '0.4';
deleteItem.style.pointerEvents = 'none';
} else {
deleteItem.addEventListener('click', () => {
closeAllDropdowns();
pushUndo('delete column', boardState);
const cardCount = col.cards.length;
boardState.columns = boardState.columns.filter(c => c.id !== columnId);
saveBoard();
renderAll();
showToast(`Column "${col.title}" deleted${cardCount ? ` (${cardCount} cards)` : ''}`, performUndo);
});
}
dropdown.appendChild(renameItem);
dropdown.appendChild(wipItem);
dropdown.appendChild(deleteItem);
const header = columnEl.querySelector('.column-header');
header.style.position = 'relative';
header.appendChild(dropdown);
// Close on outside click
setTimeout(() => {
document.addEventListener('click', function closeMenu(e) {
if (!dropdown.contains(e.target)) {
document.removeEventListener('click', closeMenu);
dropdown.remove();
}
});
}, 0);
}
// ── function: closeAllDropdowns ──
function closeAllDropdowns() {
document.querySelectorAll('.column-dropdown').forEach(d => d.remove());
}
// ── let: cardDrag ──
// ============================================================
// DRAG & DROP — Card Dragging (Pointer Events)
// ============================================================
let cardDrag = null;
// ── function: startCardDrag ──
// { cardId, sourceColumnId, ghostEl, placeholderEl, startX, startY }
function startCardDrag(e, cardId, columnId) {
closeAllDropdowns();
const cardEl = e.target.closest('.card');
if (!cardEl) return;
const col = boardState.columns.find(c => c.id === columnId);
const card = col.cards.find(c => c.id === cardId);
if (!card || !col) return;
// Create ghost element (follows cursor)
const rect = cardEl.getBoundingClientRect();
const ghost = document.createElement('div');
ghost.className = 'drag-ghost';
ghost.innerHTML = cardEl.innerHTML;
ghost.style.width = rect.width + 'px';
ghost.style.left = rect.left + 'px';
ghost.style.top = rect.top + 'px';
// Hide original
cardEl.classList.add('dragging');
document.body.appendChild(ghost);
// Create placeholder in the column body
const placeholder = document.createElement('div');
placeholder.className = 'drop-placeholder';
placeholder.dataset.placeholder = 'true';
// Insert placeholder where card was
const colBody = cardEl.parentElement;
colBody.insertBefore(placeholder, cardEl);
cardDrag = {
cardId,
sourceColumnId: columnId,
ghostEl: ghost,
placeholderEl: placeholder,
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top,
currentColumnId: columnId,
scrollRAF: null,
};
document.addEventListener('mousemove', onCardDragMove);
document.addEventListener('mouseup', onCardDragEnd);
document.addEventListener('keydown', onCardDragKey);
}
// ── function: onCardDragKey ──
function onCardDragKey(e) {
if (e.key === 'Escape') {
cancelCardDrag();
e.preventDefault();
}
}
// ── function: cancelCardDrag ──
function cancelCardDrag() {
if (!cardDrag) return;
const { cardId, sourceColumnId, ghostEl, placeholderEl } = cardDrag;
// Remove ghost and placeholder
ghostEl.remove();
placeholderEl.remove();
// Restore original card visibility
const cardEl = document.querySelector(`.card[data-card-id="${cardId}"]`);
if (cardEl) cardEl.classList.remove('dragging');
// Re-render to restore exact state
renderAll();
cleanupCardDragListeners();
cardDrag = null;
}
// ── function: onCardDragMove ──
function onCardDragMove(e) {
if (!cardDrag) return;
const { ghostEl, placeholderEl, offsetX, offsetY } = cardDrag;
// Move ghost to follow cursor
ghostEl.style.left = (e.clientX - offsetX) + 'px';
ghostEl.style.top = (e.clientY - offsetY) + 'px';
// Auto-scroll
handleAutoScroll(e);
// Find drop target column and position
const dropInfo = findCardDropTarget(e.clientX, e.clientY);
if (!dropInfo) {
cardDrag = null;
return;
}
const { columnId, insertIndex } = dropInfo;
cardDrag.currentColumnId = columnId;
// Move placeholder to the right position
const targetColBody = document.querySelector(`.column-body[data-column-id="${columnId}"]`);
if (!targetColBody) return;
// Remove any empty-drop-zone from the way
const existingPlaceholder = targetColBody.querySelector('.drop-placeholder');
if (existingPlaceholder && existingPlaceholder !== placeholderEl) {
existingPlaceholder.remove();
}
// Find visible cards in this column body (excluding placeholders and dragged card)
const visibleCards = [...targetColBody.querySelectorAll(':scope > .card:not(.dragging)')];
let insertBefore;
if (insertIndex >= visibleCards.length) {
// Insert at end — before any empty-drop-zone or add to end
insertBefore = targetColBody.querySelector('.empty-drop-zone');
if (!insertBefore) insertBefore = null;
} else {
insertBefore = visibleCards[insertIndex];
}
targetColBody.insertBefore(placeholderEl, insertBefore);
// Remove empty drop zone if present
const emptyZone = targetColBody.querySelector('.empty-drop-zone');
if (emptyZone) emptyZone.remove();
}
// ── function: onCardDragEnd ──
function onCardDragEnd(e) {
if (!cardDrag) return;
const { cardId, sourceColumnId } = cardDrag;
const dropInfo = findCardDropTarget(e.clientX, e.clientY);
// Remove ghost and placeholder
cardDrag.ghostEl.remove();
cardDrag.placeholderEl.remove();
cleanupCardDragListeners();
if (dropInfo) {
const { columnId: targetColumnId, insertIndex } = dropInfo;
pushUndo('move card', boardState);
// Find and remove the card from source
const srcCol = boardState.columns.find(c => c.id === sourceColumnId);
const cardIdx = srcCol.cards.findIndex(c => c.id === cardId);
if (cardIdx === -1) { cardDrag = null; renderAll(); return; }
const [movedCard] = srcCol.cards.splice(cardIdx, 1);
// Insert into target at the right position among ALL cards (not just filtered)
const tgtCol = boardState.columns.find(c => c.id === targetColumnId);
// We need to map insertIndex from visible/filtered cards to actual card index
const allCards = tgtCol.cards;
const filteredCards = getFilteredCards(allCards);
let actualInsertIndex;
if (insertIndex >= filteredCards.length) {
actualInsertIndex = allCards.length;
} else {
// Find the target card in the full list
const targetCardId = filteredCards[insertIndex].id;
actualInsertIndex = allCards.findIndex(c => c.id === targetCardId);
if (actualInsertIndex === -1) actualInsertIndex = allCards.length;
}
tgtCol.cards.splice(actualInsertIndex, 0, movedCard);
saveBoard();
}
cardDrag = null;
renderAll();
}
// ── function: cleanupCardDragListeners ──
function cleanupCardDragListeners() {
document.removeEventListener('mousemove', onCardDragMove);
document.removeEventListener('mouseup', onCardDragEnd);
document.removeEventListener('keydown', onCardDragKey);
if (cardDrag?.scrollRAF) cancelAnimationFrame(cardDrag.scrollRAF);
}
// ── function: findCardDropTarget ──
function findCardDropTarget(clientX, clientY) {
// Find which column body the cursor is over
const columns = document.querySelectorAll('.column-body');
let targetColBody = null;
let targetColumnId = null;
for (const colBody of columns) {
const rect = colBody.getBoundingClientRect();
if (clientX >= rect.left && clientX <= rect.right &&
clientY >= rect.top && clientY <= rect.bottom) {
targetColBody = colBody;
targetColumnId = colBody.dataset.columnId;
break;
}
}
// Also check if cursor is over an empty column (the whole column element)
if (!targetColBody) {
const columnsFull = document.querySelectorAll('.column');
for (const colEl of columnsFull) {
const rect = colEl.getBoundingClientRect();
if (clientX >= rect.left && clientX <= rect.right &&
clientY >= rect.top && clientY <= rect.bottom) {
targetColBody = colEl.querySelector('.column-body');
targetColumnId = colEl.dataset.columnId;
break;
}
}
}
if (!targetColBody || !targetColumnId) return null;
// Find visible cards in this column (excluding dragged card and placeholders)
const visibleCards = [...targetColBody.querySelectorAll(':scope > .card:not(.dragging)')];
if (visibleCards.length === 0) {
return { columnId: targetColumnId, insertIndex: 0 };
}
// Find the closest card to drop before/after
let bestIndex = visibleCards.length; // default: append at end
for (let i = 0; i < visibleCards.length; i++) {
const rect = visibleCards[i].getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (clientY < midY) {
bestIndex = i;
break;
}
}
return { columnId: targetColumnId, insertIndex: bestIndex };
}
// ── function: handleAutoScroll ──
function handleAutoScroll(e) {
const { clientX, clientY } = e;
const margin = DRAG_SCROLL_MARGIN;
const speed = DRAG_SCROLL_SPEED;
// Horizontal board scroll
const boardContainer = document.getElementById('board-container');
if (clientX < margin) {
boardContainer.scrollBy(speed * -1, 0);
} else if (clientX > window.innerWidth - margin) {
boardContainer.scrollBy(speed, 0);
}
// Vertical column scroll — find the column body under cursor
const columns = document.querySelectorAll('.column-body');
for (const colBody of columns) {
const rect = colBody.getBoundingClientRect();
if (clientX >= rect.left && clientX <= rect.right &&
clientY >= rect.top && clientY <= rect.bottom) {
if (clientY < rect.top + margin) {
colBody.scrollTop -= speed;
} else if (clientY > rect.bottom - margin) {
colBody.scrollTop += speed;
}
break;
}
}
}
// ── let: columnDrag ──
// ============================================================
// DRAG & DROP — Column Reordering
// ============================================================
let columnDrag = null;
// ── function: startColumnDrag ──
function startColumnDrag(e, columnId) {
if (e.button !== 0) return; // Only left click
const colEl = e.target.closest('.column');
if (!colEl) return;
const rect = colEl.getBoundingClientRect();
// Create ghost
const ghost = document.createElement('div');
ghost.className = 'column-ghost';
ghost.innerHTML = colEl.innerHTML;
ghost.style.width = rect.width + 'px';
ghost.style.height = Math.min(rect.height, 300) + 'px';
ghost.style.left = (e.clientX - rect.width / 2) + 'px';
ghost.style.top = (e.clientY - 40) + 'px';
// Append ghost to body so it's in the DOM
document.body.appendChild(ghost);
// Create placeholder in board
const placeholder = document.createElement('div');
placeholder.className = 'column-placeholder';
colEl.parentNode.insertBefore(placeholder, colEl);
colEl.style.opacity = '0.3';
columnDrag = {
columnId,
ghostEl: ghost,
placeholderEl: placeholder,
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top,
};
document.addEventListener('mousemove', onColumnDragMove);
document.addEventListener('mouseup', onColumnDragEnd);
document.addEventListener('keydown', onColumnDragKey);
}
// ── function: onColumnDragKey ──
function onColumnDragKey(e) {
if (e.key === 'Escape') {
cancelColumnDrag();
e.preventDefault();
}
}
// ── function: cancelColumnDrag ──
function cancelColumnDrag() {
if (!columnDrag) return;
columnDrag.ghostEl.remove();
columnDrag.placeholderEl.remove();
// Restore all columns
document.querySelectorAll('.column').forEach(el => el.style.opacity = '');
cleanupColumnDragListeners();
columnDrag = null;
}
// ── function: onColumnDragMove ──
function onColumnDragMove(e) {
if (!columnDrag) return;
const { ghostEl, placeholderEl } = columnDrag;
// Move ghost to follow cursor
ghostEl.style.left = (e.clientX - columnDrag.offsetX) + 'px';
ghostEl.style.top = (e.clientY - columnDrag.offsetY) + 'px';
// Find which visible column to insert before/after
const board = document.getElementById('board');
const allColumns = [...board.querySelectorAll(':scope > .column')];
const filteredVisible = allColumns.filter(c => c.style.opacity !== '0.3');
let bestIndex = filteredVisible.length;
for (let i = 0; i < filteredVisible.length; i++) {
const rect = filteredVisible[i].getBoundingClientRect();
const midX = rect.left + rect.width / 2;
if (e.clientX < midX) {
bestIndex = i;
break;
}
}
// Move placeholder to correct position among non-dragged columns
board.insertBefore(placeholderEl, filteredVisible[bestIndex] || null);
}
// ── function: onColumnDragEnd ──
function onColumnDragEnd(e) {
if (!columnDrag) return;
const { columnId } = columnDrag;
// Calculate target index BEFORE removing elements from DOM
const board = document.getElementById('board');
const allChildren = [...board.children];
// Count non-dragged columns before the placeholder to get insert position
let insertIndex = 0;
for (let i = 0; i < allChildren.length; i++) {
const child = allChildren[i];
if (child.classList.contains('column-placeholder')) break;
if (child.classList.contains('column') && child.style.opacity !== '0.3') {
insertIndex++;
}
}
// Remove ghost and placeholder
columnDrag.ghostEl.remove();
columnDrag.placeholderEl.remove();
cleanupColumnDragListeners();
// Remove dragged column from state
const draggedColIdx = boardState.columns.findIndex(c => c.id === columnId);
const draggedCol = boardState.columns.splice(draggedColIdx, 1)[0];
// insertIndex represents position among non-dragged columns (post-removal indices)
pushUndo('reorder columns', boardState);
boardState.columns.splice(insertIndex, 0, draggedCol);
saveBoard();
columnDrag = null;
renderAll();
}
// ── function: cleanupColumnDragListeners ──
function cleanupColumnDragListeners() {
document.removeEventListener('mousemove', onColumnDragMove);
document.removeEventListener('mouseup', onColumnDragEnd);
document.removeEventListener('keydown', onColumnDragKey);
}
// ── main ──
// ============================================================
// MAIN — Initialization & Event Wiring
// ============================================================
(function init() {
// Load or create board state
if (!loadBoard()) {
boardState = createDefaultBoard();
saveBoard();
}
renderAll();
// Search input
const searchInput = document.getElementById('search-input');
let searchTimeout;
searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
boardState.searchQuery = searchInput.value;
saveBoard();
renderAll();
}, 150);
});
// Restore search query in input
if (boardState.searchQuery) {
searchInput.value = boardState.searchQuery;
}
// Add column button
document.getElementById('add-column-btn').addEventListener('click', () => {
pushUndo('add column', boardState);
const title = prompt('Column name:');
if (!title || !title.trim()) return;
boardState.columns.push({
id: generateId(),
title: title.trim(),
wipLimit: null,
cards: [],
});
saveBoard();
renderAll();
});
// Close modal on overlay click
document.getElementById('modal-overlay').addEventListener('click', (e) => {
if (e.target.id === 'modal-overlay') closeModal();
});
// Close modal on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && document.getElementById('modal-overlay').style.display !== 'none') {
closeModal();
}
});
console.log('Kanban Board initialized with', boardState.columns.length, 'columns');
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"composer","sessionId":"2b978634-796b-41a2-b6a8-9862e5a2be30","tokensIn":2883143,"tokensOut":56857,"tokensTotal":2940000,"turns":69,"toolCalls":68,"failedToolCalls":1,"timestamp":"2026-07-30T02:58:11.162Z"} -->