feat: add kanban board
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
import * as vscode from "vscode";
|
||||
|
||||
const NONCE_LENGTH = 32;
|
||||
|
||||
/** Kanban board document (columns + HTML5 drag-and-drop). */
|
||||
export function getTaskKanbanHtml(webview: vscode.Webview, nonce: string): string {
|
||||
const csp = [
|
||||
"default-src 'none'",
|
||||
`style-src ${webview.cspSource} 'unsafe-inline'`,
|
||||
`script-src 'nonce-${nonce}'`,
|
||||
].join("; ");
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp}" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kanban</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size);
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-editor-background);
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--vscode-panel-border, var(--vscode-widget-border));
|
||||
}
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 4px 10px;
|
||||
color: var(--vscode-button-foreground);
|
||||
background: var(--vscode-button-background);
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
button.secondary {
|
||||
background: var(--vscode-button-secondaryBackground, var(--vscode-input-background));
|
||||
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
|
||||
}
|
||||
#status-line {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.85;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
#status-line.error { color: var(--vscode-errorForeground); }
|
||||
#board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
height: calc(100% - 48px);
|
||||
overflow: auto;
|
||||
align-items: stretch;
|
||||
}
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 200px;
|
||||
border: 1px solid var(--vscode-panel-border, var(--vscode-widget-border));
|
||||
border-radius: 4px;
|
||||
background: var(--vscode-sideBar-background, transparent);
|
||||
}
|
||||
.column.drag-over {
|
||||
outline: 2px solid var(--vscode-focusBorder);
|
||||
}
|
||||
.column-header {
|
||||
padding: 8px 10px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9em;
|
||||
border-bottom: 1px solid var(--vscode-panel-border, var(--vscode-widget-border));
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.column-header .count {
|
||||
opacity: 0.7;
|
||||
font-weight: 400;
|
||||
}
|
||||
.column-body {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow: auto;
|
||||
min-height: 80px;
|
||||
}
|
||||
.card {
|
||||
padding: 8px 10px;
|
||||
border-radius: 3px;
|
||||
background: var(--vscode-editor-background);
|
||||
border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border));
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
.card:active { cursor: grabbing; }
|
||||
.card .meta {
|
||||
margin-top: 4px;
|
||||
font-size: 0.85em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Kanban</h1>
|
||||
<span id="status-line"></span>
|
||||
<button id="refresh" type="button" class="secondary" title="Refresh">↻</button>
|
||||
</header>
|
||||
<div id="board"></div>
|
||||
<script nonce="${nonce}">
|
||||
const vscode = acquireVsCodeApi();
|
||||
const boardEl = document.getElementById("board");
|
||||
const statusLine = document.getElementById("status-line");
|
||||
const refreshBtn = document.getElementById("refresh");
|
||||
|
||||
let board = { columns: [] };
|
||||
let dragTaskId = null;
|
||||
|
||||
function post(message) {
|
||||
vscode.postMessage(message);
|
||||
}
|
||||
|
||||
function setStatus(text, isError) {
|
||||
statusLine.textContent = text || "";
|
||||
statusLine.classList.toggle("error", Boolean(isError));
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function render() {
|
||||
boardEl.innerHTML = "";
|
||||
for (const column of board.columns || []) {
|
||||
const col = document.createElement("section");
|
||||
col.className = "column";
|
||||
col.dataset.status = column.status;
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "column-header";
|
||||
header.innerHTML =
|
||||
"<span>" + escapeHtml(column.label) + "</span>" +
|
||||
'<span class="count">' + column.tasks.length + "</span>";
|
||||
col.appendChild(header);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "column-body";
|
||||
body.dataset.status = column.status;
|
||||
|
||||
body.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
col.classList.add("drag-over");
|
||||
});
|
||||
body.addEventListener("dragleave", () => {
|
||||
col.classList.remove("drag-over");
|
||||
});
|
||||
body.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
col.classList.remove("drag-over");
|
||||
const id = event.dataTransfer.getData("text/task-id") || dragTaskId;
|
||||
const status = column.status;
|
||||
if (!id) return;
|
||||
post({ type: "moveTask", id: id, status: status });
|
||||
dragTaskId = null;
|
||||
});
|
||||
|
||||
for (const task of column.tasks) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "card";
|
||||
card.draggable = true;
|
||||
card.dataset.id = task.id;
|
||||
card.innerHTML =
|
||||
"<div>" + escapeHtml(task.title) + "</div>" +
|
||||
'<div class="meta">' + escapeHtml(task.priority) + "</div>";
|
||||
card.addEventListener("dragstart", (event) => {
|
||||
dragTaskId = task.id;
|
||||
event.dataTransfer.setData("text/task-id", task.id);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
card.addEventListener("dragend", () => {
|
||||
dragTaskId = null;
|
||||
document.querySelectorAll(".column.drag-over").forEach((el) => {
|
||||
el.classList.remove("drag-over");
|
||||
});
|
||||
});
|
||||
body.appendChild(card);
|
||||
}
|
||||
|
||||
col.appendChild(body);
|
||||
boardEl.appendChild(col);
|
||||
}
|
||||
}
|
||||
|
||||
refreshBtn.addEventListener("click", () => {
|
||||
post({ type: "refresh" });
|
||||
});
|
||||
|
||||
window.addEventListener("message", (event) => {
|
||||
const message = event.data;
|
||||
if (!message || typeof message !== "object") return;
|
||||
if (message.type === "state") {
|
||||
board = message.board || { columns: [] };
|
||||
setStatus("");
|
||||
render();
|
||||
} else if (message.type === "error") {
|
||||
setStatus((message.error && message.error.message) || "Error", true);
|
||||
}
|
||||
});
|
||||
|
||||
post({ type: "ready" });
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function createKanbanNonce(): string {
|
||||
const alphabet =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let result = "";
|
||||
for (let i = 0; i < NONCE_LENGTH; i += 1) {
|
||||
result += alphabet.charAt(Math.floor(Math.random() * alphabet.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user