Kanban
Ornith 1.5 9B GGUF · 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 38,763 bytes · SHA-256 dccab97ffd6b
<!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>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: #eef0f4;
color: #1f2430;
-webkit-font-smoothing: antialiased;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
padding: 0 20px;
background: #fff;
border-bottom: 1px solid #e3e7ee;
position: sticky;
top: 0;
z-index: 50;
}
.topbar-left { display: flex; align-items: center; }
.topbar-right { display: flex; align-items: center; gap: 12px; }
#undo-btn {
border: 1px solid #e3e7ee;
background: #fff;
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
color: #6b7280;
font-weight: 600;
}
#undo-btn:hover { background: #f7f8fb; }
.topbar h1 { font-size: 20px; font-weight: 800; letter-spacing: -0.5px; margin: 0; }
#search-box { position: relative; margin-right: 12px; }
#search {
padding: 8px 12px;
border: 1px solid #e3e7ee;
border-radius: 8px;
width: 220px;
font-size: 13px;
outline: none;
background: #f7f8fb;
}
#search:focus { border-color: #3b6cf6; background: #fff; }
#filter-bar { display: inline-flex; gap: 6px; align-items: center; flex-wrap: wrap; max-width: 460px; }
.filter-chip {
border: none;
border-radius: 20px;
padding: 5px 12px;
font-size: 12px;
cursor: pointer;
font-weight: 600;
background: transparent;
color: #6b7280;
}
.filter-chip.active { color: #fff; }
#reset-btn {
border: 1px solid #e3e7ee;
background: #fff;
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
font-size: 12px;
color: #6b7280;
}
#reset-btn:hover { background: #f7f8fb; }
.board {
display: flex;
gap: 16px;
align-items: flex-start;
overflow-x: auto;
padding: 20px 16px;
background: #eef0f4;
min-height: calc(100vh - 56px);
}
.column {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(30,40,60,0.06);
border: 1px solid #e3e7ee;
width: 320px;
display: flex;
flex-direction: column;
transition: box-shadow 160ms ease, transform 160ms ease;
}
.column-over { box-shadow: 0 8px 24px rgba(30,40,60,0.16); transform: scale(1.01); }
.column-dragging { opacity: 0.4; }
.col-header {
display: flex;
align-items: center;
gap: 8px;
padding: 0 14px;
height: 56px;
border-bottom: 1px solid #e3e7ee;
flex-shrink: 0;
}
.col-title {
border: none;
background: transparent;
font-size: 14px;
font-weight: 700;
color: #1f2430;
outline: none;
width: 100%;
padding: 0;
}
.col-title:focus { border-bottom: 2px solid #3b6cf6; }
.col-count {
background: #f1f3f8;
border-radius: 20px;
padding: 1px 9px;
font-size: 12px;
font-weight: 700;
color: #6b7280;
min-width: 24px;
text-align: center;
}
.col-wip {
font-size: 11px;
font-weight: 700;
color: #9aa3b2;
border-radius: 20px;
}
.col-wip-over { color: #ef4444; }
.col-drag { cursor: grab; color: #c3c9d4; font-size: 16px; }
.col-drag:active { cursor: grabbing; }
.col-menu {
border: 1px solid #e3e7ee;
background: #fff;
border-radius: 8px;
width: 28px;
height: 28px;
cursor: pointer;
font-size: 16px;
color: #6b7280;
margin-left: auto;
display: flex;
align-items: center;
justify-content: center;
}
.col-menu:hover { background: #f7f8fb; }
.col-body {
flex: 1;
overflow-y: auto;
padding: 6px 12px 18px;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 60px;
}
.card {
background: #fff;
border: 1px solid #e3e7ee;
border-radius: 10px;
padding: 12px;
cursor: grab;
box-shadow: 0 1px 3px rgba(30,40,60,0.05);
animation: cardIn 220ms ease both;
transition: box-shadow 140ms ease, transform 140ms ease;
}
.card:hover { box-shadow: 0 4px 12px rgba(30,40,60,0.10); transform: translateY(-1px); }
@keyframes cardIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.col-settle .card { animation: settle 320ms ease both; }
@keyframes settle { 0% { transform: translateY(-14px); opacity: 0.6; } 60% { transform: translateY(3px); } 100% { transform: none; opacity: 1; } }
.card-hidden { display: none; }
.card-title-row { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.card-title { font-size: 13px; font-weight: 600; color: #1f2430; cursor: text; flex: 1; line-height: 1.4; }
.card-del {
border: none;
background: transparent;
border-radius: 6px;
width: 20px;
height: 20px;
cursor: pointer;
font-size: 14px;
color: #9aa3b2;
display: flex;
align-items: center;
justify-content: center;
}
.card-del:hover { background: #fdebeb; color: #ef4444; }
.card-title-input {
flex: 1;
padding: 2px 6px;
border: 1px solid #3b6cf6;
border-radius: 5px;
font-size: 13px;
font-weight: 600;
outline: none;
}
.card-desc { font-size: 12px; color: #6b7280; margin-top: 6px; line-height: 1.4; }
.card-due {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
margin-top: 8px;
color: #6b7280;
}
.card-labels { display: flex; gap: 5px; margin-top: 8px; flex-wrap: wrap; }
.label-chip {
border: none;
border-radius: 20px;
padding: 2px 9px;
font-size: 11px;
color: #fff;
cursor: pointer;
font-weight: 600;
opacity: 0.95;
}
.label-chip:hover { opacity: 1; filter: brightness(1.08); }
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 26px 10px;
color: #9aa3b2;
text-align: center;
border: 2px dashed #e3e7ee;
border-radius: 10px;
}
.placeholder {
height: 74px;
border-radius: 8px;
background: #e8efff;
border: 2px dashed #3b6cf6;
opacity: 0.55;
transition: height 120ms ease;
}
.filter-note { font-size: 11px; color: #9aa3b2; text-align: right; padding-top: 2px; }
.col-menu-popup {
position: fixed;
min-width: 180px;
background: #fff;
border: 1px solid #e3e7ee;
border-radius: 10px;
box-shadow: 0 12px 30px rgba(30,40,60,0.22);
padding: 6px;
z-index: 2000;
}
.col-menu-popup button {
display: block;
width: 100%;
text-align: left;
padding: 9px 12px;
border: none;
background: transparent;
border-radius: 7px;
cursor: pointer;
color: #1f2430;
font-size: 13px;
}
.col-menu-popup button:hover { background: #f7f8fb; }
#toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(20px);
opacity: 0;
transition: all 260ms ease;
background: #1f2430;
color: #fff;
padding: 12px 18px;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,0.28);
z-index: 5000;
display: flex;
gap: 10px;
align-items: center;
}
</style>
</head>
<body>
<header class="topbar">
<div class="topbar-left">
<button id="undo-btn" title="Undo last action" style="display:none">↶ Undo</button>
<h1>Kanban</h1>
</div>
<div class="topbar-right">
<div id="search-box">
<input id="search" type="text" placeholder="Search cards by title…" style="padding:8px 12px;border:1px solid #e3e7ee;border-radius:8px;width:220px;font-size:13px;outline:none;background:#f7f8fb">
<span style="position:absolute;right:10px;top:50%;transform:translateY(-50%);color:#9aa3b2">🔍</span>
</div>
<div id="filter-bar"></div>
<button id="reset-btn" title="Reset to demo board" style="border:1px solid #e3e7ee;background:#fff;border-radius:8px;padding:8px 12px;cursor:pointer;font-size:12px;color:#6b7280">Reset demo</button>
</div>
</header>
<div class="board"></div>
<button id="add-col-btn" style="display:none;border:1px dashed #c3c9d4;background:#fff;border-radius:10px;padding:12px;cursor:pointer;color:#6b7280;margin:0 16px 16px">✩ Add column</button>
<script>
/* ============================================================
STATE / MODEL
Single source of truth. Never reconstruct state by reading
the DOM. Everything (render, hit-test, persistence) derives
from this object.
============================================================ */
const CONSTANTS = {
colors: {
page: '#eef0f4', header: '#ffffff', column: '#ffffff', card: '#ffffff',
text: '#1f2430', muted: '#6b7280', border: '#e3e7ee', accent: '#3b6cf6',
accentSoft: '#e8efff', hover: '#f7f8fb',
shadow: 'rgba(30,40,60,0.10)', shadowStrong: 'rgba(30,40,60,0.22)'
},
labelPalette: [
{ id: 'lab-blue', name: 'Bug', color: '#60a5fa' },
{ id: 'lab-green', name: 'Feature', color: '#34d399' },
{ id: 'lab-yellow', name: 'Urgent', color: '#fbbf24' },
{ id: 'lab-purple', name: 'Design', color: '#a78bfa' },
{ id: 'lab-pink', name: 'Research', color: '#f472b6' },
{ id: 'lab-gray', name: 'Docs', color: '#9ca3af' }
],
geometry: { cardW: 300, cardH: 74, cardMargin: 10, columnW: 320, columnMargin: 16, headerH: 56 },
drag: {
edgeMargin: 48, scrollSpeed: 9, moveThreshold: 4, dropSnap: 2,
tilt: 6, liftY: 6, zIndex: 1000
},
historyMax: 60
};
let state = { columns: [], idSeq: 400, history: [] };
let uid = () => 'id' + (state.idSeq++);
/* ============================================================
PERSISTENCE
============================================================ */
const STORAGE_KEY = 'kanban.board.v1';
function save() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) {} }
function load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) state = normalize(JSON.parse(raw));
} catch (e) {}
if (!state.columns.length) seedDemo();
}
/* ============================================================
ID HELPERS / MODEL MUTATIONS
============================================================ */
function newId(prefix) { state.idSeq++; return prefix + '_' + state.idSeq; }
function pushHistory() {
state.history.push(JSON.stringify(state));
if (state.history.length > CONSTANTS.historyMax) state.history.shift();
}
function restoreSnapshot() {
const prev = state.history.pop();
if (prev === undefined) return;
state = JSON.parse(prev);
}
function addColumn(title) {
pushHistory();
state.columns.push({ id: newId('col'), title: title || 'New Column', cards: [] });
save();
}
function deleteColumn(colId) {
pushHistory();
state.columns = state.columns.filter(c => c.id !== colId);
save();
}
function renameColumn(colId, title) {
const c = state.columns.find(c => c.id === colId);
if (c && c.title !== title) { pushHistory(); c.title = title; save(); }
}
function setWip(colId, wip) {
const c = state.columns.find(c => c.id === colId);
if (c && (c.wip !== wip)) { pushHistory(); c.wip = wip || null; save(); }
}
function addCard(colId, card) {
pushHistory();
const c = state.columns.find(c => c.id === colId);
if (c) { c.cards.push(card || { id: newId('card'), title: 'New Card', description: '', labels: [], dueDate: null }); save(); }
}
function deleteCard(colId, cardId) {
pushHistory();
const c = state.columns.find(c => c.id === colId);
if (c) { c.cards = c.cards.filter(x => x.id !== cardId); save(); }
}
function renameCard(colId, cardId, title) {
const c = state.columns.find(c => c.id === colId);
const card = c && c.cards.find(x => x.id === cardId);
if (card && card.title !== title) { pushHistory(); card.title = title; save(); }
}
/* ============================================================
DEMO SEED / NORMALIZATION
============================================================ */
function seedDemo() {
state.idSeq = 500;
const mk = (title, body, labels, due) => ({ id: newId('card'), title, description: body, labels, dueDate: due || null });
state.columns = [
{ id: newId('col'), title: 'Backlog', cards: [
mk('Investigate API latency spikes', 'Check the metrics dashboard for anomalies', ['lab-yellow'], null),
mk('Write documentation for v2 endpoints', '', ['lab-gray'], '2024-06-15')
], wip: 5 },
{ id: newId('col'), title: 'To Do', cards: [
mk('Design onboarding flow', '', ['lab-purple']),
mk('Set up CI pipeline', '', ['lab-green']),
mk('Add dark mode support', '', ['lab-purple'])
]},
{ id: newId('col'), title: 'In Progress', cards: [
mk('Implement search indexing', 'Use a vector store for semantic search', ['lab-blue']),
mk('Refactor auth module', 'Split session and token logic', ['lab-blue'])
]},
{ id: newId('col'), title: 'Review', cards: [
mk('Pull request: #142', 'Awaiting maintainer review', ['lab-yellow']),
mk('QA sign-off for release', '', ['lab-green'])
]},
{ id: newId('col'), title: 'Done', cards: [
mk('Project scaffolding', 'Repo, branch strategy, conventions', ['lab-green'])
]}
];
save();
}
function normalize(data) {
const seq = (data.idSeq) || 400;
const cols = (data.columns || []).map(c => ({
id: c.id || newId('col'),
title: c.title || 'Column',
wip: c.wip || null,
cards: (c.cards || []).map(card => ({
id: card.id || newId('card'),
title: card.title || 'Untitled',
description: card.description || '',
labels: card.labels || [],
dueDate: card.dueDate || null
}))
}));
return { columns: cols, idSeq: seq, history: [] };
}
/* ============================================================
FILTER STATE (view-only)
============================================================ */
let filter = { search: '', labelIds: [] };
function cardMatches(card) {
if (filter.search) {
const q = filter.search.toLowerCase();
const hay = (card.title + ' ' + card.description).toLowerCase();
if (!hay.includes(q)) return false;
}
if (filter.labelIds.length && !filter.labelIds.some(id => card.labels.includes(id))) return false;
return true;
}
/* ============================================================
VIEW HELPERS
============================================================ */
function labelById(id) { return CONSTANTS.labelPalette.find(l => l.id === id); }
function colEl(id) { return document.querySelector('[data-col="' + id + '"]'); }
function cardEl(id) { return document.querySelector('[data-card="' + id + '"]'); }
function wipOf(colId) { const c = state.columns.find(c => c.id === colId); return c ? c.wip : null; }
function isOverWip(colId) { const c = state.columns.find(c => c.id === colId); const w = c ? c.wip : null; return w != null && c.cards.length > w; }
/* ============================================================
RENDERING
============================================================ */
function render() {
const board = document.querySelector('.board');
board.innerHTML = '';
state.columns.forEach(col => board.appendChild(renderColumn(col.id)));
renderHeader();
save();
}
function renderColumn(colId) {
const col = state.columns.find(c => c.id === colId);
if (!col) return null;
const wip = wipOf(colId);
const over = wip != null && col.cards.length > wip;
const el = document.createElement('section');
el.className = 'column' + (over ? ' column-over' : '') + (colId === drag && drag.type === 'column' ? ' column-dragging' : '');
el.dataset.col = colId;
el.style.width = CONSTANTS.geometry.columnW + 'px';
const header = document.createElement('header');
header.className = 'col-header';
header.style.height = CONSTANTS.geometry.headerH + 'px';
const title = document.createElement('input');
title.className = 'col-title';
title.value = col.title;
title.spellcheck = false;
title.style.flex = '1';
title.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { commitColEdit(colId); title.remove(); }
else if (e.key === 'Escape') { title.remove(); }
});
title.addEventListener('blur', () => { title.remove(); });
const count = document.createElement('span');
count.className = 'col-count';
count.textContent = col.cards.length;
const wipBadge = document.createElement('span');
wipBadge.className = 'col-wip' + (over ? ' col-wip-over' : '');
wipBadge.textContent = wip != null ? '\u221e' : '';
wipBadge.title = 'WIP limit';
const dragHandle = document.createElement('span');
dragHandle.className = 'col-drag';
dragHandle.textContent = '\u2045';
dragHandle.setAttribute('aria-label', 'Drag to reorder column');
header.style.display = 'flex';
header.style.alignItems = 'center';
header.style.gap = '8px';
header.style.padding = '0 14px';
header.appendChild(dragHandle);
header.appendChild(title);
header.appendChild(wipBadge);
header.appendChild(count);
el.appendChild(header);
const menu = document.createElement('button');
menu.className = 'col-menu';
menu.textContent = '\u2026';
menu.title = 'Column menu';
menu.addEventListener('click', (e) => { e.stopPropagation(); toggleColumnMenu(e, col); });
el.appendChild(menu);
const body = document.createElement('div');
body.className = 'col-body';
body.style.flex = '1';
body.style.overflow = 'auto';
body.style.padding = '6px 12px 18px';
const visible = col.cards.filter(cardMatches);
const hiddenCount = col.cards.length - visible.length;
if (!col.cards.length) {
const empty = document.createElement('div');
empty.className = 'empty-state';
empty.style.display = 'flex';
empty.innerHTML = '<div style="width:38px;height:38px;border-radius:50%;background:' + CONSTANTS.colors.accentSoft + ';display:flex;align-items:center;justify-content:center;color:' + CONSTANTS.colors.accent + ';font-size:18px;margin-right:12px">✩</div><div><div style="font-weight:600;color:' + CONSTANTS.colors.text + '">No cards yet</div><div style="color:' + CONSTANTS.colors.muted + ';font-size:13px;margin-top:2px">Drop cards here</div></div>';
body.appendChild(empty);
} else {
visible.forEach(card => body.appendChild(renderCard(col, card)));
if (hiddenCount > 0) {
const note = document.createElement('div');
note.className = 'filter-note';
note.textContent = hiddenCount + ' hidden';
body.appendChild(note);
}
}
el.appendChild(body);
return el;
}
function renderCard(col, card) {
const el = document.createElement('div');
el.className = 'card' + (cardMatches(card) ? '' : ' card-hidden');
el.dataset.col = col.id;
el.dataset.card = card.id;
const cards = col.cards;
const idx = cards.findIndex(c => c.id === card.id);
el.style.animationDelay = (idx * 20) + 'ms';
const titleRow = document.createElement('div');
titleRow.className = 'card-title-row';
const title = document.createElement('span');
title.className = 'card-title';
title.textContent = card.title;
title.style.cursor = 'text';
title.title = 'Click to edit';
const del = document.createElement('button');
del.className = 'card-del';
del.textContent = '\u00d7';
del.title = 'Delete card';
del.addEventListener('click', (e) => { e.stopPropagation(); deleteCard(col.id, card.id); });
titleRow.style.display = 'flex';
titleRow.style.alignItems = 'center';
titleRow.style.justifyContent = 'space-between';
titleRow.style.gap = '6px';
titleRow.style.flexWrap = 'wrap';
titleRow.appendChild(title);
titleRow.appendChild(del);
el.appendChild(titleRow);
if (card.description) {
const desc = document.createElement('div');
desc.className = 'card-desc';
desc.textContent = card.description;
el.appendChild(desc);
}
if (card.dueDate) {
const due = document.createElement('div');
due.className = 'card-due';
const d = new Date(card.dueDate + 'T00:00:00');
const today = new Date(); today.setHours(0, 0, 0, 0);
const dd = new Date(card.dueDate + 'T00:00:00'); dd.setHours(0, 0, 0, 0);
due.style.color = dd < today ? '#ef4444' : CONSTANTS.colors.muted;
due.innerHTML = '<span>\uD83D\uDCC5</span><span>' + d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + '</span>';
el.appendChild(due);
}
if (card.labels.length) {
const labels = document.createElement('div');
labels.className = 'card-labels';
card.labels.forEach(lid => {
const lab = labelById(lid);
if (!lab) return;
const chip = document.createElement('span');
chip.className = 'label-chip';
chip.style.background = lab.color;
chip.textContent = lab.name;
chip.title = 'Click to toggle ' + lab.name;
chip.addEventListener('click', (e) => { e.stopPropagation(); toggleLabel(col.id, card.id, lid); });
labels.appendChild(chip);
});
el.appendChild(labels);
}
el.addEventListener('pointerdown', (e) => onPointerDown(e, 'card', el), { passive: false });
return el;
}
function renderHeader() {
const box = document.getElementById('search-box');
box.value = filter.search;
const bar = document.getElementById('filter-bar');
bar.innerHTML = '';
const all = document.createElement('button');
all.className = 'filter-chip' + (filter.labelIds.length === 0 ? ' active' : '');
all.textContent = 'All labels';
all.addEventListener('click', () => { filter.labelIds = []; render(); });
bar.appendChild(all);
CONSTANTS.labelPalette.forEach(lab => {
const chip = document.createElement('button');
chip.className = 'filter-chip' + (filter.labelIds.includes(lab.id) ? ' active' : '');
chip.style.background = filter.labelIds.includes(lab.id) ? lab.color : 'transparent';
chip.style.color = filter.labelIds.includes(lab.id) ? '#fff' : lab.color;
chip.textContent = lab.name;
chip.title = 'Filter by ' + lab.name;
chip.addEventListener('click', () => {
const arr = filter.labelIds.includes(lab.id) ? filter.labelIds.filter(x => x !== lab.id) : filter.labelIds.concat(lab.id);
filter.labelIds = arr; render();
});
bar.appendChild(chip);
});
const undo = document.getElementById('undo-btn');
if (undo) undo.style.display = state.history.length ? '' : 'none';
const add = document.getElementById('add-col-btn');
if (add) add.style.display = state.columns.length ? '' : 'none';
}
/* ============================================================
INLINE EDITING (card title, column title)
============================================================ */
let editState = null;
function startCardEdit(colId, cardId) {
const card = state.columns.find(c => c.id === colId).cards.find(x => x.id === cardId);
const el = cardEl(cardId);
editState = { colId, cardId };
const title = el.querySelector('.card-title');
const input = document.createElement('input');
input.className = 'card-title-input';
input.value = card.title;
input.spellcheck = false;
input.style.flex = '1';
input.style.padding = '2px 6px';
input.style.border = '1px solid ' + CONSTANTS.colors.accent;
input.style.borderRadius = '5px';
input.style.fontSize = '13px';
input.style.fontWeight = '600';
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { commitCardEdit(); input.remove(); editState = null; }
else if (e.key === 'Escape') { input.remove(); editState = null; }
});
input.addEventListener('blur', () => { input.remove(); editState = null; });
title.replaceWith(input);
input.focus(); input.select();
}
function commitCardEdit() {
if (!editState) return;
const val = cardEl(editState.cardId).querySelector('.card-title-input').value.trim() || 'Untitled';
renameCard(editState.colId, editState.cardId, val);
}
/* ============================================================
LABEL TOGGLE / COLUMN MENU
============================================================ */
function toggleLabel(colId, cardId, lid) {
pushHistory();
const card = state.columns.find(c => c.id === colId).cards.find(x => x.id === cardId);
if (card) {
const arr = card.labels.includes(lid) ? card.labels.filter(x => x !== lid) : card.labels.concat(lid);
card.labels = arr;
save();
}
}
let menuState = null;
function toggleColumnMenu(e, col) {
e.stopPropagation();
const existing = document.getElementById('col-menu-' + col.id);
if (existing) { existing.remove(); menuState = null; return; }
pushMenu(col.id);
}
function pushMenu(colId) {
menuState = colId;
const col = state.columns.find(c => c.id === colId);
const menu = document.createElement('div');
menu.id = 'col-menu-' + colId;
menu.className = 'col-menu-popup';
const addWip = () => {
const input = prompt('Set WIP limit (blank = unlimited):', col.wip != null ? String(col.wip) : '');
if (input === null) return;
const n = input.trim();
if (n === '') setWip(colId, null);
else if (parseInt(n, 10)) setWip(colId, parseInt(n, 10));
closeMenu();
};
const rename = () => {
const v = prompt('Rename column:', col.title);
if (v !== null) renameColumn(colId, v.trim() || col.title);
closeMenu();
};
const del = () => { if (confirm('Delete "' + col.title + '" and its ' + col.cards.length + ' cards?')) { deleteColumn(colId); closeMenu(); } };
menu.style.minWidth = '180px';
menu.style.background = CONSTANTS.colors.card;
menu.style.border = '1px solid ' + CONSTANTS.colors.border;
menu.style.borderRadius = '10px';
menu.style.boxShadow = '0 12px 30px ' + CONSTANTS.colors.shadowStrong;
menu.style.padding = '6px';
menu.style.zIndex = '2000';
['Rename column', 'Set WIP limit', 'Delete column'].forEach((label, i) => {
const b = document.createElement('button');
b.textContent = label;
b.style.display = 'block';
b.style.width = '100%';
b.style.textAlign = 'left';
b.style.padding = '9px 12px';
b.style.border = 'none';
b.style.background = 'transparent';
b.style.borderRadius = '7px';
b.style.cursor = 'pointer';
b.style.color = CONSTANTS.colors.text;
if (i === 0) b.addEventListener('click', rename);
else if (i === 1) b.addEventListener('click', addWip);
else b.addEventListener('click', del);
menu.appendChild(b);
});
document.body.appendChild(menu);
const r = menu.getBoundingClientRect();
menu.style.left = Math.min(r.left, window.innerWidth - 200) + 'px';
menu.style.top = (r.bottom + 6) + 'px';
}
function closeMenu() {
const el = document.getElementById('col-menu-' + (menuState || ''));
if (el) el.remove();
menuState = null;
}
/* ============================================================
UNDO / TOAST
============================================================ */
function doUndo() {
if (!state.history.length) return;
restoreSnapshot();
render();
save();
toast('Undo', 'Action undone');
}
let toastTimer = null;
function toast(title, msg) {
let t = document.getElementById('toast');
if (!t) {
t = document.createElement('div');
t.id = 'toast';
t.style.position = 'fixed';
t.style.bottom = '24px';
t.style.left = '50%';
t.style.transform = 'translateX(-50%) translateY(20px)';
t.style.opacity = '0';
t.style.transition = 'all 260ms ease';
t.style.background = '#1f2430';
t.style.color = '#fff';
t.style.padding = '12px 18px';
t.style.borderRadius = '10px';
t.style.boxShadow = '0 10px 30px rgba(0,0,0,.28)';
t.style.zIndex = '5000';
t.style.display = 'flex';
t.style.gap = '10px';
t.style.alignItems = 'center';
document.body.appendChild(t);
}
t.innerHTML = '';
t.innerHTML = '<strong>' + title + '</strong>';
if (msg) { const s = document.createElement('span'); s.textContent = ' ' + msg; s.style.opacity = '.8'; s.style.fontWeight = '400'; t.appendChild(s); }
const btn = document.createElement('button');
btn.textContent = 'Undo';
btn.style.border = 'none';
btn.style.background = 'rgba(255,255,255,.18)';
btn.style.color = '#fff';
btn.style.padding = '5px 12px';
btn.style.borderRadius = '7px';
btn.style.cursor = 'pointer';
btn.style.fontWeight = '600';
btn.addEventListener('click', doUndo);
t.appendChild(btn);
requestAnimationFrame(() => { t.style.opacity = '1'; t.style.transform = 'translateX(-50%) translateY(0)'; });
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { t.style.opacity = '0'; t.style.transform = 'translateX(-50%) translateY(20px)'; }, 1400);
}
/* ============================================================
GLOBAL CONTROLS
============================================================ */
function initGlobalControls() {
const search = document.getElementById('search-box');
search.addEventListener('input', () => { filter.search = search.value.trim(); render(); });
const filterBar = document.getElementById('filter-bar');
filterBar.addEventListener('click', () => closeMenu());
document.getElementById('add-col-btn').addEventListener('click', () => {
const v = prompt('New column name:', 'New Column');
if (v !== null) addColumn(v);
});
document.getElementById('undo-btn').addEventListener('click', doUndo);
document.getElementById('reset-btn').addEventListener('click', () => {
if (confirm('Reset to the demo board? This clears your current board.')) { state = { columns: [], idSeq: 400, history: [] }; seedDemo(); render(); save(); }
});
document.addEventListener('pointerdown', (e) => {
if (menuState && !e.target.closest('#col-menu-' + menuState) && !e.target.closest('[data-col-menu]')) closeMenu();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && menuState) closeMenu();
});
}
/* ============================================================
DRAG & DROP (Pointer Events)
============================================================ */
let drag = null;
let lastMove = { x: 0, y: 0, t: 0 };
function onPointerDown(e, type, element) {
if (editState) return;
if (e.button !== undefined && e.button !== 0) return;
e.preventDefault();
if (type === 'column') {
if (menuState) return;
startColumnDrag(e, element);
return;
}
if (type === 'card') {
const titleRow = element.querySelector('.card-title-row');
if (e.target.closest('.card-del, .label-chip, .card-title-input')) return;
if (e.target.closest('.card-title')) return;
startCardDrag(e, element);
}
}
function startCardDrag(e, element) {
const colId = element.dataset.col;
const cards = state.columns.find(c => c.id === colId).cards;
const index = cards.findIndex(c => c.id === element.dataset.card);
const cardId = element.dataset.card;
drag = {
type: 'card',
element, cardId, colId, index,
offsetX: e.clientX - element.offsetLeft,
offsetY: e.clientY - element.offsetTop,
lastX: e.clientX, lastY: e.clientY,
lastT: performance.now(),
vx: 0, vy: 0,
fromElement: true
};
element.style.position = 'fixed';
element.style.left = element.offsetLeft + 'px';
element.style.top = element.offsetTop + 'px';
element.style.zIndex = CONSTANTS.drag.zIndex;
element.style.opacity = '0.92';
insertPlaceholder(colId, index);
applyLift(element, 0);
lastMove = { x: e.clientX, y: e.clientY, t: performance.now() };
}
function startColumnDrag(e, element) {
const colId = element.closest('[data-col]').dataset.col;
drag = {
type: 'column',
element, colId,
offsetX: e.clientX - element.offsetLeft,
offsetY: e.clientY - element.offsetTop,
lastX: e.clientX, lastY: e.clientY,
lastT: performance.now(),
vx: 0, vy: 0,
fromElement: true
};
element.style.position = 'fixed';
element.style.left = element.offsetLeft + 'px';
element.style.top = element.offsetTop + 'px';
element.style.zIndex = CONSTANTS.drag.zIndex;
element.style.opacity = '0.92';
lastMove = { x: e.clientX, y: e.clientY, t: performance.now() };
}
function applyLift(el, vy) {
const tilt = Math.max(-CONSTANTS.drag.tilt, Math.min(CONSTANTS.drag.tilt, (vy || 0) / 220));
el.style.transform = 'translateY(' + (-CONSTANTS.drag.liftY) + 'px) perspective(80px) rotate(' + (-tilt * 0.12) + 'deg)';
el.style.boxShadow = '0 16px 34px ' + CONSTANTS.colors.shadowStrong;
}
let placeholder = null;
function insertPlaceholder(colId, index) {
const colEl = colEl(colId);
if (placeholder && placeholder.parentNode) placeholder.remove();
const ph = document.createElement('div');
ph.className = 'placeholder';
ph.dataset.col = colId;
ph.style.height = CONSTANTS.geometry.cardH + 'px';
ph.style.margin = CONSTANTS.geometry.cardMargin + 'px 0';
ph.style.borderRadius = '8px';
ph.style.borderTop = '2px dashed ' + CONSTANTS.colors.accent;
ph.style.background = CONSTANTS.colors.accentSoft;
ph.style.opacity = '0.55';
colEl.insertBefore(ph, colEl.querySelector('.col-body'));
placeholder = ph;
}
function removePlaceholder() { if (placeholder && placeholder.parentNode) placeholder.remove(); placeholder = null; }
function colUnderPointer(e) {
const cols = document.querySelectorAll('.column[data-col]');
for (const c of cols) {
const r = c.getBoundingClientRect();
if (e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom) return c;
}
return null;
}
function dropIndexFor(colEl, relY) {
const cards = [...colEl.querySelectorAll('.card:not(.card-hidden)')];
for (let i = 0; i < cards.length; i++) {
const r = cards[i].getBoundingClientRect();
if (relY < r.top) return 0;
if (relY >= r.bottom) continue;
return i + 1;
}
return cards.length;
}
function positionPlaceholder(colId, relY) {
if (!placeholder) return;
const colEl = colEl(colId);
const phR = colEl.getBoundingClientRect();
placeholder.style.top = (relY - CONSTANTS.geometry.cardH / 2) + 'px';
placeholder.style.left = '12px';
placeholder.style.width = (phR.width - 24) + 'px';
}
function autoScroll(e) {
if (!drag) return;
const speed = CONSTANTS.drag.scrollSpeed;
const board = document.querySelector('.board');
const boardR = board.getBoundingClientRect();
const x = e.clientX, y = e.clientY;
if (x < boardR.left + CONSTANTS.drag.edgeMargin && board.scrollLeft > 0) board.scrollLeft += speed;
else if (x > boardR.right - CONSTANTS.drag.edgeMargin && board.scrollLeft < board.scrollWidth - board.clientWidth) board.scrollLeft -= speed;
const target = colUnderPointer(e);
if (target) {
const cr = target.getBoundingClientRect();
if (y < cr.top + 12 && target.scrollTop > 0) target.scrollTop += speed;
else if (y > cr.bottom - 12 && target.scrollTop < target.scrollHeight - target.clientHeight) target.scrollTop -= speed;
}
}
/* ============================================================
DRAG MOVE / UP
============================================================ */
function onPointerMove(e) {
if (!drag) return;
const now = performance.now();
const dt = Math.max(1, now - drag.lastT);
drag.vx = (e.clientX - drag.lastX) / dt * 16;
drag.vy = (e.clientY - drag.lastY) / dt * 16;
drag.lastX = e.clientX; drag.lastY = e.clientY; drag.lastT = now;
drag.element.style.left = (e.clientX - drag.offsetX) + 'px';
drag.element.style.top = (e.clientY - drag.offsetY) + 'px';
applyLift(drag.element, drag.vy);
const originColEl = colEl(drag.colId);
const oRect = originColEl.getBoundingClientRect();
drag._y = e.clientY - oRect.top;
if (drag.type === 'card') {
const targetCol = colUnderPointer(e);
const targetId = targetCol ? targetCol.dataset.col : drag.colId;
positionPlaceholder(targetId, drag._y);
placeholder._index = dropIndexFor(targetCol || originColEl, drag._y);
} else {
const cols = [...document.querySelectorAll('.column[data-col]')];
for (let i = 0; i < cols.length; i++) {
const r = cols[i].getBoundingClientRect();
if (e.clientX < r.left) { placeholder._index = i; break; }
if (e.clientX > r.right) { placeholder._index = i + 1; continue; }
}
positionPlaceholder(drag.colId, drag._y);
placeholder.style.top = (drag._y - CONSTANTS.geometry.headerH - 6) + 'px';
}
autoScroll(e);
}
function finishDrag() {
if (!drag) return;
const el = drag.element;
el.style.position = '';
el.style.left = '';
el.style.top = '';
el.style.transform = '';
el.style.boxShadow = '';
el.style.opacity = '';
el.style.zIndex = '';
removePlaceholder();
if (drag.type === 'card') {
const targetColEl = colUnderPointer(drag._lastE) || colEl(drag.colId);
const targetId = targetColEl ? targetColEl.dataset.col : drag.colId;
const relY = drag._lastY;
const idx = dropIndexFor(targetColEl, relY);
const srcCol = state.columns.find(c => c.id === drag.colId);
const [card] = srcCol.cards.splice(drag.index, 1);
const dstCol = state.columns.find(c => c.id === targetId);
dstCol.cards.splice(idx, 0, card);
renderColumn(drag.colId);
if (targetId !== drag.colId) renderColumn(targetId);
scheduleSettle(drag.colId, targetId);
save();
} else {
const cols = state.columns.map(c => c.id);
const arr = cols.filter(id => id !== drag.colId);
const idx = placeholder._index || arr.length;
arr.splice(idx, 0, drag.colId);
state.columns = arr.map(id => state.columns.find(c => c.id === id));
state.columns.forEach(c => renderColumn(c.id));
save();
}
drag = null;
}
let settleTimer = null;
function scheduleSettle(fromColId, toColId) {
if (settleTimer) clearTimeout(settleTimer);
settleTimer = setTimeout(() => {
[fromColId, toColId].forEach(id => {
const el = colEl(id);
if (el) { el.classList.add('col-settle'); setTimeout(() => el.classList.remove('col-settle'), 340); }
});
}, 20);
}
/* ============================================================
POINTER LISTENERS
============================================================ */
window.addEventListener('pointermove', onPointerMove, { passive: false });
window.addEventListener('pointerup', (e) => {
if (!drag) return;
drag._lastE = e;
drag._lastY = e.clientY;
try { drag.element.releasePointerCapture(e.pointerId); } catch (err) {}
if (drag.fromElement) finishDrag();
}, { passive: true });
/* ============================================================
KEYBOARD: Escape cancels a drag
============================================================ */
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && drag) {
const el = drag.element;
el.style.position = '';
el.style.left = '';
el.style.top = '';
el.style.transform = '';
el.style.boxShadow = '';
el.style.opacity = '';
el.style.zIndex = '';
removePlaceholder();
drag = null;
}
});
/* ============================================================
BOOT
============================================================ */
function boot() {
load();
render();
initGlobalControls();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
</script>
</body>
</html>