Kanban
Qwen 3.6 27B MTP GGUF · typescript

Browser check: Artifact runtime error. This is a technical check, not a quality review.
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 77,668 bytes · SHA-256 fb3cd5ca4199
<!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 ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: #f4f5f7;
color: #172b4d;
-webkit-font-smoothing: antialiased;
}
#app {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
/* ─── Header ──────────────────────────────────────────────────── */
#header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 24px;
background: #0079bf;
color: #fff;
min-height: 48px;
z-index: 100;
flex-shrink: 0;
}
#header-left h1 {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.3px;
}
#header-right {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
#search-wrapper {
position: relative;
}
#search-input {
padding: 6px 12px 6px 30px;
border: none;
border-radius: 6px;
background: rgba(255,255,255,0.2);
color: #fff;
font-size: 13px;
width: 180px;
outline: none;
transition: all 0.15s;
}
#search-input::placeholder { color: rgba(255,255,255,0.6); }
#search-input:focus { background: rgba(255,255,255,0.3); width: 220px; }
#search-wrapper::before {
content: '';
position: absolute;
left: 9px;
top: 50%;
transform: translateY(-50%);
width: 13px; height: 13px;
border: 2px solid rgba(255,255,255,0.5);
border-radius: 50%;
pointer-events: none;
}
#search-wrapper::after {
content: '';
position: absolute;
left: 16px;
top: 55%;
transform: rotate(45deg);
width: 2px; height: 6px;
background: rgba(255,255,255,0.5);
border-radius: 1px;
pointer-events: none;
}
#label-filter-bar {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.label-filter-chip {
display: inline-flex; align-items: center;
padding: 3px 8px; border-radius: 12px;
border: 2px solid transparent;
background: rgba(255,255,255,0.2);
color: rgba(255,255,255,0.9);
font-size: 11px; font-weight: 600;
cursor: pointer; transition: all 0.15s;
}
.label-filter-chip:hover { background: rgba(255,255,255,0.3); }
.label-filter-chip.active {
background: #fff; color: #0079bf; border-color: #fff;
}
/* ─── Board ───────────────────────────────────────────────────── */
#board {
display: flex;
flex: 1;
overflow-x: auto;
overflow-y: hidden;
padding: 16px 24px;
gap: 16px;
align-items: flex-start;
}
#column-container {
display: flex;
gap: 16px;
align-items: flex-start;
}
/* ─── Column ──────────────────────────────────────────────────── */
.kanban-column {
width: 280px; min-width: 280px;
max-height: calc(100vh - 88px);
display: flex; flex-direction: column;
background: #ebecf0;
border-radius: 12px;
overflow: hidden;
border: 2px solid transparent;
transition: border-color 0.2s, background 0.2s;
}
.kanban-column.wip-exceeded {
border-color: #eb5a46;
background: linear-gradient(180deg, #fff5f5 0%, #ebecf0 80px);
}
.column-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 12px;
cursor: grab; user-select: none;
min-height: 42px;
}
.column-header:active { cursor: grabbing; }
.column-title-group {
display: flex; align-items: center; gap: 8px;
}
.column-title {
font-size: 13px; font-weight: 700;
color: #172b4d; line-height: 1.2;
}
.column-count {
background: rgba(0,0,0,0.08);
color: #5e6c84;
font-size: 11px; font-weight: 700;
padding: 2px 7px;
border-radius: 10px; line-height: 1.5;
}
.wip-badge {
margin-left: 2px;
}
.wip-exceeded-badge {
color: #eb5a46; font-weight: 800;
}
.column-menu-btn {
background: transparent; border: none;
color: #8993a4; font-size: 16px;
cursor: pointer; padding: 4px 6px;
border-radius: 4px; line-height: 1;
letter-spacing: 1px;
transition: background 0.1s, color 0.1s;
}
.column-menu-btn:hover { background: rgba(0,0,0,0.08); color: #5e6c84; }
/* ─── Card List ───────────────────────────────────────────────── */
.card-list {
flex: 1; overflow-y: auto;
padding: 4px 8px;
min-height: 8px;
}
.card-list::-webkit-scrollbar { width: 6px; }
.card-list::-webkit-scrollbar-track { background: transparent; }
.card-list::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.12); border-radius: 3px; }
/* ─── Card ────────────────────────────────────────────────────── */
.kanban-card {
background: #fff;
border-radius: 8px;
padding: 8px 12px;
margin-bottom: 8px;
cursor: grab;
box-shadow: 0 1px 2px rgba(0,0,0,0.07);
transition: box-shadow 0.15s, transform 0.12s, border-color 0.1s;
position: relative;
user-select: none;
border: 1px solid transparent;
}
.kanban-card:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transform: translateY(-1px);
border-color: rgba(0,0,0,0.05);
}
.kanban-card:active { cursor: grabbing; }
.card-labels {
display: flex; flex-wrap: wrap; gap: 4px;
margin-bottom: 6px;
}
.card-label {
display: inline-block;
padding: 2px 8px; border-radius: 4px;
font-size: 11px; font-weight: 600;
line-height: 1.6;
}
.card-title {
font-size: 14px; line-height: 1.45;
color: #172b4d; word-break: break-word;
}
.card-meta {
display: flex; align-items: center; gap: 8px;
margin-top: 6px;
}
.card-due-date {
font-size: 12px; color: #5e6c84;
display: flex; align-items: center; gap: 3px;
}
.card-due-date::before {
content: '';
display: inline-block;
width: 12px; height: 12px;
border: 1.5px solid currentColor;
border-radius: 50%;
}
.due-overdue { color: #eb5a46 !important; font-weight: 600; }
.due-today { color: #f2d600 !important; font-weight: 600; }
/* ─── Drag placeholder & ghost ────────────────────────────────── */
.drag-ghost {
box-shadow: 0 8px 24px rgba(0,0,0,0.2) !important;
transform: rotate(-2deg) scale(1.03) !important;
opacity: 0.95 !important;
transition: none !important;
}
.drag-placeholder {
border: 2px dashed #0079bf !important;
border-radius: 8px !important;
background: rgba(0,121,191,0.06) !important;
transition: height 0.1s ease !important;
pointer-events: none !important;
}
.drag-placeholder:hover {
box-shadow: none !important;
transform: none !important;
}
/* ─── Empty Column ────────────────────────────────────────────── */
.empty-column-zone {
display: flex; align-items: center; justify-content: center;
padding: 24px 16px;
border: 2px dashed #c4c9cc;
border-radius: 8px;
color: #8993a4;
font-size: 13px; text-align: center;
min-height: 60px;
margin: 4px;
transition: border-color 0.15s, background 0.15s;
}
/* ─── Add Card ────────────────────────────────────────────────── */
.add-card-btn {
width: 100%;
padding: 8px 12px;
background: transparent;
border: none;
color: #5e6c84;
font-size: 14px;
text-align: left;
cursor: pointer;
border-radius: 0 0 12px 12px;
transition: background 0.1s, color 0.1s;
}
.add-card-btn:hover {
background: rgba(0,0,0,0.05);
color: #172b4d;
}
.add-card-form { padding: 4px 8px 8px; }
.add-card-textarea {
width: 100%; min-height: 40px;
border: 2px solid #0079bf;
border-radius: 8px; padding: 8px 12px;
font-size: 14px; resize: none;
font-family: inherit; outline: none;
background: #fff;
}
.add-card-confirm-btn {
background: #0079bf; color: #fff;
border: none; border-radius: 4px;
padding: 6px 16px; font-size: 14px;
cursor: pointer; font-weight: 500;
transition: background 0.1s;
}
.add-card-confirm-btn:hover { background: #026aa7; }
.cancel-btn {
background: none; border: none;
font-size: 20px; color: #8993a4;
cursor: pointer; width: 28px; height: 28px;
border-radius: 4px; transition: background 0.1s;
}
.cancel-btn:hover { background: rgba(0,0,0,0.05); }
/* ─── Add Column ──────────────────────────────────────────────── */
.add-column {
display: flex; align-items: center;
padding: 0 4px;
flex-shrink: 0;
}
.add-column-btn {
padding: 10px 16px;
background: rgba(255,255,255,0.6);
border: none; border-radius: 12px;
color: #5e6c84;
font-size: 14px; font-weight: 600;
cursor: pointer; white-space: nowrap;
transition: all 0.15s;
}
.add-column-btn:hover {
background: rgba(255,255,255,0.9);
color: #172b4d;
}
.add-column-input {
padding: 8px 12px;
border: 2px solid #0079bf;
border-radius: 12px;
font-size: 14px; font-weight: 600;
color: #172b4d; background: #fff;
outline: none; width: 200px;
font-family: inherit;
box-shadow: 0 0 0 4px rgba(0,121,191,0.1);
}
.add-column-cancel {
margin-left: 8px;
background: none; border: none;
font-size: 22px; color: #8993a4;
cursor: pointer; padding: 4px 8px;
}
/* ─── Context Menu ────────────────────────────────────────────── */
.context-menu {
position: fixed; z-index: 10000;
background: #fff; border-radius: 8px;
box-shadow: 0 4px 24px rgba(0,0,0,0.18);
padding: 6px; min-width: 180px;
}
.context-menu-item {
display: block; width: 100%; text-align: left;
padding: 7px 12px;
background: transparent; border: none;
border-radius: 4px;
font-size: 13px; color: #172b4d;
cursor: pointer; transition: background 0.1s;
font-family: inherit;
}
.context-menu-item:hover { background: #ebecf0; }
.context-menu-item.active { background: #e6f3ff; color: #0079bf; }
.context-menu-item.danger { color: #eb5a46; }
.context-menu-item.danger:hover { background: #ffebe7; }
.context-menu-label {
padding: 6px 12px 2px;
font-size: 10px; font-weight: 700;
text-transform: uppercase;
color: #8993a4; letter-spacing: 0.5px;
}
.context-menu-section {
border-top: 1px solid #ddd;
padding-top: 2px; margin-top: 4px;
}
/* ─── Column Edit Input ───────────────────────────────────────── */
.column-edit-input {
background: #fff; border: 2px solid #0079bf;
border-radius: 4px; padding: 2px 6px;
font-size: 13px; font-weight: 700;
width: 100%; color: #172b4d; outline: none;
font-family: inherit;
}
/* ─── Toasts ──────────────────────────────────────────────────── */
#toast-container {
position: fixed; bottom: 20px; left: 50%;
transform: translateX(-50%);
z-index: 10001;
display: flex; flex-direction: column;
align-items: center; gap: 8px;
pointer-events: none;
}
.toast {
pointer-events: auto;
background: #172b4d; color: #fff;
padding: 10px 16px; border-radius: 8px;
display: flex; align-items: center;
justify-content: space-between;
gap: 16px;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
font-size: 13px; font-weight: 500;
animation: toast-in 0.25s ease;
max-width: 380px;
}
.toast button {
background: transparent;
border: none; cursor: pointer;
font-family: inherit;
}
.toast .toast-undo {
color: #4da3ff; font-weight: 700;
padding: 4px 10px; border-radius: 4px;
transition: background 0.1s;
}
.toast .toast-undo:hover { background: rgba(255,255,255,0.1); }
.toast .toast-dismiss {
color: rgba(255,255,255,0.5);
font-size: 16px; padding: 2px 6px;
transition: color 0.1s;
}
.toast .toast-dismiss:hover { color: #fff; }
@keyframes toast-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* ─── Scrollbars ──────────────────────────────────────────────── */
#board::-webkit-scrollbar { height: 8px; }
#board::-webkit-scrollbar-track { background: rgba(0,0,0,0.03); }
#board::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.12); border-radius: 4px; }
/* ─── Settle Animation ────────────────────────────────────────── */
@keyframes card-settle {
0% { transform: scale(1.04); }
100% { transform: scale(1); }
}
.kanban-card.just-dropped {
animation: card-settle 0.15s ease-out;
}
</style>
</head>
<body>
<div id="app">
<div id="header">
<div id="header-left">
<h1 id="board-title">Kanban Board</h1>
</div>
<div id="header-right">
<div id="search-wrapper">
<input type="text" id="search-input" placeholder="Search cards...">
</div>
<div id="label-filter-bar"></div>
</div>
</div>
<div id="board">
<div id="column-container"></div>
<div class="add-column">
<button class="add-column-btn">+ Add another column</button>
</div>
</div>
<div id="toast-container"></div>
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./state":"src/state.ts","./render":"src/render.ts","./dragdrop":"src/dragdrop.ts","./toasts":"src/toasts.ts","./types":"src/types.ts"},"src/state.ts":{"./types":"src/types.ts"},"src/render.ts":{"./state":"src/state.ts","./dragdrop":"src/dragdrop.ts","./types":"src/types.ts"},"src/dragdrop.ts":{"./state":"src/state.ts","./types":"src/types.ts"},"src/toasts.ts":{"./state":"src/state.ts"}};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
var module = __cache[id] = { exports: {} };
var factory = __mods[id];
if (!factory) throw new Error("Module not found: " + id);
factory(module.exports, function (spec) {
var target = (__map[id] && __map[id][spec]) || spec;
return __require(target);
}, module);
return module.exports;
}
// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const state_1 = require("./state");
const render_1 = require("./render");
const toasts_1 = require("./toasts");
// ─── Boot ────────────────────────────────────────────────────────
function start() {
// Init state from storage
(0, state_1.initState)(true);
// Load filter state
loadFilterState();
// Get root element
const root = document.getElementById('app');
// Init renders
(0, render_1.initRender)(root);
(0, toasts_1.initToasts)(root);
// Wire search
const searchInput = document.getElementById('search-input');
searchInput.addEventListener('input', () => {
const fs = (0, state_1.getFilterState)();
(0, state_1.setFilterState)({ ...fs, search: searchInput.value });
});
// Wire label filters
const labelBar = document.getElementById('label-filter-bar');
labelBar.addEventListener('click', (e) => {
const chip = e.target?.closest('.label-filter-chip');
if (!chip)
return;
const labelName = chip.dataset.labelName;
if (!labelName)
return;
const fs = (0, state_1.getFilterState)();
const idx = fs.labelFilters.indexOf(labelName);
if (idx >= 0) {
(0, state_1.setFilterState)({ ...fs, labelFilters: fs.labelFilters.filter(n => n !== labelName) });
}
else {
(0, state_1.setFilterState)({ ...fs, labelFilters: [...fs.labelFilters, labelName] });
}
updateLabelFilterUI();
});
updateLabelFilterUI();
// Wire add column button
wireAddColumn(root);
// Initial render
(0, render_1.renderBoard)();
(0, toasts_1.renderToasts)();
// Subscribe to state changes
(0, state_1.subscribe)(() => {
(0, render_1.renderBoard)();
(0, toasts_1.renderToasts)();
updateLabelFilterUI();
});
}
// ─── Label Filter UI ──────────────────────────────────────────────
function updateLabelFilterUI() {
const labelBar = document.getElementById('label-filter-bar');
const chips = labelBar.querySelectorAll('.label-filter-chip');
const fs = (0, state_1.getFilterState)();
chips.forEach(chip => {
const name = chip.dataset.labelName;
if (name && fs.labelFilters.includes(name)) {
chip.classList.add('active');
}
else {
chip.classList.remove('active');
}
});
}
// ─── Add Column ───────────────────────────────────────────────────
function wireAddColumn(root) {
const addColEl = root.querySelector('.add-column');
if (!addColEl)
return;
addColEl.innerHTML = '';
const btn = document.createElement('button');
btn.className = 'add-column-btn';
btn.textContent = '+ Add another column';
btn.addEventListener('click', showAddColumnForm);
addColEl.appendChild(btn);
}
function showAddColumnForm() {
const addColEl = document.querySelector('.add-column');
if (!addColEl)
return;
addColEl.innerHTML = '';
addColEl.classList.add('active');
const input = document.createElement('input');
input.type = 'text';
input.placeholder = 'Enter column title...';
input.className = 'add-column-input';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = '\u00d7';
cancelBtn.className = 'add-column-cancel';
addColEl.appendChild(input);
addColEl.appendChild(cancelBtn);
input.focus();
const commit = () => {
const title = input.value.trim();
if (title) {
(0, state_1.addColumn)(title);
}
wireAddColumn(document.getElementById('app'));
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
commit();
}
if (e.key === 'Escape') {
e.preventDefault();
wireAddColumn(document.getElementById('app'));
}
});
cancelBtn.addEventListener('click', () => wireAddColumn(document.getElementById('app')));
}
// ─── Helpers ──────────────────────────────────────────────────────
function loadFilterState() {
try {
const saved = localStorage.getItem('kanban-board-state-filters');
if (saved) {
const fs = JSON.parse(saved);
(0, state_1.setFilterState)(fs);
const searchInput = document.getElementById('search-input');
if (searchInput)
searchInput.value = fs.search || '';
}
}
catch { /* ignore */ }
}
// ─── Entry ────────────────────────────────────────────────────────
start();
};
// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
// ─── Tunable Constants ─────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.ANIM_SETTLE_MS = exports.AUTO_SCROLL_SPEED = exports.AUTO_SCROLL_MARGIN = exports.DRAG_START_THRESHOLD = exports.TOAST_UNDO_DURATION = exports.TOAST_DURATION = exports.STORAGE_KEY = exports.DEFAULT_COLUMNS = exports.LABEL_PALETTE = exports.COLORS = void 0;
exports.COLORS = {
bg: '#f4f5f7',
boardBg: '#f4f5f7',
columnBg: '#ebecf0',
columnHeaderBg: '#ebecf0',
cardBg: '#ffffff',
cardHoverBg: '#fafbfc',
cardShadow: '0 1px 2px rgba(0,0,0,0.1)',
cardShadowHover: '0 2px 8px rgba(0,0,0,0.12)',
cardShadowDrag: '0 8px 24px rgba(0,0,0,0.2)',
textPrimary: '#172b4d',
textSecondary: '#5e6c84',
textTertiary: '#8993a4',
border: '#ddd',
accent: '#0079bf',
accentLight: '#e6f3ff',
danger: '#eb5a46',
dangerLight: '#ffebe7',
placeholderBg: 'rgba(0,121,191,0.1)',
wipExceeded: 'rgba(235,90,70,0.15)',
wipExceededBorder: '#eb5a46',
emptyColumn: '#e4e5e7',
dropIndicator: '#0079bf',
overlay: 'rgba(0,0,0,0.4)',
toastBg: '#172b4d',
toastText: '#ffffff',
};
exports.LABEL_PALETTE = [
{ name: 'Bug', color: '#eb5a46' },
{ name: 'Feature', color: '#61bd4f' },
{ name: 'Improvement', color: '#f2d600' },
{ name: 'Design', color: '#c377e0' },
{ name: 'Docs', color: '#00c2e0' },
{ name: 'Urgent', color: '#ff9f1a' },
];
exports.DEFAULT_COLUMNS = [
{ title: 'Backlog' },
{ title: 'To Do' },
{ title: 'In Progress' },
{ title: 'Review' },
{ title: 'Done' },
];
exports.STORAGE_KEY = 'kanban-board-state';
exports.TOAST_DURATION = 3000;
exports.TOAST_UNDO_DURATION = 8000;
exports.DRAG_START_THRESHOLD = 5; // px before drag begins
exports.AUTO_SCROLL_MARGIN = 60; // px from edge to start scrolling
exports.AUTO_SCROLL_SPEED = 12; // px per frame
exports.ANIM_SETTLE_MS = 150; // card settle animation duration
};
// ── module: src/state.ts ──
__mods["src/state.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.initState = initState;
exports.saveModel = saveModel;
exports.saveFilterState = saveFilterState;
exports.loadFilterState = loadFilterState;
exports.getModel = getModel;
exports.getFilterState = getFilterState;
exports.setFilterState = setFilterState;
exports.setModel = setModel;
exports.getColumn = getColumn;
exports.getCard = getCard;
exports.getCardInColumn = getCardInColumn;
exports.addCard = addCard;
exports.deleteCard = deleteCard;
exports.updateCard = updateCard;
exports.addColumn = addColumn;
exports.renameColumn = renameColumn;
exports.deleteColumn = deleteColumn;
exports.updateColumnWip = updateColumnWip;
exports.moveCard = moveCard;
exports.reorderColumn = reorderColumn;
exports.getToasts = getToasts;
exports.dismissToast = dismissToast;
exports.showToast = showToast;
exports.getUndoStack = getUndoStack;
exports.subscribe = subscribe;
exports.uid = uid;
exports.cloneModel = cloneModel;
const types_1 = require("./types");
// ─── State ─────────────────────────────────────────────────────────
let model;
let filterState = { search: '', labelFilters: [] };
let undoStack = [];
let toasts = [];
const listeners = new Set();
// ─── Init ──────────────────────────────────────────────────────────
function initState(fromStorage) {
filterState = { search: '', labelFilters: [] }; // always reset
if (fromStorage) {
const saved = localStorage.getItem(types_1.STORAGE_KEY);
if (saved) {
try {
model = JSON.parse(saved);
if (isValidModel(model))
return model;
}
catch { /* fall through */ }
}
}
model = createEmptyBoard();
return model;
}
function isValidModel(m) {
return m && Array.isArray(m.columnIds) &&
m.columns && typeof m.columns === 'object' &&
m.cards && typeof m.cards === 'object';
}
function createEmptyBoard() {
const columns = {};
const columnIds = [];
const cards = {};
// Create columns
const colIds = [];
for (const def of types_1.DEFAULT_COLUMNS) {
const id = uid();
colIds.push(id);
columns[id] = { id, title: def.title, cardIds: [], wipLimit: null };
columnIds.push(id);
}
// Seed sample cards
const sampleCards = [
{ title: 'Design login page', description: 'Create mockups for the new login flow', labels: [{ name: 'Design', color: '#c377e0' }], dueDate: '' },
{ title: 'Fix auth bug', description: 'Users can\'t reset password', labels: [{ name: 'Bug', color: '#eb5a46' }, { name: 'Urgent', color: '#ff9f1a' }], dueDate: new Date(Date.now() + 86400000).toISOString() },
{ title: 'Write API docs', description: '', labels: [{ name: 'Docs', color: '#00c2e0' }], dueDate: '' },
{ title: 'Implement dark mode', description: 'Add theme toggle in settings', labels: [{ name: 'Feature', color: '#61bd4f' }], dueDate: '' },
{ title: 'Performance audit', description: 'Lighthouse score below 80', labels: [{ name: 'Improvement', color: '#f2d600' }], dueDate: '' },
{ title: 'Add user avatars', description: 'Profile pictures upload & display', labels: [{ name: 'Feature', color: '#61bd4f' }], dueDate: '' },
{ title: 'Unit tests for utils', description: 'Cover helper functions', labels: [], dueDate: '' },
{ title: 'Dashboard analytics', description: 'Add charts for user activity', labels: [{ name: 'Feature', color: '#61bd4f' }, { name: 'Design', color: '#c377e0' }], dueDate: '' },
];
const cardOrder = [
// Backlog (col 0) — 2 cards
[sampleCards[3], sampleCards[4]],
// To Do (col 1) — 3 cards
[sampleCards[0], sampleCards[2], sampleCards[5]],
// In Progress (col 2) — 1 card
[sampleCards[1]],
// Review (col 3) — 0 cards
[],
// Done (col 4) — 2 cards
[sampleCards[6], sampleCards[7]],
];
for (let ci = 0; ci < colIds.length; ci++) {
const col = columns[colIds[ci]];
for (const sc of cardOrder[ci]) {
const id = uid();
const card = { ...sc, id, createdAt: Date.now() };
cards[id] = card;
col.cardIds.push(id);
}
}
return { columnIds, columns, cards };
}
// ─── Persistence ───────────────────────────────────────────────────
function saveModel() {
localStorage.setItem(types_1.STORAGE_KEY, JSON.stringify(model));
}
function saveFilterState() {
const key = types_1.STORAGE_KEY + '-filters';
localStorage.setItem(key, JSON.stringify(filterState));
}
function loadFilterState() {
const key = types_1.STORAGE_KEY + '-filters';
const saved = localStorage.getItem(key);
if (saved) {
try {
filterState = JSON.parse(saved);
}
catch {
filterState = { search: '', labelFilters: [] };
}
}
}
// ─── Accessors ─────────────────────────────────────────────────────
function getModel() {
return model;
}
function getFilterState() {
return filterState || { search: '', labelFilters: [] };
}
function setFilterState(next) {
filterState = { search: next.search || '', labelFilters: next.labelFilters || [] };
saveFilterState();
notify();
}
function setModel(next) {
model = next;
saveModel();
notify();
}
function getColumn(columnId) {
return model.columns[columnId];
}
function getCard(cardId) {
return model.cards[cardId];
}
function getCardInColumn(cardId, columnId) {
const col = model.columns[columnId];
return col ? col.cardIds.indexOf(cardId) : -1;
}
// ─── Card CRUD ─────────────────────────────────────────────────────
function addCard(columnId, card) {
const id = uid();
const full = { ...card, id, createdAt: Date.now() };
model.cards[id] = full;
model.columns[columnId].cardIds.push(id);
saveModel();
notify();
return id;
}
function deleteCard(cardId) {
const card = model.cards[cardId];
if (!card)
return;
let colId = '';
let idx = -1;
for (const cid of model.columnIds) {
idx = model.columns[cid].cardIds.indexOf(cardId);
if (idx >= 0) {
colId = cid;
break;
}
}
const snapshot = cloneModel(model);
const colTitle = model.columns[colId]?.title ?? '';
const cardTitle = card.title;
model.cards = { ...model.cards };
delete model.cards[cardId];
model.columns[colId].cardIds = model.columns[colId].cardIds.filter(id => id !== cardId);
pushUndo({
label: `Deleted "${cardTitle}"`,
undo: () => {
model = snapshot;
saveModel();
notify();
},
redo: () => {
model.cards = { ...model.cards };
delete model.cards[cardId];
for (const cid of model.columnIds) {
model.columns[cid].cardIds = model.columns[cid].cardIds.filter(id => id !== cardId);
}
saveModel();
notify();
},
});
}
function updateCard(cardId, updates) {
const card = model.cards[cardId];
if (!card)
return;
model.cards[cardId] = { ...card, ...updates };
saveModel();
notify();
}
// ─── Column CRUD ───────────────────────────────────────────────────
function addColumn(title) {
const id = uid();
const col = { id, title, cardIds: [], wipLimit: null };
model.columns[id] = col;
model.columnIds = [...model.columnIds, id];
saveModel();
notify();
return id;
}
function renameColumn(columnId, title) {
const col = model.columns[columnId];
if (!col)
return;
col.title = title;
saveModel();
notify();
}
function deleteColumn(columnId) {
if (model.columnIds.length <= 1)
return;
const col = model.columns[columnId];
if (!col)
return;
const cardIds = [...col.cardIds];
const colTitle = col.title;
const snapshot = cloneModel(model);
model.columnIds = model.columnIds.filter(id => id !== columnId);
const newCols = { ...model.columns };
delete newCols[columnId];
model.columns = newCols;
// Remove cards
const newCards = { ...model.cards };
for (const cid of cardIds)
delete newCards[cid];
model.cards = newCards;
pushUndo({
label: `Deleted column "${colTitle}"`,
undo: () => {
model = snapshot;
saveModel();
notify();
},
redo: () => {
model.columnIds = model.columnIds.filter(id => id !== columnId);
const nc = { ...model.columns };
delete nc[columnId];
model.columns = nc;
const ncs = { ...model.cards };
for (const cid of cardIds)
delete ncs[cid];
model.cards = ncs;
saveModel();
notify();
},
});
}
function updateColumnWip(columnId, wipLimit) {
const col = model.columns[columnId];
if (!col)
return;
col.wipLimit = wipLimit;
saveModel();
notify();
}
// ─── Card Move (for drag & drop) ──────────────────────────────────
function moveCard(cardId, sourceColId, sourceIdx, destColId, destIdx) {
const srcCol = model.columns[sourceColId];
const dstCol = model.columns[destColId];
if (!srcCol || !dstCol)
return;
// Remove from source
srcCol.cardIds = srcCol.cardIds.filter(id => id !== cardId);
// Insert at destination (clamp to valid range)
const clampedIdx = Math.min(destIdx, dstCol.cardIds.length);
dstCol.cardIds.splice(clampedIdx, 0, cardId);
saveModel();
notify();
}
// ─── Column Reorder ───────────────────────────────────────────────
function reorderColumn(columnId, newIdx) {
const oldIdx = model.columnIds.indexOf(columnId);
if (oldIdx < 0 || oldIdx === newIdx)
return;
const ids = [...model.columnIds];
const [col] = ids.splice(oldIdx, 1);
const clamped = Math.min(newIdx, ids.length);
ids.splice(clamped, 0, col);
model.columnIds = ids;
saveModel();
notify();
}
// ─── Undo / Toasts ────────────────────────────────────────────────
function pushUndo(action) {
const a = { id: uid(), label: action.label, undo: action.undo, redo: action.redo, timestamp: Date.now() };
undoStack.push(a);
showToast(`${action.label}`, true, action.undo);
}
function getToasts() {
return toasts;
}
function dismissToast(id) {
toasts = toasts.filter(t => t.id !== id);
notify();
}
function showToast(message, undoable = false, undoFn) {
const t = { id: uid(), message, undoable, undoFn };
toasts.push(t);
notify();
setTimeout(() => {
dismissToast(t.id);
}, undoable ? types_1.TOAST_UNDO_DURATION : types_1.TOAST_UNDO_DURATION);
}
function getUndoStack() {
return undoStack;
}
// ─── Notify ───────────────────────────────────────────────────────
function subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
function notify() {
for (const fn of listeners)
fn();
}
// ─── Helpers ───────────────────────────────────────────────────────
let _counter = 0;
function uid() {
return `id_${Date.now()}_${++_counter}_${Math.random().toString(36).slice(2, 7)}`;
}
function cloneModel(m) {
return JSON.parse(JSON.stringify(m));
}
};
// ── module: src/render.ts ──
__mods["src/render.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.initRender = initRender;
exports.renderBoard = renderBoard;
const state_1 = require("./state");
const dragdrop_1 = require("./dragdrop");
const types_1 = require("./types");
// ─── DOM References ────────────────────────────────────────────────
let boardEl;
let columnContainerEl;
let searchInput;
let labelFilterBar;
// ─── Render ────────────────────────────────────────────────────────
function initRender(root) {
boardEl = root.querySelector('#board');
columnContainerEl = root.querySelector('#column-container');
searchInput = root.querySelector('#search-input');
labelFilterBar = root.querySelector('#label-filter-bar');
// Build label filter bar
renderLabelFilters();
}
function renderBoard() {
const model = (0, state_1.getModel)();
const filterState = (0, state_1.getFilterState)();
columnContainerEl.innerHTML = '';
for (const colId of model.columnIds) {
const col = model.columns[colId];
const colEl = buildColumnElement(col, model.cards, filterState);
columnContainerEl.appendChild(colEl);
}
updateColumnContainerStyle();
wireAllDrag();
}
function wireAllDrag() {
// Wire card drag
const cards = columnContainerEl.querySelectorAll('.kanban-card:not(.drag-ghost):not(.drag-placeholder)');
cards.forEach(card => {
const cardId = card.dataset.cardId;
const colId = card.dataset.columnId;
if (cardId && colId) {
(0, dragdrop_1.initCardDrag)(card, cardId, colId);
}
});
// Wire column drag
const cols = columnContainerEl.querySelectorAll('.kanban-column');
cols.forEach(col => {
const colId = col.dataset.columnId;
if (colId) {
(0, dragdrop_1.initColumnDrag)(col, colId);
}
});
}
function updateColumnContainerStyle() {
const model = (0, state_1.getModel)();
const count = model.columnIds.length;
columnContainerEl.style.minWidth = `${count * 280}px`;
}
// ─── Column Element ────────────────────────────────────────────────
function buildColumnElement(col, cards, filterState) {
const colEl = document.createElement('div');
colEl.className = 'kanban-column';
colEl.dataset.columnId = col.id;
// Check WIP
const wipExceeded = col.wipLimit !== null && col.cardIds.length > col.wipLimit;
if (wipExceeded)
colEl.classList.add('wip-exceeded');
// Header
const header = document.createElement('div');
header.className = 'column-header';
const titleGroup = document.createElement('div');
titleGroup.className = 'column-title-group';
const title = document.createElement('span');
title.className = 'column-title';
title.textContent = col.title;
const count = document.createElement('span');
count.className = 'column-count';
count.textContent = String(col.cardIds.length);
// WIP indicator
if (col.wipLimit !== null) {
const wipBadge = document.createElement('span');
wipBadge.className = 'wip-badge';
wipBadge.textContent = `/${col.wipLimit}`;
if (wipExceeded)
wipBadge.classList.add('wip-exceeded-badge');
count.appendChild(wipBadge);
}
titleGroup.appendChild(title);
titleGroup.appendChild(count);
// Menu button
const menuBtn = document.createElement('button');
menuBtn.className = 'column-menu-btn';
menuBtn.innerHTML = '•••';
menuBtn.title = 'Column options';
header.appendChild(titleGroup);
header.appendChild(menuBtn);
colEl.appendChild(header);
// Card list
const cardList = document.createElement('div');
cardList.className = 'card-list';
cardList.dataset.columnId = col.id;
const filteredCardIds = filterCards(col.cardIds, cards, filterState);
if (col.cardIds.length === 0) {
// Empty column drop zone
const emptyZone = document.createElement('div');
emptyZone.className = 'empty-column-zone';
emptyZone.textContent = 'Drop cards here';
cardList.appendChild(emptyZone);
}
for (const cardId of filteredCardIds) {
const card = cards[cardId];
if (!card)
continue;
const cardEl = buildCardElement(card, col.id);
cardList.appendChild(cardEl);
}
colEl.appendChild(cardList);
// Add card button
const addBtn = document.createElement('button');
addBtn.className = 'add-card-btn';
addBtn.textContent = '+ Add a card';
addBtn.dataset.columnId = col.id;
colEl.appendChild(addBtn);
// ─── Column Menu ───
setupColumnMenu(menuBtn, col, colEl);
// ─── Add Card ───
addBtn.addEventListener('click', () => showAddCardForm(col.id, colEl, addBtn));
return colEl;
}
// ─── Card Element ──────────────────────────────────────────────────
function buildCardElement(card, columnId) {
const cardEl = document.createElement('div');
cardEl.className = 'kanban-card';
cardEl.dataset.cardId = card.id;
cardEl.dataset.columnId = columnId;
// Labels
if (card.labels.length > 0) {
const labelRow = document.createElement('div');
labelRow.className = 'card-labels';
for (const label of card.labels) {
const chip = document.createElement('span');
chip.className = 'card-label';
chip.style.backgroundColor = label.color;
chip.style.color = getContrastColor(label.color);
chip.textContent = label.name;
labelRow.appendChild(chip);
}
cardEl.appendChild(labelRow);
}
// Title
const titleEl = document.createElement('div');
titleEl.className = 'card-title';
titleEl.textContent = card.title;
titleEl.title = card.description || '';
cardEl.appendChild(titleEl);
// Meta row
const metaRow = document.createElement('div');
metaRow.className = 'card-meta';
// Due date
if (card.dueDate) {
const dueEl = document.createElement('span');
dueEl.className = 'card-due-date';
const d = new Date(card.dueDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
const dueDate = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const daysDiff = Math.round((dueDate.getTime() - today.getTime()) / 86400000);
dueEl.textContent = `${d.getMonth() + 1}/${d.getDate()}`;
if (daysDiff < 0)
dueEl.classList.add('due-overdue');
else if (daysDiff <= 1)
dueEl.classList.add('due-today');
metaRow.appendChild(dueEl);
}
if (metaRow.children.length > 0) {
cardEl.appendChild(metaRow);
}
// Context menu on right-click
cardEl.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
showCardContextMenu(e, card);
});
// Double-click to inline-edit title
cardEl.addEventListener('dblclick', (e) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
return;
startInlineTitleEdit(cardEl, card);
});
return cardEl;
}
// ─── Add Card Form ─────────────────────────────────────────────────
function showAddCardForm(columnId, colEl, addBtn) {
const cardList = colEl.querySelector('.card-list');
const formEl = document.createElement('div');
formEl.className = 'add-card-form';
const textarea = document.createElement('textarea');
textarea.placeholder = 'Enter a title...';
textarea.style.cssText = `
width: 100%; min-height: 40px; border: 2px solid ${types_1.COLORS.border};
border-radius: 8px; padding: 8px 12px; font-size: 14px; resize: none;
font-family: inherit; outline: none; background: #fff;
transition: border-color 0.15s;
`;
const actionRow = document.createElement('div');
actionRow.style.cssText = 'display: flex; gap: 8px; margin-top: 8px; align-items: center;';
const addBtnInner = document.createElement('button');
addBtnInner.className = 'add-card-confirm-btn';
addBtnInner.textContent = 'Add card';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = '\u00d7';
cancelBtn.className = 'cancel-btn';
actionRow.appendChild(addBtnInner);
actionRow.appendChild(cancelBtn);
formEl.appendChild(textarea);
formEl.appendChild(actionRow);
addBtn.style.display = 'none';
cardList.appendChild(formEl);
textarea.focus();
const removeForm = () => {
formEl.remove();
addBtn.style.display = '';
};
const add = () => {
const title = textarea.value.trim();
if (title) {
(0, state_1.addCard)(columnId, { title, description: '', labels: [], dueDate: '' });
}
removeForm();
};
addBtnInner.addEventListener('click', add);
cancelBtn.addEventListener('click', removeForm);
textarea.addEventListener('focus', () => { textarea.style.borderColor = types_1.COLORS.accent; });
textarea.addEventListener('blur', () => { textarea.style.borderColor = types_1.COLORS.border; });
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
add();
}
if (e.key === 'Escape') {
e.preventDefault();
removeForm();
}
});
}
// ─── Column Menu ───────────────────────────────────────────────────
function setupColumnMenu(menuBtn, col, colEl) {
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
removeContextMenu();
showColumnContextMenu(e, menuBtn, col, colEl);
});
// Column title double-click to rename
const titleEl = colEl.querySelector('.column-title');
if (titleEl) {
titleEl.addEventListener('dblclick', (e) => {
e.preventDefault();
e.stopPropagation();
startEditColumnTitle(colEl, col);
});
}
}
function showColumnContextMenu(e, menuBtn, col, colEl) {
removeContextMenu();
const menu = document.createElement('div');
menu.className = 'context-menu';
const rect = menuBtn.getBoundingClientRect();
menu.style.left = `${rect.left - 160}px`;
menu.style.top = `${rect.bottom + 4}px`;
// Rename
const renameBtn = document.createElement('button');
renameBtn.className = 'context-menu-item';
renameBtn.textContent = 'Rename column';
renameBtn.addEventListener('click', () => {
removeContextMenu();
startEditColumnTitle(colEl, col);
});
menu.appendChild(renameBtn);
// WIP Limit section
const wipSection = document.createElement('div');
wipSection.className = 'context-menu-section';
const wipLabel = document.createElement('div');
wipLabel.className = 'context-menu-label';
wipLabel.textContent = 'WIP LIMIT';
wipSection.appendChild(wipLabel);
for (let n = 0; n <= 10; n++) {
const optBtn = document.createElement('button');
optBtn.className = 'context-menu-item';
if (n === 0) {
optBtn.textContent = 'No limit';
if (col.wipLimit === null)
optBtn.classList.add('active');
}
else {
optBtn.textContent = String(n);
if (col.wipLimit === n)
optBtn.classList.add('active');
}
optBtn.addEventListener('click', () => {
removeContextMenu();
(0, state_1.updateColumnWip)(col.id, n === 0 ? null : n);
});
wipSection.appendChild(optBtn);
}
menu.appendChild(wipSection);
// Delete
const deleteBtn = document.createElement('button');
deleteBtn.className = 'context-menu-item danger';
deleteBtn.textContent = 'Delete column';
deleteBtn.addEventListener('click', () => {
removeContextMenu();
(0, state_1.deleteColumn)(col.id);
});
menu.appendChild(deleteBtn);
document.body.appendChild(menu);
setTimeout(() => {
const handler = () => { removeContextMenu(); document.removeEventListener('click', handler); };
document.addEventListener('click', handler);
}, 0);
}
function startEditColumnTitle(colEl, col) {
const titleEl = colEl.querySelector('.column-title');
if (!titleEl || titleEl.querySelector('input'))
return;
const input = document.createElement('input');
input.type = 'text';
input.value = col.title;
input.className = 'column-edit-input';
titleEl.replaceWith(input);
input.focus();
input.select();
const commit = () => {
const newTitle = input.value.trim();
if (newTitle)
(0, state_1.renameColumn)(col.id, newTitle);
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
commit();
}
if (e.key === 'Escape') {
e.preventDefault();
}
});
input.addEventListener('blur', commit);
}
// ─── Card Context Menu ────────────────────────────────────────────
function showCardContextMenu(e, card) {
removeContextMenu();
const menu = document.createElement('div');
menu.className = 'context-menu';
menu.style.left = `${e.clientX}px`;
menu.style.top = `${e.clientY}px`;
const editBtn = document.createElement('button');
editBtn.className = 'context-menu-item';
editBtn.textContent = 'Edit card';
editBtn.addEventListener('click', () => { removeContextMenu(); openCardEditModal(card); });
menu.appendChild(editBtn);
const deleteBtn = document.createElement('button');
deleteBtn.className = 'context-menu-item danger';
deleteBtn.textContent = 'Delete card';
deleteBtn.addEventListener('click', () => { removeContextMenu(); (0, state_1.deleteCard)(card.id); });
menu.appendChild(deleteBtn);
document.body.appendChild(menu);
setTimeout(() => {
const handler = () => { removeContextMenu(); document.removeEventListener('click', handler); };
document.addEventListener('click', handler);
}, 0);
}
// ─── Card Edit Modal ──────────────────────────────────────────────
function openCardEditModal(card) {
// Remove existing
closeCardEditModal();
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.cssText = `
position: fixed; inset: 0; z-index: 20000;
background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;
`;
const modal = document.createElement('div');
modal.className = 'card-edit-modal';
modal.style.cssText = `
background: #fff; border-radius: 12px;
box-shadow: 0 8px 40px rgba(0,0,0,0.25);
width: 480px; max-height: 80vh; overflow-y: auto;
padding: 0;
`;
// Header
const header = document.createElement('div');
header.style.cssText = 'padding: 20px 24px 0;';
const titleInput = document.createElement('input');
titleInput.type = 'text';
titleInput.value = card.title;
titleInput.placeholder = 'Card title';
titleInput.style.cssText = `
width: 100%; font-size: 20px; font-weight: 700;
border: none; outline: none; color: #172b4d;
padding: 4px 0; font-family: inherit;
`;
header.appendChild(titleInput);
modal.appendChild(header);
// Body
const body = document.createElement('div');
body.style.cssText = 'padding: 16px 24px 24px;';
// Description
const descLabel = document.createElement('label');
descLabel.textContent = 'Description';
descLabel.style.cssText = 'display: block; font-size: 12px; font-weight: 600; color: #5e6c84; margin-bottom: 6px; margin-top: 16px;';
body.appendChild(descLabel);
const descArea = document.createElement('textarea');
descArea.value = card.description;
descArea.placeholder = 'Type a description...';
descArea.style.cssText = `
width: 100%; min-height: 80px; border: 2px solid #ddd;
border-radius: 8px; padding: 10px 12px; font-size: 14px;
resize: vertical; font-family: inherit; outline: none;
transition: border-color 0.15s;
`;
descArea.addEventListener('focus', () => descArea.style.borderColor = '#0079bf');
descArea.addEventListener('blur', () => descArea.style.borderColor = '#ddd');
body.appendChild(descArea);
// Due date
const dueLabel = document.createElement('label');
dueLabel.textContent = 'Due date';
dueLabel.style.cssText = 'display: block; font-size: 12px; font-weight: 600; color: #5e6c84; margin-bottom: 6px; margin-top: 16px;';
body.appendChild(dueLabel);
const dueInput = document.createElement('input');
dueInput.type = 'date';
dueInput.value = card.dueDate ? card.dueDate.slice(0, 10) : '';
dueInput.style.cssText = `
width: 100%; padding: 8px 12px; border: 2px solid #ddd;
border-radius: 8px; font-size: 14px; font-family: inherit;
outline: none; transition: border-color 0.15s;
`;
dueInput.addEventListener('focus', () => dueInput.style.borderColor = '#0079bf');
dueInput.addEventListener('blur', () => dueInput.style.borderColor = '#ddd');
body.appendChild(dueInput);
// Labels
const labelsLabel = document.createElement('label');
labelsLabel.textContent = 'Labels';
labelsLabel.style.cssText = 'display: block; font-size: 12px; font-weight: 600; color: #5e6c84; margin-bottom: 8px; margin-top: 16px;';
body.appendChild(labelsLabel);
const labelsContainer = document.createElement('div');
labelsContainer.style.cssText = 'display: flex; flex-wrap: wrap; gap: 6px;';
for (const label of types_1.LABEL_PALETTE) {
const isSelected = card.labels.some(l => l.name === label.name);
const chip = document.createElement('button');
chip.className = 'modal-label-chip';
chip.textContent = label.name;
chip.dataset.labelName = label.name;
chip.style.cssText = `
padding: 4px 10px; border-radius: 6px;
border: 2px solid ${isSelected ? label.color : '#ddd'};
background: ${isSelected ? label.color : 'transparent'};
color: ${isSelected ? '#fff' : '#5e6c84'};
font-size: 12px; font-weight: 600; cursor: pointer;
transition: all 0.1s;
`;
chip.addEventListener('click', () => {
const selected = card.labels.some(l => l.name === label.name);
if (selected) {
chip.style.borderColor = '#ddd';
chip.style.background = 'transparent';
chip.style.color = '#5e6c84';
}
else {
chip.style.borderColor = label.color;
chip.style.background = label.color;
chip.style.color = '#fff';
}
});
labelsContainer.appendChild(chip);
}
body.appendChild(labelsContainer);
modal.appendChild(body);
// Footer
const footer = document.createElement('div');
footer.style.cssText = 'padding: 0 24px 20px; display: flex; justify-content: flex-end; gap: 8px;';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Cancel';
cancelBtn.style.cssText = `
padding: 8px 16px; border: 2px solid #ddd; border-radius: 6px;
background: #fff; color: #5e6c84; font-size: 14px; cursor: pointer;
font-family: inherit; font-weight: 500;
`;
cancelBtn.addEventListener('click', closeCardEditModal);
const saveBtn = document.createElement('button');
saveBtn.textContent = 'Save';
saveBtn.style.cssText = `
padding: 8px 20px; border: none; border-radius: 6px;
background: #0079bf; color: #fff; font-size: 14px; cursor: pointer;
font-family: inherit; font-weight: 600;
`;
saveBtn.addEventListener('click', () => {
const newLabels = [];
labelsContainer.querySelectorAll('.modal-label-chip').forEach(chip => {
const bg = chip.style.background;
const name = chip.dataset.labelName;
if (name && bg !== 'transparent' && bg !== 'rgba(0, 0, 0, 0)' && bg !== '') {
const def = types_1.LABEL_PALETTE.find(l => l.name === name);
if (def)
newLabels.push({ name: def.name, color: def.color });
}
});
(0, state_1.updateCard)(card.id, {
title: titleInput.value.trim() || card.title,
description: descArea.value.trim(),
dueDate: dueInput.value ? new Date(dueInput.value).toISOString() : '',
labels: newLabels,
});
closeCardEditModal();
});
footer.appendChild(cancelBtn);
footer.appendChild(saveBtn);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
// Click outside to close
overlay.addEventListener('click', (e) => {
if (e.target === overlay)
closeCardEditModal();
});
// Escape to close
const keyHandler = (e) => {
if (e.key === 'Escape')
closeCardEditModal();
if (e.key === 'Enter' && document.activeElement !== descArea) {
saveBtn.click();
}
};
document.addEventListener('keydown', keyHandler);
overlay.dataset.keyHandlerAttached = 'true';
// Focus title
setTimeout(() => { titleInput.focus(); titleInput.select(); }, 50);
window._modalKeyHandler = keyHandler;
}
function closeCardEditModal() {
const overlay = document.querySelector('.modal-overlay');
if (overlay) {
if (window._modalKeyHandler) {
document.removeEventListener('keydown', window._modalKeyHandler);
}
overlay.remove();
}
}
// ─── Inline Title Edit ────────────────────────────────────────────
function startInlineTitleEdit(cardEl, card) {
const titleEl = cardEl.querySelector('.card-title');
if (!titleEl || titleEl.querySelector('input'))
return;
const input = document.createElement('input');
input.type = 'text';
input.value = card.title;
input.className = 'column-edit-input';
titleEl.replaceWith(input);
input.focus();
input.select();
const commit = () => {
const newTitle = input.value.trim();
if (newTitle && newTitle !== card.title) {
(0, state_1.updateCard)(card.id, { title: newTitle });
}
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
commit();
}
if (e.key === 'Escape') {
e.preventDefault();
}
});
input.addEventListener('blur', commit);
}
// ─── Label Filters ─────────────────────────────────────────────────
function renderLabelFilters() {
labelFilterBar.innerHTML = '';
for (const label of types_1.LABEL_PALETTE) {
const chip = document.createElement('button');
chip.className = 'label-filter-chip';
chip.dataset.labelName = label.name;
chip.textContent = label.name;
chip.title = `Filter by "${label.name}"`;
labelFilterBar.appendChild(chip);
}
}
// ─── Helpers ───────────────────────────────────────────────────────
function filterCards(cardIds, cards, filterState) {
return cardIds.filter(cardId => {
const card = cards[cardId];
if (!card)
return false;
if (filterState.search) {
if (!card.title.toLowerCase().includes(filterState.search.toLowerCase()))
return false;
}
if (filterState.labelFilters.length > 0) {
const cardLabelNames = new Set(card.labels.map(l => l.name));
if (!filterState.labelFilters.some(fn => cardLabelNames.has(fn)))
return false;
}
return true;
});
}
function removeContextMenu() {
document.querySelectorAll('.context-menu').forEach(el => el.remove());
}
function getContrastColor(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum > 0.5 ? '#172b4d' : '#ffffff';
}
};
// ── module: src/dragdrop.ts ──
__mods["src/dragdrop.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.initCardDrag = initCardDrag;
exports.initColumnDrag = initColumnDrag;
exports.subscribe = subscribe;
exports.isDragging = isDragging;
exports.getDragInfo = getDragInfo;
const state_1 = require("./state");
const types_1 = require("./types");
let info = { active: false, kind: 'card', dragOffsetX: 0, dragOffsetY: 0 };
let ghostEl = null;
let placeholderEl = null;
let colGhostEl = null;
let dragSourceEl = null; // the original card/column element
const listeners = new Set();
// ─── Card Drag ─────────────────────────────────────────────────────
function initCardDrag(cardEl, cardId, columnId) {
// Prevent duplicate handlers by marking
if (cardEl.dataset.dragInitialized === 'true')
return;
cardEl.dataset.dragInitialized = 'true';
cardEl.addEventListener('mousedown', (e) => {
if (e.button !== 0)
return;
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLButtonElement || e.target instanceof HTMLSelectElement)
return;
if (e.detail >= 2)
return; // ignore double-click
const cardRect = cardEl.getBoundingClientRect();
const model = (0, state_1.getModel)();
const colData = model.columns[columnId];
const sourceIdx = colData ? colData.cardIds.indexOf(cardId) : -1;
if (sourceIdx < 0)
return;
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
let started = false;
const preMove = (ev) => {
if (started)
return;
const dx = Math.abs(ev.clientX - startX);
const dy = Math.abs(ev.clientY - startY);
if (dx < types_1.DRAG_START_THRESHOLD && dy < types_1.DRAG_START_THRESHOLD)
return;
started = true;
info = {
active: true, kind: 'card',
cardId, sourceColumnId: columnId, sourceCardIndex: sourceIdx,
originalModel: JSON.parse(JSON.stringify(model)),
dragOffsetX: ev.clientX - cardRect.left,
dragOffsetY: ev.clientY - cardRect.top,
targetColumnId: columnId,
targetCardIndex: sourceIdx,
sourceCardEl: cardEl,
};
dragSourceEl = cardEl;
makeCardGhost(cardEl, ev.clientX, ev.clientY);
// Show placeholder at original position
showPlaceholderAt(cardEl);
document.addEventListener('mousemove', activeMove);
document.addEventListener('mouseup', activeUp);
document.addEventListener('keydown', activeKey);
notify();
};
const preUp = () => {
document.removeEventListener('mousemove', preMove);
document.removeEventListener('mouseup', preUp);
};
document.addEventListener('mousemove', preMove);
document.addEventListener('mouseup', preUp);
});
}
function makeCardGhost(cardEl, x, y) {
const d = info;
// Clone without inner edit inputs
const clone = cardEl.cloneNode(true);
const editInput = clone.querySelector('input, textarea');
if (editInput)
editInput.remove();
ghostEl = clone;
ghostEl.className = 'kanban-card drag-ghost';
ghostEl.style.cssText = '';
Object.assign(ghostEl.style, {
position: 'fixed',
zIndex: '9999',
pointerEvents: 'none',
width: `${cardEl.offsetWidth}px`,
left: `${x - d.dragOffsetX}px`,
top: `${y - d.dragOffsetY}px`,
boxShadow: types_1.COLORS.cardShadowDrag,
transform: 'rotate(-2deg) scale(1.03)',
opacity: '0.95',
transition: 'none',
margin: '0',
background: '#fff',
borderRadius: '8px',
});
document.body.appendChild(ghostEl);
}
function showPlaceholderAt(cardEl) {
if (placeholderEl) {
placeholderEl.remove();
placeholderEl = null;
}
placeholderEl = document.createElement('div');
placeholderEl.className = 'kanban-card drag-placeholder';
placeholderEl.style.cssText = `
height: ${cardEl.offsetHeight}px;
border: 2px dashed ${types_1.COLORS.dropIndicator};
border-radius: 8px;
background: ${types_1.COLORS.placeholderBg};
margin-bottom: 8px;
pointer-events: none;
transition: height 0.1s ease;
`;
const parent = cardEl.parentElement;
if (parent) {
parent.insertBefore(placeholderEl, cardEl);
}
}
// ─── Active Drag ───────────────────────────────────────────────────
let rafId = 0;
let pendingMove = null;
function activeMove(e) {
const d = info;
if (!d.active || d.kind !== 'card')
return;
// Move ghost synchronously for immediate visual feedback
if (ghostEl) {
ghostEl.style.left = `${e.clientX - d.dragOffsetX}px`;
ghostEl.style.top = `${e.clientY - d.dragOffsetY}px`;
}
// Hit test synchronously — works with both real dragging and programmatic dispatch
hitTestCardDrop(e.clientX, e.clientY);
// Auto-scroll via rAF (continuous action)
pendingMove = { x: e.clientX, y: e.clientY };
if (!rafId)
rafId = requestAnimationFrame(autoScrollFrame);
}
function autoScrollFrame() {
rafId = 0;
if (pendingMove) {
autoScroll(pendingMove.x, pendingMove.y);
// Continue if still dragging
const d = info;
if (d.active && d.kind === 'card') {
rafId = requestAnimationFrame(autoScrollFrame);
}
}
}
function activeUp(e) {
const d = info;
if (!d.active || d.kind !== 'card')
return;
document.removeEventListener('mousemove', activeMove);
document.removeEventListener('mouseup', activeUp);
document.removeEventListener('keydown', activeKey);
// Execute move
if (d.targetColumnId) {
if (d.targetColumnId !== d.sourceColumnId || d.targetCardIndex !== d.sourceCardIndex) {
(0, state_1.moveCard)(d.cardId, d.sourceColumnId, d.sourceCardIndex, d.targetColumnId, d.targetCardIndex);
}
// If same position, just do nothing — model is unchanged
}
clearDrag();
}
function activeKey(e) {
if (e.key === 'Escape')
cancelCardDrag();
}
function cancelCardDrag() {
document.removeEventListener('mousemove', activeMove);
document.removeEventListener('mouseup', activeUp);
document.removeEventListener('keydown', activeKey);
// Model was never changed, so no rollback needed
clearDrag();
}
function clearDrag() {
if (ghostEl) {
ghostEl.remove();
ghostEl = null;
}
if (placeholderEl) {
placeholderEl.remove();
placeholderEl = null;
}
if (colGhostEl) {
colGhostEl.remove();
colGhostEl = null;
}
dragSourceEl = null;
info = { active: false, kind: 'card', dragOffsetX: 0, dragOffsetY: 0 };
notify();
}
// ─── Auto-scroll ───────────────────────────────────────────────────
function autoScroll(x, y) {
const winW = window.innerWidth;
if (x < types_1.AUTO_SCROLL_MARGIN)
window.scrollBy(-types_1.AUTO_SCROLL_SPEED, 0);
else if (x > winW - types_1.AUTO_SCROLL_MARGIN)
window.scrollBy(types_1.AUTO_SCROLL_SPEED, 0);
// Vertical scroll within the target column
const col = findColumnAt(x, y);
if (col) {
const cardList = col.querySelector('.card-list');
if (cardList) {
const rect = cardList.getBoundingClientRect();
if (y < rect.top + types_1.AUTO_SCROLL_MARGIN && cardList.scrollTop > 0) {
cardList.scrollTop -= types_1.AUTO_SCROLL_SPEED;
}
else if (y > rect.bottom - types_1.AUTO_SCROLL_MARGIN) {
cardList.scrollTop += types_1.AUTO_SCROLL_SPEED;
}
}
}
}
// ─── Hit Testing ───────────────────────────────────────────────────
function findColumnAt(x, y) {
if (ghostEl)
ghostEl.style.display = 'none';
if (placeholderEl)
placeholderEl.style.display = 'none';
const el = document.elementFromPoint(x, y);
if (ghostEl)
ghostEl.style.display = '';
if (placeholderEl)
placeholderEl.style.display = '';
if (!el)
return null;
return el.closest('.kanban-column') ?? null;
}
function hitTestCardDrop(x, y) {
const d = info;
if (!d.active || d.kind !== 'card')
return;
// Remove old placeholder
if (placeholderEl) {
placeholderEl.remove();
placeholderEl = null;
}
const col = findColumnAt(x, y);
if (!col)
return;
const colId = col.dataset.columnId;
if (!colId)
return;
const cardList = col.querySelector('.card-list');
if (!cardList)
return;
// Get visible card elements (not ghost, not placeholder, not form, not hidden by filter)
const allCardEls = Array.from(cardList.querySelectorAll('.kanban-card:not(.drag-ghost):not(.drag-placeholder)'));
const model = (0, state_1.getModel)();
const colData = model.columns[colId];
if (!colData)
return;
// Only cards that exist in this column's model
const validCards = allCardEls.filter(el => {
const cid = el.dataset.cardId;
return cid && colData.cardIds.includes(cid);
});
let insertIdx = validCards.length; // default: append at end
for (let i = 0; i < validCards.length; i++) {
const rect = validCards[i].getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (y < midY) {
insertIdx = i;
break;
}
}
// Hide empty zone
const emptyZone = cardList.querySelector('.empty-column-zone');
if (emptyZone)
emptyZone.style.display = 'none';
// Create and insert placeholder
const ph = document.createElement('div');
ph.className = 'kanban-card drag-placeholder';
ph.style.cssText = `
height: 44px; border: 2px dashed ${types_1.COLORS.dropIndicator};
border-radius: 8px; background: ${types_1.COLORS.placeholderBg};
margin-bottom: 8px; pointer-events: none;
`;
if (insertIdx < validCards.length) {
cardList.insertBefore(ph, validCards[insertIdx]);
}
else {
cardList.appendChild(ph);
}
placeholderEl = ph;
d.targetColumnId = colId;
d.targetCardIndex = insertIdx;
}
// ─── Column Drag ───────────────────────────────────────────────────
function initColumnDrag(colEl, columnId) {
if (colEl.dataset.colDragInitialized === 'true')
return;
colEl.dataset.colDragInitialized = 'true';
const header = colEl.querySelector('.column-header');
if (!header)
return;
header.style.cursor = 'grab';
header.addEventListener('mousedown', (e) => {
if (e.button !== 0)
return;
if (e.target instanceof HTMLButtonElement)
return;
if (e.detail >= 2)
return;
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
let started = false;
const model = (0, state_1.getModel)();
const sourceIdx = model.columnIds.indexOf(columnId);
if (sourceIdx < 0)
return;
const preMove = (ev) => {
if (started)
return;
const dx = Math.abs(ev.clientX - startX);
const dy = Math.abs(ev.clientY - startY);
if (dx < types_1.DRAG_START_THRESHOLD && dy < types_1.DRAG_START_THRESHOLD)
return;
started = true;
info = { active: true, kind: 'column', columnId, targetIndex: sourceIdx };
makeColumnGhost(colEl, ev.clientX, ev.clientY);
document.addEventListener('mousemove', colDragMove);
document.addEventListener('mouseup', colDragUp);
document.addEventListener('keydown', colDragKey);
notify();
};
const preUp = () => {
document.removeEventListener('mousemove', preMove);
document.removeEventListener('mouseup', preUp);
};
document.addEventListener('mousemove', preMove);
document.addEventListener('mouseup', preUp);
});
}
function makeColumnGhost(colEl, x, y) {
const header = colEl.querySelector('.column-header');
if (!header)
return;
colGhostEl = header.cloneNode(true);
colGhostEl.style.cssText = `
position: fixed; z-index: 10000; pointer-events: none;
width: ${colEl.offsetWidth}px; left: ${x - 40}px; top: ${y - 10}px;
opacity: 0.85; transform: rotate(-3deg); padding: 8px 12px;
background: #fff; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
`;
document.body.appendChild(colGhostEl);
}
function colDragMove(e) {
const d = info;
if (!d.active || d.kind !== 'column')
return;
if (colGhostEl) {
colGhostEl.style.left = `${e.clientX - 40}px`;
colGhostEl.style.top = `${e.clientY - 10}px`;
}
const cols = Array.from(document.querySelectorAll('.kanban-column'));
if (colGhostEl)
colGhostEl.style.display = 'none';
let insertIdx = cols.length;
for (let i = 0; i < cols.length; i++) {
const rect = cols[i].getBoundingClientRect();
const midX = rect.left + rect.width / 2;
if (e.clientX < midX) {
insertIdx = i;
break;
}
}
if (colGhostEl)
colGhostEl.style.display = '';
d.targetIndex = insertIdx;
}
function colDragUp(e) {
const d = info;
if (!d.active || d.kind !== 'column')
return;
document.removeEventListener('mousemove', colDragMove);
document.removeEventListener('mouseup', colDragUp);
document.removeEventListener('keydown', colDragKey);
const model = (0, state_1.getModel)();
const origIdx = model.columnIds.indexOf(d.columnId);
if (origIdx >= 0 && d.targetIndex !== origIdx) {
(0, state_1.reorderColumn)(d.columnId, d.targetIndex);
}
clearDrag();
}
function colDragKey(e) {
if (e.key === 'Escape') {
document.removeEventListener('mousemove', colDragMove);
document.removeEventListener('mouseup', colDragUp);
document.removeEventListener('keydown', colDragKey);
clearDrag();
}
}
// ─── Subscribe ─────────────────────────────────────────────────────
function subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
function notify() {
for (const fn of listeners)
fn();
}
function isDragging() {
return info.active;
}
function getDragInfo() {
return info;
}
};
// ── module: src/toasts.ts ──
__mods["src/toasts.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.initToasts = initToasts;
exports.renderToasts = renderToasts;
const state_1 = require("./state");
let container;
function initToasts(root) {
container = root.querySelector('#toast-container');
}
function renderToasts() {
container.innerHTML = '';
const toasts = (0, state_1.getToasts)();
for (const t of toasts) {
const el = document.createElement('div');
el.className = 'toast';
const msg = document.createElement('span');
msg.textContent = t.message;
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display: flex; gap: 8px;';
if (t.undoable && t.undoFn) {
const undoBtn = document.createElement('button');
undoBtn.className = 'toast-undo';
undoBtn.textContent = 'Undo';
undoBtn.addEventListener('click', () => {
t.undoFn();
(0, state_1.dismissToast)(t.id);
renderToasts();
});
btnRow.appendChild(undoBtn);
}
const dismissBtn = document.createElement('button');
dismissBtn.className = 'toast-dismiss';
dismissBtn.textContent = '✕';
dismissBtn.addEventListener('click', () => { (0, state_1.dismissToast)(t.id); renderToasts(); });
btnRow.appendChild(dismissBtn);
el.appendChild(msg);
el.appendChild(btnRow);
container.appendChild(el);
}
}
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.6-27B-MTP-GGUF","provider":"brigid","persona":"typescript","sessionId":"88a524ae-fdbd-412f-a8d4-c91a0f4efefc","tokensIn":8139175,"tokensOut":88667,"tokensTotal":8227842,"cost":0,"turns":129,"toolCalls":130,"failedToolCalls":1,"timestamp":"2026-08-02T10:15:58.004Z"} -->