Kanban
Qwen 3.6 27b MTP · typescript

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 76,419 bytes · SHA-256 d5a3a23e42c7
<!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;
font-size: 14px;
line-height: 1.5;
}
#app {
height: 100%;
display: flex;
flex-direction: column;
}
#board-container {
flex: 1;
overflow-x: auto;
overflow-y: hidden;
padding: 20px;
scroll-behavior: smooth;
}
#toolbar {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 24px;
background: #fff;
border-bottom: 1px solid #dfe1e6;
z-index: 10;
}
#board {
display: flex;
gap: 12px;
height: calc(100vh - 57px);
align-items: flex-start;
padding-bottom: 20px;
}
/* Column wrapper */
.column-wrapper {
min-width: 280px;
max-width: 280px;
background: #ebecf0;
border-radius: 12px;
display: flex;
flex-direction: column;
max-height: calc(100vh - 97px);
}
.column-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 12px 8px;
cursor: grab;
user-select: none;
}
.column-header:active {
cursor: grabbing;
}
.column-title {
font-weight: 600;
font-size: 14px;
color: #172b4d;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-count {
background: rgba(0,0,0,0.08);
border-radius: 10px;
padding: 2px 8px;
font-size: 12px;
color: #5e6c84;
margin-left: 8px;
}
.column-menu-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
font-size: 16px;
color: #5e6c84;
border-radius: 4px;
}
.column-menu-btn:hover {
background: rgba(0,0,0,0.08);
}
/* Card list */
.card-list {
padding: 4px 8px;
overflow-y: auto;
flex: 1;
min-height: 40px;
scroll-behavior: smooth;
}
.card-list::-webkit-scrollbar {
width: 6px;
}
.card-list::-webkit-scrollbar-track {
background: transparent;
}
.card-list::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.15);
border-radius: 3px;
}
/* Add card button */
.add-card-btn {
margin: 4px 8px 8px;
padding: 8px 12px;
background: none;
border: none;
border-radius: 6px;
cursor: pointer;
text-align: left;
color: #5e6c84;
font-size: 13px;
}
.add-card-btn:hover {
background: rgba(0,0,0,0.06);
color: #172b4d;
}
/* Add column button */
.add-column-btn {
min-width: 280px;
max-width: 280px;
}
.add-column-btn button {
width: 100%;
padding: 12px;
background: rgba(255,255,255,0.25);
border: none;
border-radius: 12px;
cursor: pointer;
text-align: left;
color: #5e6c84;
font-size: 14px;
}
.add-column-btn button:hover {
background: rgba(255,255,255,0.5);
}
/* Empty column zone */
.empty-column-zone {
border: 2px dashed #c4c9cc;
border-radius: 8px;
padding: 20px;
text-align: center;
color: #a5adba;
font-size: 13px;
margin: 4px 0;
}
/* WIP exceeded */
.card-list.wip-exceeded {
outline: 2px solid rgba(235, 90, 70, 0.4);
outline-offset: -2px;
border-radius: 8px;
}
/* ── css: cards ── */
/* Card */
.card {
background: #fff;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 8px;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
cursor: grab;
transition: transform 0.15s ease, box-shadow 0.15s ease;
position: relative;
}
.card:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
transform: translateY(-1px);
}
.card:active {
cursor: grabbing;
}
.card-hidden {
display: none;
}
.drag-source-hidden {
opacity: 0.3 !important;
pointer-events: none !important;
}
/* Card labels */
.card-labels {
display: flex;
gap: 4px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.card-label-chip {
font-size: 11px;
padding: 2px 8px;
border-radius: 3px;
border-width: 1px;
border-style: solid;
font-weight: 500;
}
/* Card title */
.card-title {
font-size: 14px;
color: #172b4d;
margin-bottom: 4px;
word-break: break-word;
}
/* Card description */
.card-desc {
font-size: 12px;
color: #5e6c84;
margin-bottom: 4px;
}
/* Card due date */
.card-due-date {
font-size: 12px;
color: #5e6c84;
}
.card-due-date.overdue {
color: #eb5a46;
font-weight: 600;
}
/* Card actions */
.card-actions {
position: absolute;
top: 6px;
right: 6px;
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s ease;
}
.card:hover .card-actions {
opacity: 1;
}
.card-action-btn {
background: rgba(0,0,0,0.06);
border: none;
cursor: pointer;
padding: 2px 6px;
font-size: 12px;
border-radius: 4px;
color: #5e6c84;
}
.card-action-btn:hover {
background: rgba(0,0,0,0.12);
color: #172b4d;
}
/* Drag ghost */
.drag-ghost {
position: fixed !important;
z-index: 1000 !important;
pointer-events: none !important;
background: #fff !important;
border-radius: 8px !important;
box-shadow: 0 8px 25px rgba(0,0,0,0.15), 0 4px 10px rgba(0,0,0,0.1) !important;
}
.drag-column-ghost {
background: #ebecf0 !important;
border-radius: 8px !important;
padding: 8px 12px !important;
display: flex !important;
align-items: center !important;
}
/* Drop placeholder */
.drop-placeholder {
background: rgba(0, 121, 191, 0.1);
border: 2px dashed #0079bf;
border-radius: 8px;
margin-bottom: 8px;
min-height: 40px;
transition: height 0.15s ease;
}
.column-placeholder {
width: 4px;
background: #0079bf;
border-radius: 2px;
margin-right: 8px;
min-height: 60px;
}
/* Column drop target highlight */
.column-drop-target .card-list {
outline: 2px solid rgba(0, 121, 191, 0.3);
border-radius: 8px;
}
/* ── css: ui-elements ── */
/* ─── Toolbar ──────────────────────────────────────────────── */
.toolbar-search input {
padding: 8px 12px;
border: 2px solid #dfe1e6;
border-radius: 8px;
font-size: 14px;
width: 250px;
outline: none;
transition: border-color 0.15s ease;
}
.toolbar-search input:focus {
border-color: #0079bf;
}
.toolbar-labels {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.label-chip {
padding: 4px 12px;
border-radius: 20px;
border: none;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: transform 0.1s ease, opacity 0.15s ease;
}
.label-chip:hover {
transform: scale(1.05);
}
.label-chip.active {
box-shadow: 0 0 0 3px rgba(0,0,0,0.15);
}
.clear-filters-btn {
padding: 4px 12px;
border-radius: 20px;
border: 1px solid #dfe1e6;
background: #fff;
cursor: pointer;
font-size: 12px;
color: #5e6c84;
}
.clear-filters-btn:hover {
background: #f4f5f7;
}
/* ─── Column Menu ──────────────────────────────────────────── */
.column-menu {
position: fixed;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15), 0 0 1px rgba(0,0,0,0.2);
z-index: 100;
min-width: 180px;
padding: 6px;
}
.menu-item {
display: block;
width: 100%;
text-align: left;
padding: 8px 12px;
border: none;
background: none;
cursor: pointer;
font-size: 13px;
color: #172b4d;
border-radius: 4px;
}
.menu-item:hover {
background: #f4f5f7;
}
.menu-danger {
color: #eb5a46 !important;
}
/* ─── Dialog / Modal ──────────────────────────────────────── */
#modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
#modal-overlay.hidden {
display: none !important;
}
.card-dialog {
background: #fff;
border-radius: 12px;
padding: 24px;
width: 400px;
max-width: 90vw;
box-shadow: 0 8px 30px rgba(0,0,0,0.2);
}
.dialog-input {
display: block;
width: 100%;
padding: 10px 12px;
border: 2px solid #dfe1e6;
border-radius: 8px;
font-size: 14px;
margin-bottom: 12px;
outline: none;
transition: border-color 0.15s ease;
font-family: inherit;
}
.dialog-input:focus {
border-color: #0079bf;
}
.dialog-textarea {
min-height: 80px;
resize: vertical;
}
.dialog-labels-section {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
}
.dialog-label-header {
font-size: 13px;
color: #5e6c84;
font-weight: 600;
margin-right: 4px;
}
.dialog-label-chip {
padding: 4px 12px;
border-radius: 20px;
border: 2px solid transparent;
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.dialog-label-chip.active {
box-shadow: 0 0 0 2px rgba(0,0,0,0.15);
}
.dialog-btn-row {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.dialog-save-btn {
padding: 8px 20px;
background: #0079bf;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
}
.dialog-save-btn:hover {
background: #026aa7;
}
.dialog-cancel-btn {
padding: 8px 20px;
background: none;
border: 1px solid #dfe1e6;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
color: #5e6c84;
}
.dialog-cancel-btn:hover {
background: #f4f5f7;
}
/* ─── Toast Notifications ─────────────────────────────────── */
#toast-container {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 300;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
background: #172b4d;
color: #fff;
padding: 12px 20px;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
display: flex;
align-items: center;
gap: 16px;
animation: toast-in 0.3s ease-out;
}
@keyframes toast-in {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.toast-message {
font-size: 14px;
}
.toast-undo-btn {
background: none;
border: none;
color: #fff;
cursor: pointer;
font-weight: 600;
text-decoration: underline;
font-size: 13px;
}
.toast-undo-btn:hover {
opacity: 0.8;
}
/* ── css: polish ── */
/* ─── Scrollbar Styling ───────────────────────────────────── */
#board-container::-webkit-scrollbar {
height: 8px;
}
#board-container::-webkit-scrollbar-track {
background: #f4f5f7;
}
#board-container::-webkit-scrollbar-thumb {
background: #c4c9cc;
border-radius: 4px;
}
#board-container::-webkit-scrollbar-thumb:hover {
background: #a5adba;
}
/* ─── Card Micro-interactions ─────────────────────────────── */
.card {
transition: transform 0.12s ease, box-shadow 0.12s ease, opacity 0.15s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
/* ─── Column Header Hover ────────────────────────────────── */
.column-header:hover {
background: rgba(0,0,0,0.03);
border-radius: 8px 8px 0 0;
}
/* ─── Add Column Button Polish ───────────────────────────── */
.add-column-btn button {
padding: 12px 16px;
background: rgba(255,255,255,0.3);
border: none;
border-radius: 12px;
cursor: pointer;
text-align: left;
color: #5e6c84;
font-size: 14px;
transition: background 0.15s ease;
}
.add-column-btn button:hover {
background: rgba(255,255,255,0.6);
color: #172b4d;
}
/* ─── Empty Column Zone Polish ───────────────────────────── */
.empty-column-zone {
border: 2px dashed #c4c9cc;
border-radius: 8px;
padding: 24px 16px;
text-align: center;
color: #a5adba;
font-size: 13px;
margin: 4px 0;
transition: border-color 0.15s ease, background 0.15s ease;
}
/* ─── Drop Placeholder Animation ─────────────────────────── */
.drop-placeholder {
animation: placeholder-pulse 1s ease-in-out infinite;
}
@keyframes placeholder-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
/* ─── Card Dialog Polish ─────────────────────────────────── */
.card-dialog {
animation: dialog-in 0.2s ease-out;
}
@keyframes dialog-in {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
/* ─── Toast Animation Polish ─────────────────────────────── */
.toast {
animation: toast-slide-up 0.3s ease-out;
}
@keyframes toast-slide-up {
from { transform: translateY(20px) scale(0.95); opacity: 0; }
to { transform: translateY(0) scale(1); opacity: 1; }
}
/* ─── Selection Styling ──────────────────────────────────── */
::selection {
background: rgba(0, 121, 191, 0.2);
}
</style>
</head>
<body>
<div id="board-container">
<div id="toolbar"></div>
<div id="board"></div>
</div>
<div id="toast-container"></div>
<div id="modal-overlay" class="hidden"></div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./state":"src/state.ts","./rendering":"src/rendering.ts","./dragdrop":"src/dragdrop.ts","./persistence":"src/persistence.ts"},"src/state.ts":{"./types":"src/types.ts"},"src/rendering.ts":{"./types":"src/types.ts","./state":"src/state.ts","./persistence":"src/persistence.ts"},"src/dragdrop.ts":{"./types":"src/types.ts","./state":"src/state.ts","./rendering":"src/rendering.ts","./persistence":"src/persistence.ts"},"src/persistence.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";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const State = __importStar(require("./state"));
const rendering_1 = require("./rendering");
const dragdrop_1 = require("./dragdrop");
const persistence_1 = require("./persistence");
// ─── Entry Point ──────────────────────────────────────────────────────
function main() {
// Load persisted state or create default board
let loaded = (0, persistence_1.load)();
// Check if saved data has cards - if not, use sample data
const savedModel = State.getBoard();
const totalCards = savedModel.columns.reduce((sum, c) => sum + c.cardIds.length, 0);
if (!loaded || totalCards === 0) {
State.initDefaultBoard();
(0, persistence_1.save)();
loaded = true;
}
// Initial render
(0, rendering_1.render)();
// Initialize drag & drop system
(0, dragdrop_1.initDragDrop)();
// Add column button
setupAddColumnButton();
// Auto-save on state changes (debounced)
// We hook into rendering to detect changes
const origRender = rendering_1.render;
// Actually, we save after each user action. The scheduleSave is called from interactions.
// Save periodically as well
setInterval(persistence_1.save, 5000);
}
// ─── Add Column Button ────────────────────────────────────────────────
function setupAddColumnButton() {
const board = document.getElementById('board');
const addColBtn = document.createElement('div');
addColBtn.className = 'add-column-btn';
addColBtn.innerHTML = '<button>+ Add another column</button>';
addColBtn.querySelector('button').addEventListener('click', () => {
const title = prompt('Column name:');
if (title && title.trim()) {
State.addColumn(title.trim());
(0, persistence_1.scheduleSave)();
(0, rendering_1.render)();
}
});
board.appendChild(addColBtn);
}
// ─── Start ────────────────────────────────────────────────────────────
main();
};
// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
// ─── Tunable Constants ────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.TOAST_DURATION = exports.DROP_SETTLE_DURATION = exports.CARD_LIFT_DURATION = exports.AUTO_SCROLL_MARGIN = exports.AUTO_SCROLL_SPEED = exports.DRAG_THRESHOLD = exports.DEFAULT_COLUMNS = exports.LABEL_PALETTE = exports.COLORS = void 0;
exports.COLORS = {
bg: '#f4f5f7',
columnBg: '#ebecf0',
cardBg: '#ffffff',
textPrimary: '#172b4d',
textSecondary: '#5e6c84',
accent: '#0079bf',
accentHover: '#026aa7',
danger: '#eb5a46',
success: '#61bd4f',
labelBorder: 'rgba(0,0,0,0.08)',
placeholderBg: 'rgba(0,121,191,0.12)',
dropIndicatorColor: '#0079bf',
wipExceeded: '#eb5a46',
};
exports.LABEL_PALETTE = [
{ name: 'Bug', color: '#eb5a46' },
{ name: 'Feature', color: '#61bd4f' },
{ name: 'Enhancement', color: '#0079bf' },
{ name: 'Urgent', color: '#c377e0' },
{ name: 'Design', color: '#f2d600' },
{ name: 'Documentation', color: '#00c2e0' },
];
exports.DEFAULT_COLUMNS = [
'Backlog', 'To Do', 'In Progress', 'Review', 'Done',
];
// Drag thresholds & animation timings (ms)
exports.DRAG_THRESHOLD = 5; // px before drag starts
exports.AUTO_SCROLL_SPEED = 12; // px per frame near edges
exports.AUTO_SCROLL_MARGIN = 60; // px from edge to trigger scroll
exports.CARD_LIFT_DURATION = 150; // ms for card lift animation
exports.DROP_SETTLE_DURATION = 200; // ms for drop settle animation
exports.TOAST_DURATION = 3000; // ms before toast auto-dismisses
};
// ── module: src/state.ts ──
__mods["src/state.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getBoard = getBoard;
exports.getFilter = getFilter;
exports.initDefaultBoard = initDefaultBoard;
exports.addColumn = addColumn;
exports.renameColumn = renameColumn;
exports.deleteColumn = deleteColumn;
exports.setWipLimit = setWipLimit;
exports.addCard = addCard;
exports.updateCard = updateCard;
exports.deleteCard = deleteCard;
exports.moveCard = moveCard;
exports.reorderCardInColumn = reorderCardInColumn;
exports.reorderColumns = reorderColumns;
exports.setSearchQuery = setSearchQuery;
exports.toggleLabelFilter = toggleLabelFilter;
exports.clearFilters = clearFilters;
exports.isCardVisible = isCardVisible;
exports.serialize = serialize;
exports.deserialize = deserialize;
// ─── State (single source of truth) ──────────────────────────────────
let board = { columns: [], cards: {} };
let filter = { searchQuery: '', activeLabels: new Set() };
function getBoard() { return JSON.parse(JSON.stringify(board)); }
function getFilter() { return { ...filter, activeLabels: new Set(filter.activeLabels) }; }
// ─── Initialization ──────────────────────────────────────────────────
function initDefaultBoard() {
const col1Id = uid();
const col2Id = uid();
const col3Id = uid();
const col4Id = uid();
const col5Id = uid();
// Create sample cards
const c1 = uid(), c2 = uid(), c3 = uid(), c4 = uid(), c5 = uid(), c6 = uid(), c7 = uid(), c8 = uid();
board = {
columns: [
{ id: col1Id, title: 'Backlog', cardIds: [c1, c2] },
{ id: col2Id, title: 'To Do', cardIds: [c3, c4] },
{ id: col3Id, title: 'In Progress', cardIds: [c5] },
{ id: col4Id, title: 'Review', cardIds: [] },
{ id: col5Id, title: 'Done', cardIds: [c6, c7, c8] },
],
cards: {
[c1]: { id: c1, title: 'Research competitors', description: 'Analyze top 3 competitor products and document findings', labels: ['Feature'], dueDate: '' },
[c2]: { id: c2, title: 'Define user personas', description: '', labels: ['Design'], dueDate: '' },
[c3]: { id: c3, title: 'Set up CI/CD pipeline', description: 'Configure GitHub Actions for automated testing and deployment', labels: ['Enhancement'], dueDate: '' },
[c4]: { id: c4, title: 'Fix login redirect bug', description: 'Users are redirected to 404 after OAuth login on mobile', labels: ['Bug', 'Urgent'], dueDate: '' },
[c5]: { id: c5, title: 'Implement drag & drop', description: 'Card reordering within and across columns', labels: ['Feature'], dueDate: '' },
[c6]: { id: c6, title: 'Project kickoff meeting', description: '', labels: [], dueDate: '' },
[c7]: { id: c7, title: 'Design system v1', description: 'Color palette, typography, spacing tokens', labels: ['Design'], dueDate: '' },
[c8]: { id: c8, title: 'Write API docs', description: '', labels: ['Documentation'], dueDate: '' },
},
};
}
// ─── Column CRUD ─────────────────────────────────────────────────────
function addColumn(title) {
board.columns.push({ id: uid(), title, cardIds: [] });
}
function renameColumn(columnId, newTitle) {
const col = board.columns.find(c => c.id === columnId);
if (col)
col.title = newTitle;
}
function deleteColumn(columnId) {
if (board.columns.length <= 1)
return; // keep at least one
const col = board.columns.find(c => c.id === columnId);
if (!col)
return;
// Remove all cards in this column
for (const cardId of col.cardIds) {
delete board.cards[cardId];
}
board.columns = board.columns.filter(c => c.id !== columnId);
}
function setWipLimit(columnId, limit) {
const col = board.columns.find(c => c.id === columnId);
if (col)
col.wipLimit = limit;
}
// ─── Card CRUD ───────────────────────────────────────────────────────
function addCard(columnId, card) {
const id = uid();
board.cards[id] = { ...card, id };
const col = board.columns.find(c => c.id === columnId);
if (col)
col.cardIds.push(id);
return id;
}
function updateCard(cardId, updates) {
const card = board.cards[cardId];
if (card)
Object.assign(card, updates);
}
function deleteCard(cardId) {
// Remove from column
for (const col of board.columns) {
col.cardIds = col.cardIds.filter(id => id !== cardId);
}
delete board.cards[cardId];
}
// ─── Card Reordering (drag & drop result) ────────────────────────────
function moveCard(cardId, fromColumnId, toColumnId, toIndex) {
const fromCol = board.columns.find(c => c.id === fromColumnId);
if (!fromCol)
return;
// Remove from source column
fromCol.cardIds = fromCol.cardIds.filter(id => id !== cardId);
// Insert into target column at index
const toCol = board.columns.find(c => c.id === toColumnId);
if (toCol) {
toCol.cardIds.splice(toIndex, 0, cardId);
}
}
function reorderCardInColumn(columnId, fromIndex, toIndex) {
const col = board.columns.find(c => c.id === columnId);
if (!col)
return;
const [cardId] = col.cardIds.splice(fromIndex, 1);
col.cardIds.splice(toIndex, 0, cardId);
}
// ─── Column Reordering ──────────────────────────────────────────────
function reorderColumns(fromIndex, toIndex) {
const [col] = board.columns.splice(fromIndex, 1);
board.columns.splice(toIndex, 0, col);
}
// ─── Filter ──────────────────────────────────────────────────────────
function setSearchQuery(query) { filter.searchQuery = query; }
function toggleLabelFilter(labelName) {
if (filter.activeLabels.has(labelName))
filter.activeLabels.delete(labelName);
else
filter.activeLabels.add(labelName);
}
function clearFilters() {
filter.searchQuery = '';
filter.activeLabels.clear();
}
// ─── Visibility Check ────────────────────────────────────────────────
function isCardVisible(cardId) {
const card = board.cards[cardId];
if (!card)
return false;
// Search filter
if (filter.searchQuery.trim()) {
const q = filter.searchQuery.toLowerCase();
if (!card.title.toLowerCase().includes(q))
return false;
}
// Label filter: show cards that have at least one active label
if (filter.activeLabels.size > 0) {
const hasActiveLabel = card.labels.some(l => filter.activeLabels.has(l));
if (!hasActiveLabel)
return false;
}
return true;
}
// ─── Serialization ──────────────────────────────────────────────────
function serialize() {
return JSON.stringify({ board, filter: { searchQuery: filter.searchQuery, activeLabels: [...filter.activeLabels] } });
}
function deserialize(json) {
try {
const data = JSON.parse(json);
board = data.board;
filter = {
searchQuery: data.filter?.searchQuery ?? '',
activeLabels: new Set(data.filter?.activeLabels ?? [])
};
}
catch { /* ignore corrupt data */ }
}
// ─── Helpers ─────────────────────────────────────────────────────────
let _uidCounter = 0;
function uid() {
return 'id_' + Date.now().toString(36) + '_' + (++_uidCounter).toString(36);
}
};
// ── module: src/rendering.ts ──
__mods["src/rendering.ts"] = function (exports, require, module) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.render = render;
exports.deleteCardWithUndo = deleteCardWithUndo;
exports.deleteColumnWithUndo = deleteColumnWithUndo;
const types_1 = require("./types");
const State = __importStar(require("./state"));
const persistence_1 = require("./persistence");
// ─── DOM References ──────────────────────────────────────────────────
const boardEl = document.getElementById('board');
const toolbarEl = document.getElementById('toolbar');
// ─── Render Everything ───────────────────────────────────────────────
function render() {
renderToolbar();
renderBoard();
}
// ─── Toolbar (search, label filters) ─────────────────────────────────
function renderToolbar() {
const filter = State.getFilter();
toolbarEl.innerHTML = '';
// Search input
const searchWrap = h('div', { class: 'toolbar-search' });
const searchInput = h('input', { type: 'text', placeholder: 'Search cards…', value: filter.searchQuery, id: 'search-input' });
searchInput.addEventListener('input', () => { State.setSearchQuery(searchInput.value); (0, persistence_1.scheduleSave)(); render(); });
searchWrap.appendChild(searchInput);
// Label filters
const labelBar = h('div', { class: 'toolbar-labels' });
for (const label of types_1.LABEL_PALETTE) {
const isActive = filter.activeLabels.has(label.name);
const chip = h('button', {
class: `label-chip ${isActive ? 'active' : ''}`,
style: `background:${label.color};color:#fff;`,
title: label.name,
'data-label': label.name,
});
chip.textContent = label.name;
chip.addEventListener('click', () => { State.toggleLabelFilter(label.name); (0, persistence_1.scheduleSave)(); render(); });
labelBar.appendChild(chip);
}
// Clear filters button (only show if filters active)
if (filter.searchQuery || filter.activeLabels.size > 0) {
const clearBtn = h('button', { class: 'clear-filters-btn' });
clearBtn.textContent = '✕ Clear';
clearBtn.addEventListener('click', () => { State.clearFilters(); render(); });
labelBar.appendChild(clearBtn);
}
toolbarEl.appendChild(searchWrap);
toolbarEl.appendChild(labelBar);
}
// ─── Board (columns) ────────────────────────────────────────────────
function renderBoard() {
boardEl.innerHTML = '';
const model = State.getBoard();
for (const col of model.columns) {
boardEl.appendChild(createColumnElement(col));
}
}
// ─── Column Element ──────────────────────────────────────────────────
function createColumnElement(col) {
const wrapper = h('div', { class: 'column-wrapper' });
// Header
const header = h('div', { class: 'column-header', 'data-column-id': col.id, 'draggable-type': 'column' });
const titleSpan = h('span', { class: 'column-title' });
titleSpan.textContent = col.title;
// Card count
const visibleCount = col.cardIds.filter(id => State.isCardVisible(id)).length;
const totalCount = col.cardIds.length;
const countBadge = h('span', { class: 'card-count' });
countBadge.textContent = `${visibleCount}${totalCount !== visibleCount ? `/${totalCount}` : ''}`;
header.appendChild(titleSpan);
header.appendChild(countBadge);
// Column menu button
const menuBtn = h('button', { class: 'column-menu-btn' });
menuBtn.textContent = '⋯';
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
showColumnMenu(col.id, menuBtn);
});
header.appendChild(menuBtn);
wrapper.appendChild(header);
// Card list
const cardList = h('div', {
class: 'card-list',
'data-column-id': col.id,
style: `min-height:${col.cardIds.length === 0 ? '40px' : 'auto'}`,
});
// WIP exceeded styling
const wipExceeded = col.wipLimit !== undefined && col.wipLimit > 0 && col.cardIds.length > col.wipLimit;
if (wipExceeded) {
cardList.classList.add('wip-exceeded');
}
// Render cards
for (const cardId of col.cardIds) {
const card = State.getBoard().cards[cardId];
if (!card)
continue;
const isVisible = State.isCardVisible(cardId);
const cardEl = createCardElement(card, col.id, !isVisible);
cardList.appendChild(cardEl);
}
// Empty column drop zone indicator
if (col.cardIds.length === 0) {
const emptyZone = h('div', { class: 'empty-column-zone' });
emptyZone.textContent = 'Drop cards here';
cardList.appendChild(emptyZone);
}
wrapper.appendChild(cardList);
// Add column button (at bottom of last column, or always visible)
const addCardBtn = h('button', { class: 'add-card-btn' });
addCardBtn.textContent = '+ Add a card';
addCardBtn.addEventListener('click', () => showAddCardDialog(col.id));
wrapper.appendChild(addCardBtn);
return wrapper;
}
// ─── Card Element ────────────────────────────────────────────────────
function createCardElement(card, columnId, hidden) {
const cardEl = h('div', {
class: `card ${hidden ? 'card-hidden' : ''}`,
'data-card-id': card.id,
'draggable-type': 'card',
});
// Labels row
if (card.labels.length > 0) {
const labelRow = h('div', { class: 'card-labels' });
for (const labelName of card.labels) {
const labelDef = types_1.LABEL_PALETTE.find(l => l.name === labelName);
if (!labelDef)
continue;
const chip = h('span', {
class: 'card-label-chip',
style: `background:${labelDef.color}22;color:${labelDef.color};border-color:${labelDef.color}44`,
});
chip.textContent = labelName;
labelRow.appendChild(chip);
}
cardEl.appendChild(labelRow);
}
// Title
const titleEl = h('div', { class: 'card-title' });
titleEl.textContent = card.title;
cardEl.appendChild(titleEl);
// Description preview
if (card.description) {
const descEl = h('div', { class: 'card-desc' });
descEl.textContent = card.description.length > 80 ? card.description.slice(0, 80) + '…' : card.description;
cardEl.appendChild(descEl);
}
// Due date
if (card.dueDate) {
const dueEl = h('div', { class: 'card-due-date' });
const d = new Date(card.dueDate + 'T00:00:00');
const today = new Date();
today.setHours(0, 0, 0, 0);
const isOverdue = d < today;
dueEl.textContent = `📅 ${card.dueDate}`;
if (isOverdue)
dueEl.classList.add('overdue');
cardEl.appendChild(dueEl);
}
// Card actions (hover-visible)
const actionsRow = h('div', { class: 'card-actions' });
const editBtn = h('button', { class: 'card-action-btn' });
editBtn.textContent = '✎';
editBtn.title = 'Edit card';
editBtn.addEventListener('click', (e) => { e.stopPropagation(); showEditCardDialog(card.id, columnId); });
const deleteBtn = h('button', { class: 'card-action-btn' });
deleteBtn.textContent = '✕';
deleteBtn.title = 'Delete card';
deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); deleteCardWithUndo(card.id, card.title); });
actionsRow.appendChild(editBtn);
actionsRow.appendChild(deleteBtn);
cardEl.appendChild(actionsRow);
return cardEl;
}
// ─── Column Menu ─────────────────────────────────────────────────────
function showColumnMenu(columnId, btn) {
// Remove existing menus
document.querySelectorAll('.column-menu').forEach(m => m.remove());
const menu = h('div', { class: 'column-menu' });
const rect = btn.getBoundingClientRect();
menu.style.top = `${rect.bottom + 4}px`;
menu.style.left = `${rect.left}px`;
// Rename option
const renameItem = h('button', { class: 'menu-item' });
renameItem.textContent = 'Rename column';
renameItem.addEventListener('click', () => {
menu.remove();
const col = State.getBoard().columns.find(c => c.id === columnId);
if (!col)
return;
const newTitle = prompt('Column name:', col.title);
if (newTitle && newTitle.trim()) {
State.renameColumn(columnId, newTitle.trim());
(0, persistence_1.scheduleSave)();
render();
}
});
// WIP limit option
const wipItem = h('button', { class: 'menu-item' });
const col = State.getBoard().columns.find(c => c.id === columnId);
wipItem.textContent = col?.wipLimit ? `WIP limit: ${col.wipLimit}` : 'Set WIP limit';
wipItem.addEventListener('click', () => {
menu.remove();
const current = col?.wipLimit ?? 0;
const val = prompt(`Current WIP limit: ${current || 'none'}\nEnter new limit (0 to remove):`, String(current));
if (val !== null) {
const num = parseInt(val, 10);
if (!isNaN(num)) {
State.setWipLimit(columnId, num > 0 ? num : undefined);
(0, persistence_1.scheduleSave)();
render();
}
}
});
// Delete option
const deleteItem = h('button', { class: 'menu-item menu-danger' });
deleteItem.textContent = 'Delete column';
deleteItem.addEventListener('click', () => {
menu.remove();
if (State.getBoard().columns.length <= 1)
return;
const col2 = State.getBoard().columns.find(c => c.id === columnId);
deleteColumnWithUndo(columnId, col2?.title ?? 'column');
});
menu.appendChild(renameItem);
menu.appendChild(wipItem);
menu.appendChild(deleteItem);
document.body.appendChild(menu);
// Close on outside click
const closeMenu = (e) => {
if (!menu.contains(e.target)) {
menu.remove();
document.removeEventListener('click', closeMenu);
}
};
setTimeout(() => document.addEventListener('click', closeMenu), 0);
}
// ─── Add Card Dialog ────────────────────────────────────────────────
function showAddCardDialog(columnId) {
const overlay = document.getElementById('modal-overlay');
overlay.innerHTML = '';
overlay.classList.remove('hidden');
const dialog = h('div', { class: 'card-dialog' });
// Title input
const titleInput = h('input', {
type: 'text', placeholder: 'Card title…', id: 'dialog-title-input', class: 'dialog-input'
});
// Description textarea
const descTextarea = h('textarea', {
placeholder: 'Description (optional)…', id: 'dialog-desc-textarea', class: 'dialog-input dialog-textarea'
});
// Due date input
const dueDateInput = h('input', {
type: 'date', id: 'dialog-due-date', class: 'dialog-input'
});
// Labels selection
const labelSection = h('div', { class: 'dialog-labels-section' });
const labelHeader = h('span', { class: 'dialog-label-header' });
labelHeader.textContent = 'Labels';
labelSection.appendChild(labelHeader);
const selectedLabels = new Set();
for (const label of types_1.LABEL_PALETTE) {
const chip = h('button', {
class: `dialog-label-chip ${selectedLabels.has(label.name) ? 'active' : ''}`,
style: `background:${label.color}22;color:${label.color};border-color:${label.color}44`,
'data-label': label.name,
});
chip.textContent = label.name;
chip.addEventListener('click', () => {
if (selectedLabels.has(label.name))
selectedLabels.delete(label.name);
else
selectedLabels.add(label.name);
chip.classList.toggle('active');
});
labelSection.appendChild(chip);
}
// Buttons
const btnRow = h('div', { class: 'dialog-btn-row' });
const saveBtn = h('button', { class: 'dialog-save-btn' });
saveBtn.textContent = 'Add Card';
saveBtn.addEventListener('click', () => {
const tv = titleInput.value;
if (tv.trim()) {
State.addCard(columnId, {
title: tv.trim(),
description: descTextarea.value.trim(),
labels: [...selectedLabels],
dueDate: dueDateInput.value || undefined,
});
(0, persistence_1.scheduleSave)();
closeDialog();
}
});
const cancelBtn = h('button', { class: 'dialog-cancel-btn' });
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', closeDialog);
btnRow.appendChild(saveBtn);
btnRow.appendChild(cancelBtn);
dialog.appendChild(titleInput);
dialog.appendChild(descTextarea);
dialog.appendChild(dueDateInput);
dialog.appendChild(labelSection);
dialog.appendChild(btnRow);
overlay.appendChild(dialog);
// Focus title input and handle Enter/Escape
setTimeout(() => {
titleInput.focus();
const handler = (e) => {
if (e.key === 'Enter' && !e.shiftKey && document.activeElement !== descTextarea) {
e.preventDefault();
saveBtn.click();
}
if (e.key === 'Escape')
closeDialog();
};
overlay.addEventListener('keydown', handler);
}, 50);
}
function showEditCardDialog(cardId, _columnId) {
const card = State.getBoard().cards[cardId];
if (!card)
return;
const overlay = document.getElementById('modal-overlay');
overlay.innerHTML = '';
overlay.classList.remove('hidden');
const dialog = h('div', { class: 'card-dialog' });
const titleInput = h('input', {
type: 'text', value: card.title, id: 'dialog-title-input', class: 'dialog-input'
});
const descTextarea = h('textarea', {
value: card.description, id: 'dialog-desc-textarea', class: 'dialog-input dialog-textarea'
});
const dueDateInput = h('input', {
type: 'date', value: card.dueDate || '', id: 'dialog-due-date', class: 'dialog-input'
});
const labelSection = h('div', { class: 'dialog-labels-section' });
const labelHeader = h('span', { class: 'dialog-label-header' });
labelHeader.textContent = 'Labels';
labelSection.appendChild(labelHeader);
for (const label of types_1.LABEL_PALETTE) {
const isActive = card.labels.includes(label.name);
const chip = h('button', {
class: `dialog-label-chip ${isActive ? 'active' : ''}`,
style: `background:${label.color}22;color:${label.color};border-color:${label.color}44`,
'data-label': label.name,
});
chip.textContent = label.name;
chip.addEventListener('click', () => {
chip.classList.toggle('active');
});
labelSection.appendChild(chip);
}
const btnRow = h('div', { class: 'dialog-btn-row' });
const saveBtn = h('button', { class: 'dialog-save-btn' });
saveBtn.textContent = 'Save';
saveBtn.addEventListener('click', () => {
const activeLabels = [...labelSection.querySelectorAll('.dialog-label-chip.active')].map((b) => b.dataset.label).filter(Boolean);
State.updateCard(cardId, {
title: (titleInput.value.trim() || card.title),
description: descTextarea.value.trim(),
labels: activeLabels,
dueDate: dueDateInput.value || undefined,
});
(0, persistence_1.scheduleSave)();
closeDialog();
});
const cancelBtn = h('button', { class: 'dialog-cancel-btn' });
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', closeDialog);
btnRow.appendChild(saveBtn);
btnRow.appendChild(cancelBtn);
dialog.appendChild(titleInput);
dialog.appendChild(descTextarea);
dialog.appendChild(dueDateInput);
dialog.appendChild(labelSection);
dialog.appendChild(btnRow);
overlay.appendChild(dialog);
setTimeout(() => {
titleInput.focus();
const handler = (e) => {
if (e.key === 'Enter' && !e.shiftKey && document.activeElement !== descTextarea) {
e.preventDefault();
saveBtn.click();
}
if (e.key === 'Escape')
closeDialog();
};
overlay.addEventListener('keydown', handler);
}, 50);
}
function closeDialog() {
const overlay = document.getElementById('modal-overlay');
overlay.classList.add('hidden');
overlay.innerHTML = '';
render();
}
// ─── Undo Helpers ────────────────────────────────────────────────────
function deleteCardWithUndo(cardId, cardTitle) {
const snapshot = State.getBoard();
State.deleteCard(cardId);
showUndoToast(`Deleted "${cardTitle}"`, () => restoreSnapshot(snapshot));
(0, persistence_1.scheduleSave)();
render();
}
function deleteColumnWithUndo(columnId, columnTitle) {
const snapshot = State.getBoard();
State.deleteColumn(columnId);
showUndoToast(`Deleted column "${columnTitle}"`, () => restoreSnapshot(snapshot));
(0, persistence_1.scheduleSave)();
render();
}
function restoreSnapshot(snapshot) {
State.deserialize(JSON.stringify({ board: snapshot }));
render();
}
// ─── Toast System ────────────────────────────────────────────────────
function showUndoToast(message, onUndo) {
const container = document.getElementById('toast-container');
const toast = h('div', { class: 'toast' });
const msgEl = h('span', { class: 'toast-message' });
msgEl.textContent = message;
const undoBtn = h('button', { class: 'toast-undo-btn' });
undoBtn.textContent = 'Undo';
undoBtn.addEventListener('click', () => {
onUndo();
toast.remove();
});
toast.appendChild(msgEl);
toast.appendChild(undoBtn);
container.appendChild(toast);
// Auto-remove after delay
setTimeout(() => { if (toast.parentNode)
toast.remove(); }, 4000);
}
// ─── DOM Helpers ─────────────────────────────────────────────────────
function h(tag, attrs) {
const element = document.createElement(tag);
if (attrs) {
for (const [key, value] of Object.entries(attrs)) {
if (value === undefined || value === null)
continue;
if (key === 'style') {
element.style.cssText = String(value);
}
else if (key === 'class') {
element.className = String(value);
}
else if (key.startsWith('data-')) {
const dataKey = key.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
element.dataset[dataKey] = String(value);
}
else if (key.includes('-') || !(key in element)) {
// Custom attribute or non-standard property - use setAttribute
element.setAttribute(key, String(value));
}
else {
try {
element[key] = value;
}
catch { }
}
}
}
return element;
}
};
// ── module: src/dragdrop.ts ──
__mods["src/dragdrop.ts"] = function (exports, require, module) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.initDragDrop = initDragDrop;
const types_1 = require("./types");
const State = __importStar(require("./state"));
const rendering_1 = require("./rendering");
const persistence_1 = require("./persistence");
// ─── Drag Drop System ────────────────────────────────────────────────
let dragState = null;
let placeholderEl = null;
let ghostEl = null;
let autoScrollRAF = 0;
// ─── Initialization ──────────────────────────────────────────────────
function initDragDrop() {
const board = document.getElementById('board');
board.addEventListener('mousedown', onBoardMouseDown);
window.addEventListener('mousemove', onMouseMove, true); // capture for reliability
window.addEventListener('mouseup', onMouseUp, true);
window.addEventListener('keydown', onKeyDown);
}
// ─── Mouse Down ──────────────────────────────────────────────────────
function onBoardMouseDown(e) {
if (e.button !== 0)
return; // only left click
const target = e.target;
// Check for card drag
const cardEl = target.closest('[draggable-type="card"]') || null;
if (cardEl && !(target.closest('.card-action-btn'))) {
startCardDrag(e, cardEl);
return;
}
// Check for column header drag
const headerEl = target.closest('[draggable-type="column"]') || null;
if (headerEl && !target.closest('.column-menu-btn')) {
startColumnDrag(e, headerEl);
return;
}
}
// ─── Card Drag ───────────────────────────────────────────────────────
function startCardDrag(e, cardEl) {
const cardId = cardEl.dataset.cardId;
if (!cardId)
return;
const columnList = cardEl.closest('.card-list');
const columnId = columnList?.dataset.columnId;
if (!columnId)
return;
// Find index in model
const model = State.getBoard();
const col = model.columns.find(c => c.id === columnId);
if (!col)
return;
const sourceIndex = col.cardIds.indexOf(cardId);
if (sourceIndex < 0)
return;
// Store initial state for cancel
dragState = { type: 'card', sourceColumnId: columnId, sourceIndex, cardId };
e.preventDefault();
const rect = cardEl.getBoundingClientRect();
const offsetX = e.clientX - rect.left;
const offsetY = e.clientY - rect.top;
// Create ghost element (follows cursor)
ghostEl = cardEl.cloneNode(true);
ghostEl.classList.add('drag-ghost');
ghostEl.style.width = `${rect.width}px`;
ghostEl.style.position = 'fixed';
ghostEl.style.left = `${rect.left}px`;
ghostEl.style.top = `${rect.top}px`;
ghostEl.style.zIndex = '1000';
ghostEl.style.pointerEvents = 'none';
ghostEl.style.transformOrigin = `${offsetX}px ${offsetY}px`;
ghostEl.style.transition = `transform ${types_1.CARD_LIFT_DURATION}ms ease-out`;
// Hide original card (show placeholder instead)
cardEl.classList.add('drag-source-hidden');
document.body.appendChild(ghostEl);
// Create placeholder at source position
placeholderEl = createPlaceholder(cardEl);
const parent = cardEl.parentNode;
if (parent) {
parent.insertBefore(placeholderEl, cardEl.nextSibling);
}
// Animate ghost lift after a frame
requestAnimationFrame(() => {
if (ghostEl) {
ghostEl.style.transform = 'scale(1.03) rotate(-1deg)';
ghostEl.style.boxShadow = '0 8px 25px rgba(0,0,0,0.15), 0 4px 10px rgba(0,0,0,0.1)';
}
});
// Store offset for cursor tracking
ghostEl._dragOffsetX = offsetX;
ghostEl._dragOffsetY = offsetY;
}
// ─── Column Drag ─────────────────────────────────────────────────────
function startColumnDrag(e, headerEl) {
const columnId = headerEl.dataset.columnId;
if (!columnId)
return;
const model = State.getBoard();
const sourceIndex = model.columns.findIndex(c => c.id === columnId);
if (sourceIndex < 0)
return;
dragState = { type: 'column', sourceColumnId: columnId, sourceIndex };
e.preventDefault();
// Create ghost for column header
const wrapper = headerEl.closest('.column-wrapper');
if (!wrapper)
return;
const rect = wrapper.getBoundingClientRect();
ghostEl = h('div', { class: 'drag-ghost drag-column-ghost' });
ghostEl.style.width = `${rect.width}px`;
ghostEl.style.height = `${headerEl.offsetHeight}px`;
ghostEl.style.position = 'fixed';
ghostEl.style.left = `${rect.left}px`;
ghostEl.style.top = `${e.clientY - headerEl.offsetTop + wrapper.offsetTop - rect.top}px`;
ghostEl.style.zIndex = '1000';
ghostEl.style.pointerEvents = 'none';
ghostEl.style.background = types_1.COLORS.columnBg;
ghostEl.style.borderRadius = '8px';
ghostEl.style.opacity = '0.7';
const titleClone = h('span', { class: 'column-title' });
titleClone.textContent = headerEl.querySelector('.column-title')?.textContent ?? '';
ghostEl.appendChild(titleClone);
document.body.appendChild(ghostEl);
// Dim the original column
wrapper.classList.add('drag-source-hidden');
ghostEl._dragOffsetX = e.clientX - rect.left;
ghostEl._dragOffsetY = e.clientY - rect.top;
}
// ─── Mouse Move ──────────────────────────────────────────────────────
function onMouseMove(e) {
if (!dragState || !ghostEl)
return;
// Update ghost position to follow cursor
const offsetX = ghostEl._dragOffsetX ?? 0;
const offsetY = ghostEl._dragOffsetY ?? 0;
ghostEl.style.left = `${e.clientX - offsetX}px`;
ghostEl.style.top = `${e.clientY - offsetY}px`;
// Auto-scroll
handleAutoScroll(e);
if (dragState.type === 'card') {
updateCardDropPosition(e);
}
else {
updateColumnDropPosition(e);
}
}
// ─── Card Drop Position Update ───────────────────────────────────────
function updateCardDropPosition(e) {
if (!placeholderEl || !dragState)
return;
// Hide empty zone indicators temporarily for hit testing
const allLists = document.querySelectorAll('.card-list');
let targetColumnId = undefined;
let targetIndex = -1;
for (const list of allLists) {
const rect = list.getBoundingClientRect();
// Check if cursor is within this column's bounds (with some padding)
if (e.clientX >= rect.left - 20 && e.clientX <= rect.right + 20 &&
e.clientY >= rect.top - 10 && e.clientY <= rect.bottom + 10) {
targetColumnId = list.dataset.columnId;
// Find visible cards in this column (excluding hidden ones and the dragged card)
const cards = [...list.querySelectorAll('.card:not(.drag-source-hidden):not(.card-hidden)')];
if (cards.length === 0) {
// Empty column or all filtered - append at end
targetIndex = getColumnCardCount(targetColumnId);
}
else {
// Find the nearest card to determine insertion point
let bestIdx = cards.length; // default: after last
for (let i = 0; i < cards.length; i++) {
const cardRect = cards[i].getBoundingClientRect();
const midY = cardRect.top + cardRect.height / 2;
if (e.clientY < midY) {
bestIdx = i;
break;
}
}
// Map visible index to model index
targetIndex = mapVisibleToModelIndex(targetColumnId, bestIdx);
}
break;
}
}
if (!targetColumnId)
return;
// Move placeholder to the right position
const targetList = document.querySelector(`.card-list[data-column-id="${targetColumnId}"]`);
if (!targetList)
return;
// Get all visible cards in this list (excluding hidden and drag source)
const visibleCards = [...targetList.querySelectorAll('.card:not(.drag-source-hidden):not(.card-hidden)')];
let insertBefore = null;
if (targetIndex < visibleCards.length && targetIndex >= 0) {
// Find the card at this model index among visible cards
const model = State.getBoard();
const col = model.columns.find(c => c.id === targetColumnId);
if (col) {
let visCount = 0;
for (const vc of visibleCards) {
const vcCardId = vc.dataset.cardId;
const modelIdx = col.cardIds.indexOf(vcCardId);
if (modelIdx === targetIndex) {
insertBefore = vc;
break;
}
visCount++;
}
}
}
// If we couldn't find a specific card, use the visible index approach
if (!insertBefore && targetIndex < visibleCards.length) {
insertBefore = visibleCards[targetIndex];
}
else if (!insertBefore) {
insertBefore = null; // append at end
}
// Move placeholder
const emptyZone = targetList.querySelector('.empty-column-zone');
if (visibleCards.length === 0 && !insertBefore) {
// Empty column - place before empty zone
if (emptyZone) {
targetList.insertBefore(placeholderEl, emptyZone);
}
else {
targetList.appendChild(placeholderEl);
}
}
else if (insertBefore) {
targetList.insertBefore(placeholderEl, insertBefore);
}
else {
// After last card but before empty zone
if (emptyZone) {
targetList.insertBefore(placeholderEl, emptyZone);
}
else {
targetList.appendChild(placeholderEl);
}
}
}
function getColumnCardCount(columnId) {
const model = State.getBoard();
const col = model.columns.find(c => c.id === columnId);
return col ? col.cardIds.length : 0;
}
function mapVisibleToModelIndex(columnId, visibleIndex) {
const model = State.getBoard();
const col = model.columns.find(c => c.id === columnId);
if (!col)
return visibleIndex;
let visCount = 0;
for (const cardId of col.cardIds) {
// Skip the dragged card itself
if (dragState?.cardId === cardId && dragState.sourceColumnId === columnId)
continue;
const card = model.cards[cardId];
if (!card)
continue;
// Check visibility based on filters
if (!isCardVisibleByFilter(card))
continue;
if (visCount === visibleIndex)
return col.cardIds.indexOf(cardId);
visCount++;
}
return Math.min(visibleIndex, col.cardIds.length - 1);
}
function isCardVisibleByFilter(card) {
const filter = State.getFilter();
if (filter.searchQuery.trim()) {
const q = filter.searchQuery.toLowerCase();
if (!card.title.toLowerCase().includes(q))
return false;
}
if (filter.activeLabels.size > 0) {
const hasActiveLabel = card.labels.some(l => filter.activeLabels.has(l));
if (!hasActiveLabel)
return false;
}
return true;
}
// ─── Column Drop Position Update ─────────────────────────────────────
function updateColumnDropPosition(e) {
const wrappers = document.querySelectorAll('.column-wrapper:not(.drag-source-hidden)');
for (const wrapper of wrappers) {
const rect = wrapper.getBoundingClientRect();
const midX = rect.left + rect.width / 2;
if (Math.abs(e.clientX - midX) < rect.width / 2) {
// Highlight this column as drop target
wrapper.classList.add('column-drop-target');
// Move placeholder to show insertion point
if (!placeholderEl) {
placeholderEl = h('div', { class: 'column-placeholder' });
}
const board = document.getElementById('board');
if (e.clientX < midX) {
board.insertBefore(placeholderEl, wrapper);
}
else {
const next = wrapper.nextElementSibling;
if (next && next.classList.contains('column-wrapper')) {
board.insertBefore(placeholderEl, next);
}
else {
board.appendChild(placeholderEl);
}
}
// Remove highlight from others
wrappers.forEach(w => {
if (w !== wrapper)
w.classList.remove('column-drop-target');
});
break;
}
}
}
// ─── Mouse Up ────────────────────────────────────────────────────────
function onMouseUp(e) {
if (!dragState)
return;
// Clean up auto-scroll
if (autoScrollRAF) {
cancelAnimationFrame(autoScrollRAF);
autoScrollRAF = 0;
}
if (dragState.type === 'card') {
commitCardDrop(e);
}
else {
commitColumnDrop(e);
}
cleanupDrag();
}
// ─── Commit Card Drop ────────────────────────────────────────────────
function commitCardDrop(_e) {
if (!dragState || !placeholderEl)
return;
const cardId = dragState.cardId;
const sourceColumnId = dragState.sourceColumnId;
const sourceIndex = dragState.sourceIndex;
// Find where placeholder is now
const targetList = placeholderEl.parentNode;
if (!targetList) {
// No valid drop target - cancel
return;
}
const targetColumnId = targetList.dataset.columnId;
if (!targetColumnId)
return;
// Calculate target index based on placeholder position among visible cards
const allCardsInTarget = [...targetList.querySelectorAll('.card:not(.drag-source-hidden):not(.card-hidden)')];
let targetIndex = allCardsInTarget.length; // default: append at end
for (let i = 0; i < allCardsInTarget.length; i++) {
if (allCardsInTarget[i] === placeholderEl ||
(placeholderEl && allCardsInTarget[i].previousElementSibling === placeholderEl)) {
targetIndex = i;
break;
}
}
// Map visible index to model index
const model = State.getBoard();
const targetCol = model.columns.find(c => c.id === targetColumnId);
if (!targetCol)
return;
let modelTargetIndex = targetCol.cardIds.length;
let visCount = 0;
for (let i = 0; i < targetCol.cardIds.length; i++) {
const cid = targetCol.cardIds[i];
if (cid === cardId && sourceColumnId === targetColumnId)
continue; // skip self in same column
const card = model.cards[cid];
if (!card || !isCardVisibleByFilter(card))
continue;
if (visCount === targetIndex) {
modelTargetIndex = i;
break;
}
visCount++;
}
// Adjust for same-column drag: if moving after current position, account for removal
if (sourceColumnId === targetColumnId && sourceIndex < modelTargetIndex) {
modelTargetIndex--;
}
// Clamp index
modelTargetIndex = Math.max(0, Math.min(modelTargetIndex, targetCol.cardIds.length));
// Execute the move in state
State.moveCard(cardId, sourceColumnId, targetColumnId, modelTargetIndex);
(0, persistence_1.scheduleSave)();
(0, rendering_1.render)();
}
// ─── Commit Column Drop ──────────────────────────────────────────────
function commitColumnDrop(_e) {
if (!dragState || !placeholderEl)
return;
const sourceIndex = dragState.sourceIndex;
// Find where placeholder is in the board
const board = document.getElementById('board');
const children = [...board.querySelectorAll('.column-wrapper:not(.drag-source-hidden)')];
let targetIndex = children.length;
for (let i = 0; i < children.length; i++) {
if (children[i].previousElementSibling === placeholderEl ||
(i > 0 && children[i - 1] === placeholderEl)) {
targetIndex = i;
break;
}
}
// Adjust for removal of source column
let modelTargetIndex = targetIndex;
if (sourceIndex < modelTargetIndex) {
modelTargetIndex--;
}
modelTargetIndex = Math.max(0, Math.min(modelTargetIndex, State.getBoard().columns.length - 1));
State.reorderColumns(sourceIndex, modelTargetIndex);
(0, persistence_1.scheduleSave)();
(0, rendering_1.render)();
}
// ─── Cancel Drag (Escape key) ────────────────────────────────────────
function onKeyDown(e) {
if (e.key === 'Escape' && dragState) {
cancelDrag();
}
}
function cancelDrag() {
// Remove ghost and placeholder - original card stays hidden, render will restore it
cleanupDrag();
(0, rendering_1.render)();
}
// ─── Cleanup ─────────────────────────────────────────────────────────
function cleanupDrag() {
if (ghostEl) {
ghostEl.remove();
ghostEl = null;
}
if (placeholderEl) {
placeholderEl.remove();
placeholderEl = null;
}
// Remove drag-source-hidden from all elements
document.querySelectorAll('.drag-source-hidden').forEach(el => el.classList.remove('drag-source-hidden'));
document.querySelectorAll('.column-drop-target').forEach(el => el.classList.remove('column-drop-target'));
if (autoScrollRAF) {
cancelAnimationFrame(autoScrollRAF);
autoScrollRAF = 0;
}
dragState = null;
}
// ─── Auto-Scroll ─────────────────────────────────────────────────────
function handleAutoScroll(e) {
if (autoScrollRAF)
return; // already scrolling
const margin = types_1.AUTO_SCROLL_MARGIN;
let scrollX = 0, scrollY = 0;
// Horizontal board scroll
if (e.clientX < margin)
scrollX = -types_1.AUTO_SCROLL_SPEED;
else if (e.clientX > window.innerWidth - margin)
scrollX = types_1.AUTO_SCROLL_SPEED;
// Vertical column scroll
const lists = document.querySelectorAll('.card-list');
for (const list of lists) {
const rect = list.getBoundingClientRect();
if (e.clientX >= rect.left && e.clientX <= rect.right) {
if (e.clientY < rect.top + margin && list.scrollTop > 0) {
scrollY = -types_1.AUTO_SCROLL_SPEED;
}
else if (e.clientY > rect.bottom - margin &&
list.scrollTop < list.scrollHeight - list.clientHeight) {
scrollY = types_1.AUTO_SCROLL_SPEED;
}
}
}
// Board horizontal scroll
const boardContainer = document.getElementById('board-container');
if (scrollX !== 0) {
boardContainer.scrollLeft += scrollX;
}
// Column vertical scroll
for (const list of lists) {
const rect = list.getBoundingClientRect();
if (e.clientX >= rect.left && e.clientX <= rect.right) {
if (e.clientY < rect.top + margin && list.scrollTop > 0) {
list.scrollTop -= types_1.AUTO_SCROLL_SPEED;
}
else if (e.clientY > rect.bottom - margin &&
list.scrollTop < list.scrollHeight - list.clientHeight) {
list.scrollTop += types_1.AUTO_SCROLL_SPEED;
}
}
}
// Continue scrolling via rAF
autoScrollRAF = requestAnimationFrame(() => {
autoScrollRAF = 0;
handleAutoScroll(e);
});
}
// ─── Placeholder Element ─────────────────────────────────────────────
function createPlaceholder(cardEl) {
const placeholder = h('div', { class: 'drop-placeholder' });
placeholder.style.height = `${cardEl.offsetHeight}px`;
return placeholder;
}
// ─── DOM Helper ──────────────────────────────────────────────────────
function h(tag, attrs) {
const element = document.createElement(tag);
if (attrs) {
for (const [key, value] of Object.entries(attrs)) {
if (value === undefined || value === null)
continue;
if (key === 'style') {
element.style.cssText = String(value);
}
else if (key === 'class') {
element.className = String(value);
}
else if (key.startsWith('data-')) {
const dataKey = key.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
element.dataset[dataKey] = String(value);
}
else if (key.includes('-') || !(key in element)) {
element.setAttribute(key, String(value));
}
else {
try {
element[key] = value;
}
catch { }
}
}
}
return element;
}
};
// ── module: src/persistence.ts ──
__mods["src/persistence.ts"] = function (exports, require, module) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.save = save;
exports.load = load;
exports.scheduleSave = scheduleSave;
const State = __importStar(require("./state"));
const STORAGE_KEY = 'kanban-board-state';
// ─── Persistence ──────────────────────────────────────────────────────
function save() {
try {
localStorage.setItem(STORAGE_KEY, State.serialize());
}
catch { /* quota exceeded or private mode */ }
}
function load() {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (data) {
State.deserialize(data);
return true;
}
}
catch { /* ignore corrupt data */ }
return false;
}
// Auto-save on state changes
let saveTimeout = null;
function scheduleSave() {
if (saveTimeout)
clearTimeout(saveTimeout);
saveTimeout = window.setTimeout(save, 200);
}
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"typescript","sessionId":"b8a80ccd-f68a-444c-adde-48d5f4c864af","tokensIn":5874634,"tokensOut":53138,"tokensTotal":5927772,"turns":106,"toolCalls":105,"failedToolCalls":3,"timestamp":"2026-07-30T23:19:29.247Z"} -->