devglide 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +338 -0
- package/bin/claude-md-template.js +94 -0
- package/bin/devglide.js +387 -0
- package/package.json +85 -0
- package/pnpm-workspace.yaml +3 -0
- package/src/apps/coder/.turbo/turbo-lint.log +5 -0
- package/src/apps/coder/package.json +16 -0
- package/src/apps/coder/public/favicon.svg +7 -0
- package/src/apps/coder/public/page.css +275 -0
- package/src/apps/coder/public/page.js +528 -0
- package/src/apps/coder/server.js +3 -0
- package/src/apps/documentation/public/page.css +597 -0
- package/src/apps/documentation/public/page.js +609 -0
- package/src/apps/kanban/.turbo/turbo-lint.log +97 -0
- package/src/apps/kanban/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/kanban/package.json +32 -0
- package/src/apps/kanban/public/favicon.svg +7 -0
- package/src/apps/kanban/public/page.css +1010 -0
- package/src/apps/kanban/public/page.js +1730 -0
- package/src/apps/kanban/public/vendor/marked.min.js +6 -0
- package/src/apps/kanban/public/vendor/sortable.min.js +2 -0
- package/src/apps/kanban/src/db.ts +319 -0
- package/src/apps/kanban/src/index.ts +14 -0
- package/src/apps/kanban/src/mcp-helpers.test.ts +88 -0
- package/src/apps/kanban/src/mcp-helpers.ts +60 -0
- package/src/apps/kanban/src/mcp.ts +59 -0
- package/src/apps/kanban/src/routes/attachments.ts +161 -0
- package/src/apps/kanban/src/routes/features.ts +233 -0
- package/src/apps/kanban/src/routes/issues.ts +373 -0
- package/src/apps/kanban/src/tools/feature-tools.ts +164 -0
- package/src/apps/kanban/src/tools/item-tools.ts +307 -0
- package/src/apps/kanban/src/tools/versioned-entry-tools.ts +72 -0
- package/src/apps/kanban/tsconfig.check.json +9 -0
- package/src/apps/kanban/tsconfig.json +9 -0
- package/src/apps/keymap/.turbo/turbo-lint.log +5 -0
- package/src/apps/keymap/package.json +16 -0
- package/src/apps/keymap/public/page.css +275 -0
- package/src/apps/keymap/public/page.js +294 -0
- package/src/apps/keymap/server.js +25 -0
- package/src/apps/log/.turbo/turbo-build.log +5 -0
- package/src/apps/log/.turbo/turbo-lint.log +45 -0
- package/src/apps/log/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/log/node_modules/.bin/tsc +21 -0
- package/src/apps/log/node_modules/.bin/tsserver +21 -0
- package/src/apps/log/node_modules/.bin/tsx +21 -0
- package/src/apps/log/package.json +36 -0
- package/src/apps/log/public/console-sniffer.js +221 -0
- package/src/apps/log/public/favicon.svg +7 -0
- package/src/apps/log/public/page.css +322 -0
- package/src/apps/log/public/page.js +463 -0
- package/src/apps/log/src/index.ts +9 -0
- package/src/apps/log/src/mcp.ts +122 -0
- package/src/apps/log/src/routes/log.ts +333 -0
- package/src/apps/log/src/routes/status.ts +25 -0
- package/src/apps/log/src/server-sniffer.ts +118 -0
- package/src/apps/log/src/services/file-patterns.ts +39 -0
- package/src/apps/log/src/services/file-tailer.ts +228 -0
- package/src/apps/log/src/services/line-parser.ts +94 -0
- package/src/apps/log/src/services/log-writer.ts +39 -0
- package/src/apps/log/tsconfig.json +8 -0
- package/src/apps/prompts/.turbo/turbo-build.log +5 -0
- package/src/apps/prompts/.turbo/turbo-lint.log +24 -0
- package/src/apps/prompts/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/prompts/mcp.ts +175 -0
- package/src/apps/prompts/node_modules/.bin/tsc +21 -0
- package/src/apps/prompts/node_modules/.bin/tsserver +21 -0
- package/src/apps/prompts/node_modules/.bin/tsx +21 -0
- package/src/apps/prompts/package.json +25 -0
- package/src/apps/prompts/public/page.css +315 -0
- package/src/apps/prompts/public/page.js +541 -0
- package/src/apps/prompts/services/prompt-store.ts +212 -0
- package/src/apps/prompts/src/index.ts +9 -0
- package/src/apps/prompts/tsconfig.json +8 -0
- package/src/apps/prompts/types.ts +27 -0
- package/src/apps/shell/.turbo/turbo-build.log +5 -0
- package/src/apps/shell/.turbo/turbo-lint.log +34 -0
- package/src/apps/shell/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/shell/package.json +35 -0
- package/src/apps/shell/public/favicon.svg +7 -0
- package/src/apps/shell/public/page.css +407 -0
- package/src/apps/shell/public/page.js +1577 -0
- package/src/apps/shell/src/index.ts +150 -0
- package/src/apps/shell/src/mcp.ts +398 -0
- package/src/apps/shell/src/shell-types.ts +41 -0
- package/src/apps/shell/tsconfig.json +8 -0
- package/src/apps/test/.turbo/turbo-build.log +5 -0
- package/src/apps/test/.turbo/turbo-lint.log +27 -0
- package/src/apps/test/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/test/node_modules/.bin/tsc +21 -0
- package/src/apps/test/node_modules/.bin/tsserver +21 -0
- package/src/apps/test/node_modules/.bin/tsx +21 -0
- package/src/apps/test/node_modules/.bin/uuid +21 -0
- package/src/apps/test/package.json +35 -0
- package/src/apps/test/public/favicon.svg +7 -0
- package/src/apps/test/public/page.css +499 -0
- package/src/apps/test/public/page.js +417 -0
- package/src/apps/test/public/scenario-runner.js +450 -0
- package/src/apps/test/src/index.ts +9 -0
- package/src/apps/test/src/mcp.ts +192 -0
- package/src/apps/test/src/routes/trigger.ts +285 -0
- package/src/apps/test/src/services/scenario-broadcaster.ts +60 -0
- package/src/apps/test/src/services/scenario-manager.ts +361 -0
- package/src/apps/test/src/services/scenario-store.ts +145 -0
- package/src/apps/test/tsconfig.json +8 -0
- package/src/apps/vocabulary/.turbo/turbo-build.log +5 -0
- package/src/apps/vocabulary/.turbo/turbo-lint.log +25 -0
- package/src/apps/vocabulary/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/vocabulary/mcp.ts +173 -0
- package/src/apps/vocabulary/node_modules/.bin/tsc +21 -0
- package/src/apps/vocabulary/node_modules/.bin/tsserver +21 -0
- package/src/apps/vocabulary/node_modules/.bin/tsx +21 -0
- package/src/apps/vocabulary/package.json +25 -0
- package/src/apps/vocabulary/public/page.css +247 -0
- package/src/apps/vocabulary/public/page.js +444 -0
- package/src/apps/vocabulary/services/vocabulary-store.ts +179 -0
- package/src/apps/vocabulary/src/index.ts +10 -0
- package/src/apps/vocabulary/tsconfig.json +8 -0
- package/src/apps/vocabulary/types.ts +22 -0
- package/src/apps/voice/.turbo/turbo-build.log +5 -0
- package/src/apps/voice/.turbo/turbo-lint.log +43 -0
- package/src/apps/voice/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/voice/node_modules/.bin/openai +21 -0
- package/src/apps/voice/node_modules/.bin/tsc +21 -0
- package/src/apps/voice/node_modules/.bin/tsserver +21 -0
- package/src/apps/voice/node_modules/.bin/tsx +21 -0
- package/src/apps/voice/package.json +35 -0
- package/src/apps/voice/public/favicon.svg +7 -0
- package/src/apps/voice/public/page.css +388 -0
- package/src/apps/voice/public/page.js +718 -0
- package/src/apps/voice/src/index.ts +10 -0
- package/src/apps/voice/src/mcp.ts +70 -0
- package/src/apps/voice/src/providers/index.ts +85 -0
- package/src/apps/voice/src/providers/openai-compatible.ts +94 -0
- package/src/apps/voice/src/providers/types.ts +27 -0
- package/src/apps/voice/src/routes/config.ts +118 -0
- package/src/apps/voice/src/routes/transcribe.ts +90 -0
- package/src/apps/voice/src/services/config-store.ts +129 -0
- package/src/apps/voice/src/services/stats.ts +108 -0
- package/src/apps/voice/src/transcribe.ts +11 -0
- package/src/apps/voice/src/utils/mime.ts +16 -0
- package/src/apps/voice/tsconfig.json +8 -0
- package/src/apps/workflow/.turbo/turbo-build.log +5 -0
- package/src/apps/workflow/.turbo/turbo-lint.log +96 -0
- package/src/apps/workflow/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/workflow/engine/executors/decision-executor.ts +87 -0
- package/src/apps/workflow/engine/executors/file-executor.ts +90 -0
- package/src/apps/workflow/engine/executors/git-executor.ts +137 -0
- package/src/apps/workflow/engine/executors/http-executor.ts +65 -0
- package/src/apps/workflow/engine/executors/index.ts +28 -0
- package/src/apps/workflow/engine/executors/kanban-executor.ts +154 -0
- package/src/apps/workflow/engine/executors/llm-executor.ts +46 -0
- package/src/apps/workflow/engine/executors/log-executor.ts +62 -0
- package/src/apps/workflow/engine/executors/loop-executor.ts +14 -0
- package/src/apps/workflow/engine/executors/shell-executor.ts +107 -0
- package/src/apps/workflow/engine/executors/sub-workflow-executor.ts +61 -0
- package/src/apps/workflow/engine/executors/test-executor.ts +73 -0
- package/src/apps/workflow/engine/executors/trigger-executor.ts +39 -0
- package/src/apps/workflow/engine/expression-evaluator.ts +117 -0
- package/src/apps/workflow/engine/graph-runner.ts +438 -0
- package/src/apps/workflow/engine/node-executor.ts +104 -0
- package/src/apps/workflow/engine/node-registry.ts +15 -0
- package/src/apps/workflow/engine/variable-resolver.ts +109 -0
- package/src/apps/workflow/mcp.ts +223 -0
- package/src/apps/workflow/node_modules/.bin/tsc +21 -0
- package/src/apps/workflow/node_modules/.bin/tsserver +21 -0
- package/src/apps/workflow/node_modules/.bin/tsx +21 -0
- package/src/apps/workflow/package.json +25 -0
- package/src/apps/workflow/public/editor/canvas.js +366 -0
- package/src/apps/workflow/public/editor/drag-manager.js +326 -0
- package/src/apps/workflow/public/editor/edge-renderer.js +235 -0
- package/src/apps/workflow/public/editor/history-manager.js +147 -0
- package/src/apps/workflow/public/editor/layout-engine.js +159 -0
- package/src/apps/workflow/public/editor/node-renderer.js +199 -0
- package/src/apps/workflow/public/editor/selection-manager.js +193 -0
- package/src/apps/workflow/public/favicon.svg +7 -0
- package/src/apps/workflow/public/models/node-types.js +300 -0
- package/src/apps/workflow/public/models/workflow-model.js +257 -0
- package/src/apps/workflow/public/page.css +406 -0
- package/src/apps/workflow/public/page.js +658 -0
- package/src/apps/workflow/public/panels/inspector.js +360 -0
- package/src/apps/workflow/public/panels/palette.js +106 -0
- package/src/apps/workflow/public/panels/run-view.js +275 -0
- package/src/apps/workflow/public/panels/toolbar.js +232 -0
- package/src/apps/workflow/public/panels/workflow-list.js +237 -0
- package/src/apps/workflow/public/state/store.js +47 -0
- package/src/apps/workflow/services/custom-node-loader.ts +48 -0
- package/src/apps/workflow/services/legacy-converter.ts +72 -0
- package/src/apps/workflow/services/run-manager.ts +190 -0
- package/src/apps/workflow/services/workflow-store.ts +424 -0
- package/src/apps/workflow/services/workflow-validator.test.ts +103 -0
- package/src/apps/workflow/services/workflow-validator.ts +98 -0
- package/src/apps/workflow/src/index.ts +10 -0
- package/src/apps/workflow/templates/ci-pipeline.json +18 -0
- package/src/apps/workflow/templates/code-review.json +22 -0
- package/src/apps/workflow/templates/kanban-testing.json +24 -0
- package/src/apps/workflow/tsconfig.json +8 -0
- package/src/apps/workflow/types.ts +268 -0
- package/src/packages/auth-middleware.ts +14 -0
- package/src/packages/design-tokens/.turbo/turbo-build.log +10 -0
- package/src/packages/design-tokens/STYLEGUIDE.md +414 -0
- package/src/packages/design-tokens/build.js +413 -0
- package/src/packages/design-tokens/demo/index.html +1367 -0
- package/src/packages/design-tokens/demo/proposition-a.html +717 -0
- package/src/packages/design-tokens/demo/proposition-b.html +1239 -0
- package/src/packages/design-tokens/demo/proposition-c.html +1049 -0
- package/src/packages/design-tokens/dist/tailwind-preset.js +115 -0
- package/src/packages/design-tokens/dist/tokens.css +345 -0
- package/src/packages/design-tokens/dist/tokens.d.ts +229 -0
- package/src/packages/design-tokens/dist/tokens.js +386 -0
- package/src/packages/design-tokens/package.json +25 -0
- package/src/packages/design-tokens/tokens.json +228 -0
- package/src/packages/devtools-middleware.ts +22 -0
- package/src/packages/eslint-config/index.js +63 -0
- package/src/packages/eslint-config/node_modules/.bin/eslint +21 -0
- package/src/packages/eslint-config/package.json +18 -0
- package/src/packages/json-file-store.ts +232 -0
- package/src/packages/mcp-utils/.turbo/turbo-build.log +5 -0
- package/src/packages/mcp-utils/dist/index.d.ts +33 -0
- package/src/packages/mcp-utils/dist/index.d.ts.map +1 -0
- package/src/packages/mcp-utils/dist/index.js +126 -0
- package/src/packages/mcp-utils/dist/index.js.map +1 -0
- package/src/packages/mcp-utils/node_modules/.bin/tsc +21 -0
- package/src/packages/mcp-utils/node_modules/.bin/tsserver +21 -0
- package/src/packages/mcp-utils/package.json +32 -0
- package/src/packages/mcp-utils/src/index.ts +171 -0
- package/src/packages/mcp-utils/tsconfig.json +9 -0
- package/src/packages/paths.ts +18 -0
- package/src/packages/project-context/index.js +55 -0
- package/src/packages/project-context/package.json +13 -0
- package/src/packages/project-store.ts +127 -0
- package/src/packages/server-sniffer.ts +132 -0
- package/src/packages/shared-assets/favicon.svg +7 -0
- package/src/packages/shared-assets/keymap-registry.js +512 -0
- package/src/packages/shared-assets/logo.svg +6 -0
- package/src/packages/shared-assets/package.json +11 -0
- package/src/packages/shared-assets/ui-utils.js +48 -0
- package/src/packages/shared-assets/voice-widget.d.ts +37 -0
- package/src/packages/shared-assets/voice-widget.js +695 -0
- package/src/packages/shared-types/.turbo/turbo-build.log +5 -0
- package/src/packages/shared-types/dist/index.d.ts +39 -0
- package/src/packages/shared-types/dist/index.d.ts.map +1 -0
- package/src/packages/shared-types/node_modules/.bin/tsc +21 -0
- package/src/packages/shared-types/node_modules/.bin/tsserver +21 -0
- package/src/packages/shared-types/package.json +25 -0
- package/src/packages/shared-types/src/index.ts +41 -0
- package/src/packages/shared-types/tsconfig.json +11 -0
- package/src/packages/tsconfig/base.json +15 -0
- package/src/packages/tsconfig/next.json +14 -0
- package/src/packages/tsconfig/node.json +11 -0
- package/src/packages/tsconfig/package.json +10 -0
- package/turbo.json +25 -0
|
@@ -0,0 +1,1730 @@
|
|
|
1
|
+
// ── Kanban App — Native Page Module ───────────────────────────────────────────
|
|
2
|
+
// ES module: mount(container, ctx), unmount(container), onProjectChange(project)
|
|
3
|
+
//
|
|
4
|
+
// This replaces the iframe-based page module with a fully native implementation.
|
|
5
|
+
// All DOM queries are scoped to `_root` (the container).
|
|
6
|
+
|
|
7
|
+
import { escapeHtml, escapeAttr, normalizeEscapes } from '/shared-assets/ui-utils.js';
|
|
8
|
+
|
|
9
|
+
let _root = null;
|
|
10
|
+
let _projectId = null;
|
|
11
|
+
let _navigate = null;
|
|
12
|
+
|
|
13
|
+
// ── Vendor library loading ───────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
let _vendorsReady = null;
|
|
16
|
+
|
|
17
|
+
function loadVendors() {
|
|
18
|
+
if (_vendorsReady) return _vendorsReady;
|
|
19
|
+
// Load sequentially: the AMD shim sets window.define=undefined then restores it
|
|
20
|
+
// in onload. Loading in parallel causes the second script to capture the already-
|
|
21
|
+
// cleared window.define, so when the first script's onload restores Monaco's AMD
|
|
22
|
+
// define, the second script executes with define present and registers via AMD
|
|
23
|
+
// instead of setting window.marked/window.Sortable.
|
|
24
|
+
_vendorsReady = loadScript('/app/kanban/vendor/sortable.min.js', 'Sortable')
|
|
25
|
+
.then(() => loadScript('/app/kanban/vendor/marked.min.js', 'marked'));
|
|
26
|
+
return _vendorsReady;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function loadScript(src, globalName) {
|
|
30
|
+
if (window[globalName]) return Promise.resolve();
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const existing = document.querySelector(`script[src="${src}"]`);
|
|
33
|
+
if (existing) {
|
|
34
|
+
// Script tag exists — wait for global to appear (may still be loading)
|
|
35
|
+
if (window[globalName]) { resolve(); return; }
|
|
36
|
+
existing.addEventListener('load', resolve, { once: true });
|
|
37
|
+
existing.addEventListener('error', reject, { once: true });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
// Hide AMD define so UMD scripts don't conflict with Monaco's loader
|
|
41
|
+
const amdDefine = window.define;
|
|
42
|
+
window.define = undefined;
|
|
43
|
+
const s = document.createElement('script');
|
|
44
|
+
s.src = src;
|
|
45
|
+
s.onload = () => { window.define = amdDefine; resolve(); };
|
|
46
|
+
s.onerror = (e) => { window.define = amdDefine; reject(e); };
|
|
47
|
+
document.head.appendChild(s);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── API helpers ──────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function apiFetch(url, options = {}) {
|
|
54
|
+
const headers = { ...options.headers };
|
|
55
|
+
if (_projectId) headers['x-project-id'] = _projectId;
|
|
56
|
+
return fetch(url, { ...options, headers });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let _toastTimer = null;
|
|
60
|
+
function showToast(msg, type = 'error') {
|
|
61
|
+
if (!_root) return;
|
|
62
|
+
let toast = _root.querySelector('.toast');
|
|
63
|
+
if (!toast) {
|
|
64
|
+
toast = document.createElement('div');
|
|
65
|
+
toast.className = 'toast';
|
|
66
|
+
_root.appendChild(toast);
|
|
67
|
+
}
|
|
68
|
+
toast.textContent = msg;
|
|
69
|
+
toast.dataset.type = type;
|
|
70
|
+
toast.classList.add('visible');
|
|
71
|
+
clearTimeout(_toastTimer);
|
|
72
|
+
_toastTimer = setTimeout(() => toast.classList.remove('visible'), 4000);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Scoped query helpers ─────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
function $(sel) { return _root?.querySelector(sel) ?? null; }
|
|
78
|
+
function $$(sel) { return _root?.querySelectorAll(sel) ?? []; }
|
|
79
|
+
|
|
80
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
const FEATURE_COLORS = [
|
|
83
|
+
'#6366f1', '#8b5cf6', '#ec4899', '#ef4444',
|
|
84
|
+
'#f59e0b', '#22c55e', '#06b6d4', '#3b82f6',
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const PRIORITY_LABELS = { LOW: 'Low', MEDIUM: 'Medium', HIGH: 'High', URGENT: 'Urgent' };
|
|
88
|
+
|
|
89
|
+
// ── State ────────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
let features = [];
|
|
92
|
+
let pollTimer = null;
|
|
93
|
+
let boardFeature = null;
|
|
94
|
+
let boardPollTimer = null;
|
|
95
|
+
let isDragging = false;
|
|
96
|
+
let isDialogOpen = false;
|
|
97
|
+
let searchQuery = '';
|
|
98
|
+
let sortableInstances = [];
|
|
99
|
+
let selectedColor = FEATURE_COLORS[0];
|
|
100
|
+
let deleteTargetFeature = null;
|
|
101
|
+
let editTargetFeature = null;
|
|
102
|
+
let dialogState = null;
|
|
103
|
+
let pendingObjectURLs = [];
|
|
104
|
+
let _visibilityHandler = null;
|
|
105
|
+
let _searchKeydownHandler = null;
|
|
106
|
+
let _escapeHandler = null;
|
|
107
|
+
let _voiceHandler = null;
|
|
108
|
+
|
|
109
|
+
// ── Router (internal hash-based within the kanban page) ──────────────────────
|
|
110
|
+
|
|
111
|
+
function getRoute() {
|
|
112
|
+
const hash = location.hash || '#/';
|
|
113
|
+
const featureMatch = hash.match(/^#\/features\/(.+)$/);
|
|
114
|
+
if (featureMatch) return { page: 'board', featureId: featureMatch[1] };
|
|
115
|
+
return { page: 'list' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function internalNavigate(hash) {
|
|
119
|
+
location.hash = hash;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Feature list rendering ───────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
function renderFeatureList() {
|
|
125
|
+
if (!_root) return;
|
|
126
|
+
_root.innerHTML = `
|
|
127
|
+
<div class="sync-indicator" aria-live="polite">
|
|
128
|
+
<span class="sync-dot"></span>
|
|
129
|
+
Board updated
|
|
130
|
+
</div>
|
|
131
|
+
<header class="app-header">
|
|
132
|
+
<span class="app-name">Kanban</span>
|
|
133
|
+
<div class="header-actions">
|
|
134
|
+
<span class="sync-badge hidden" data-sync="list-sync">
|
|
135
|
+
<span class="sync-dot"></span> Updated
|
|
136
|
+
</span>
|
|
137
|
+
<button class="btn btn-primary" data-action="new-feature">+ New Feature</button>
|
|
138
|
+
</div>
|
|
139
|
+
</header>
|
|
140
|
+
<main class="features-container"></main>
|
|
141
|
+
${_getDialogHTML()}
|
|
142
|
+
`;
|
|
143
|
+
|
|
144
|
+
$('[data-action="new-feature"]')?.addEventListener('click', openNewFeatureDialog);
|
|
145
|
+
_bindModalOverlays();
|
|
146
|
+
renderFeatures();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function renderFeatures() {
|
|
150
|
+
const main = $('.features-container');
|
|
151
|
+
if (!main) return;
|
|
152
|
+
|
|
153
|
+
if (features.length === 0) {
|
|
154
|
+
main.innerHTML = `
|
|
155
|
+
<div class="empty-state">
|
|
156
|
+
<div class="empty-icon">
|
|
157
|
+
<img src="/favicon.svg" alt="" style="width:28px;height:28px;opacity:0.5">
|
|
158
|
+
</div>
|
|
159
|
+
<p class="empty-text">No features yet</p>
|
|
160
|
+
<button class="btn btn-primary" data-action="empty-create">+ Create your first feature</button>
|
|
161
|
+
</div>
|
|
162
|
+
`;
|
|
163
|
+
$('[data-action="empty-create"]')?.addEventListener('click', openNewFeatureDialog);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
main.innerHTML = `
|
|
168
|
+
<h1 class="section-title">Features</h1>
|
|
169
|
+
<div class="features-grid">
|
|
170
|
+
${features.map(f => `
|
|
171
|
+
<a href="#/features/${f.id}" class="feature-card" data-id="${f.id}">
|
|
172
|
+
<div class="feature-card-top">
|
|
173
|
+
<div class="feature-card-icon" style="background-color: ${f.color}30">
|
|
174
|
+
<div class="feature-card-dot" style="background-color: ${f.color}"></div>
|
|
175
|
+
</div>
|
|
176
|
+
<div class="feature-card-info">
|
|
177
|
+
<p class="feature-card-name">${escapeHtml(f.name)}</p>
|
|
178
|
+
<p class="feature-card-desc">${f.description ? escapeHtml(f.description) : ''}</p>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
<div class="feature-card-footer">
|
|
182
|
+
<span class="feature-card-count"># ${f._count.issues} active item${f._count.issues !== 1 ? 's' : ''}</span>
|
|
183
|
+
<button class="feature-edit-btn" data-id="${f.id}" title="Edit feature" aria-label="Edit feature ${escapeHtml(f.name)}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg></button>
|
|
184
|
+
<button class="feature-delete-btn" data-id="${f.id}" title="Delete feature" aria-label="Delete feature ${escapeHtml(f.name)}">×</button>
|
|
185
|
+
</div>
|
|
186
|
+
</a>
|
|
187
|
+
`).join('')}
|
|
188
|
+
</div>
|
|
189
|
+
`;
|
|
190
|
+
|
|
191
|
+
main.querySelectorAll('.feature-edit-btn').forEach(btn => {
|
|
192
|
+
btn.addEventListener('click', (e) => {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
e.stopPropagation();
|
|
195
|
+
const id = btn.dataset.id;
|
|
196
|
+
const feature = features.find(f => f.id === id);
|
|
197
|
+
if (feature) openEditFeatureDialog(feature);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
main.querySelectorAll('.feature-delete-btn').forEach(btn => {
|
|
202
|
+
btn.addEventListener('click', (e) => {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
e.stopPropagation();
|
|
205
|
+
const id = btn.dataset.id;
|
|
206
|
+
const feature = features.find(f => f.id === id);
|
|
207
|
+
if (feature) openDeleteFeatureDialog(feature);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Feature polling ──────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
function featureSignature(fs) {
|
|
215
|
+
return fs.map(f => `${f.id}:${f._count.issues}`).join('|');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function startFeaturePolling() {
|
|
219
|
+
pollTimer = setInterval(async () => {
|
|
220
|
+
try {
|
|
221
|
+
const res = await apiFetch('/api/kanban/features');
|
|
222
|
+
if (!res.ok) return;
|
|
223
|
+
const fresh = await res.json();
|
|
224
|
+
if (featureSignature(fresh) === featureSignature(features)) return;
|
|
225
|
+
features = fresh;
|
|
226
|
+
renderFeatures();
|
|
227
|
+
showSyncFlash('list-sync');
|
|
228
|
+
} catch { /* ignore */ }
|
|
229
|
+
}, 5000);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function stopPolling() {
|
|
233
|
+
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Sync flash ───────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
function showSyncFlash(name) {
|
|
239
|
+
const el = $(`[data-sync="${name}"]`);
|
|
240
|
+
if (!el) return;
|
|
241
|
+
el.classList.remove('hidden');
|
|
242
|
+
setTimeout(() => el.classList.add('hidden'), 1500);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── New Feature Dialog ───────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
function openNewFeatureDialog() {
|
|
248
|
+
const dialog = $('.modal-overlay[data-dialog="new-feature"]');
|
|
249
|
+
if (!dialog) return;
|
|
250
|
+
const nameInput = dialog.querySelector('[data-field="nf-name"]');
|
|
251
|
+
const descInput = dialog.querySelector('[data-field="nf-desc"]');
|
|
252
|
+
const colorsDiv = dialog.querySelector('[data-field="nf-colors"]');
|
|
253
|
+
|
|
254
|
+
nameInput.value = '';
|
|
255
|
+
descInput.value = '';
|
|
256
|
+
selectedColor = FEATURE_COLORS[0];
|
|
257
|
+
|
|
258
|
+
colorsDiv.innerHTML = FEATURE_COLORS.map(c =>
|
|
259
|
+
`<button type="button" class="color-swatch ${c === selectedColor ? 'selected' : ''}"
|
|
260
|
+
data-color="${c}" style="background-color: ${c}" aria-label="Select color ${c}"></button>`
|
|
261
|
+
).join('');
|
|
262
|
+
|
|
263
|
+
colorsDiv.querySelectorAll('.color-swatch').forEach(btn => {
|
|
264
|
+
btn.addEventListener('click', () => {
|
|
265
|
+
selectedColor = btn.dataset.color;
|
|
266
|
+
colorsDiv.querySelectorAll('.color-swatch').forEach(b => b.classList.remove('selected'));
|
|
267
|
+
btn.classList.add('selected');
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
dialog.classList.remove('hidden');
|
|
272
|
+
nameInput.focus();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function _bindNewFeatureDialog() {
|
|
276
|
+
const dialog = $('.modal-overlay[data-dialog="new-feature"]');
|
|
277
|
+
if (!dialog) return;
|
|
278
|
+
|
|
279
|
+
dialog.querySelector('[data-action="nf-cancel"]')?.addEventListener('click', () => {
|
|
280
|
+
dialog.classList.add('hidden');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
dialog.querySelector('[data-action="nf-create"]')?.addEventListener('click', async () => {
|
|
284
|
+
const name = dialog.querySelector('[data-field="nf-name"]').value.trim();
|
|
285
|
+
if (!name) return;
|
|
286
|
+
|
|
287
|
+
const createBtn = dialog.querySelector('[data-action="nf-create"]');
|
|
288
|
+
createBtn.disabled = true;
|
|
289
|
+
createBtn.textContent = 'Creating...';
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const res = await apiFetch('/api/kanban/features', {
|
|
293
|
+
method: 'POST',
|
|
294
|
+
headers: { 'Content-Type': 'application/json' },
|
|
295
|
+
body: JSON.stringify({
|
|
296
|
+
name,
|
|
297
|
+
description: dialog.querySelector('[data-field="nf-desc"]').value,
|
|
298
|
+
color: selectedColor,
|
|
299
|
+
}),
|
|
300
|
+
});
|
|
301
|
+
const feature = await res.json();
|
|
302
|
+
features.unshift({ ...feature, description: feature.description || null, _count: { issues: 0 } });
|
|
303
|
+
renderFeatures();
|
|
304
|
+
dialog.classList.add('hidden');
|
|
305
|
+
} finally {
|
|
306
|
+
createBtn.disabled = false;
|
|
307
|
+
createBtn.textContent = 'Create Feature';
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
dialog.querySelector('[data-field="nf-name"]')?.addEventListener('keydown', (e) => {
|
|
312
|
+
if (e.key === 'Enter') dialog.querySelector('[data-action="nf-create"]')?.click();
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── Delete Feature Dialog ────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
function openDeleteFeatureDialog(feature) {
|
|
319
|
+
deleteTargetFeature = feature;
|
|
320
|
+
const dialog = $('.modal-overlay[data-dialog="delete-feature"]');
|
|
321
|
+
if (!dialog) return;
|
|
322
|
+
const msg = dialog.querySelector('[data-field="delete-feature-msg"]');
|
|
323
|
+
let text = `Are you sure you want to delete <strong>${escapeHtml(feature.name)}</strong>?`;
|
|
324
|
+
if (feature._count.issues > 0) {
|
|
325
|
+
text += ` This will permanently remove ${feature._count.issues} item${feature._count.issues !== 1 ? 's' : ''}.`;
|
|
326
|
+
}
|
|
327
|
+
msg.innerHTML = text;
|
|
328
|
+
dialog.classList.remove('hidden');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function _bindDeleteFeatureDialog() {
|
|
332
|
+
const dialog = $('.modal-overlay[data-dialog="delete-feature"]');
|
|
333
|
+
if (!dialog) return;
|
|
334
|
+
|
|
335
|
+
dialog.querySelector('[data-action="df-cancel"]')?.addEventListener('click', () => {
|
|
336
|
+
dialog.classList.add('hidden');
|
|
337
|
+
deleteTargetFeature = null;
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
dialog.querySelector('[data-action="df-confirm"]')?.addEventListener('click', async () => {
|
|
341
|
+
if (!deleteTargetFeature) return;
|
|
342
|
+
await apiFetch(`/api/kanban/features/${deleteTargetFeature.id}`, { method: 'DELETE' });
|
|
343
|
+
features = features.filter(f => f.id !== deleteTargetFeature.id);
|
|
344
|
+
renderFeatures();
|
|
345
|
+
dialog.classList.add('hidden');
|
|
346
|
+
deleteTargetFeature = null;
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function _bindDeleteIssueDialog() {
|
|
351
|
+
const dialog = $('.modal-overlay[data-dialog="delete-issue"]');
|
|
352
|
+
if (!dialog) return;
|
|
353
|
+
|
|
354
|
+
dialog.querySelector('[data-action="di-cancel"]')?.addEventListener('click', () => {
|
|
355
|
+
dialog.classList.add('hidden');
|
|
356
|
+
$('.modal-overlay[data-dialog="issue"]')?.classList.remove('hidden');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
dialog.querySelector('[data-action="di-confirm"]')?.addEventListener('click', async () => {
|
|
360
|
+
const s = dialogState;
|
|
361
|
+
if (!s.issue) return;
|
|
362
|
+
dialog.classList.add('hidden');
|
|
363
|
+
await apiFetch(`/api/kanban/issues/${s.issue.id}`, { method: 'DELETE' });
|
|
364
|
+
s.onDelete(s.issue.id);
|
|
365
|
+
closeDialog();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
dialog.addEventListener('click', (e) => {
|
|
369
|
+
if (e.target === dialog) {
|
|
370
|
+
dialog.classList.add('hidden');
|
|
371
|
+
$('.modal-overlay[data-dialog="issue"]')?.classList.remove('hidden');
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── Edit Feature Dialog ──────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
function openEditFeatureDialog(feature) {
|
|
379
|
+
editTargetFeature = feature;
|
|
380
|
+
const dialog = $('.modal-overlay[data-dialog="edit-feature"]');
|
|
381
|
+
if (!dialog) return;
|
|
382
|
+
|
|
383
|
+
const nameInput = dialog.querySelector('[data-field="ef-name"]');
|
|
384
|
+
const descInput = dialog.querySelector('[data-field="ef-desc"]');
|
|
385
|
+
const colorsDiv = dialog.querySelector('[data-field="ef-colors"]');
|
|
386
|
+
|
|
387
|
+
nameInput.value = feature.name || '';
|
|
388
|
+
descInput.value = feature.description || '';
|
|
389
|
+
selectedColor = feature.color || FEATURE_COLORS[0];
|
|
390
|
+
|
|
391
|
+
colorsDiv.innerHTML = FEATURE_COLORS.map(c =>
|
|
392
|
+
`<button type="button" class="color-swatch ${c === selectedColor ? 'selected' : ''}"
|
|
393
|
+
data-color="${c}" style="background-color: ${c}" aria-label="Select color ${c}"></button>`
|
|
394
|
+
).join('');
|
|
395
|
+
|
|
396
|
+
colorsDiv.querySelectorAll('.color-swatch').forEach(btn => {
|
|
397
|
+
btn.addEventListener('click', () => {
|
|
398
|
+
selectedColor = btn.dataset.color;
|
|
399
|
+
colorsDiv.querySelectorAll('.color-swatch').forEach(b => b.classList.remove('selected'));
|
|
400
|
+
btn.classList.add('selected');
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
dialog.classList.remove('hidden');
|
|
405
|
+
nameInput.focus();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function _bindEditFeatureDialog() {
|
|
409
|
+
const dialog = $('.modal-overlay[data-dialog="edit-feature"]');
|
|
410
|
+
if (!dialog) return;
|
|
411
|
+
|
|
412
|
+
dialog.querySelector('[data-action="ef-cancel"]')?.addEventListener('click', () => {
|
|
413
|
+
dialog.classList.add('hidden');
|
|
414
|
+
editTargetFeature = null;
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
dialog.querySelector('[data-action="ef-save"]')?.addEventListener('click', async () => {
|
|
418
|
+
if (!editTargetFeature) return;
|
|
419
|
+
const name = dialog.querySelector('[data-field="ef-name"]').value.trim();
|
|
420
|
+
if (!name) return;
|
|
421
|
+
|
|
422
|
+
const saveBtn = dialog.querySelector('[data-action="ef-save"]');
|
|
423
|
+
saveBtn.disabled = true;
|
|
424
|
+
saveBtn.textContent = 'Saving...';
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
const res = await apiFetch(`/api/kanban/features/${editTargetFeature.id}`, {
|
|
428
|
+
method: 'PATCH',
|
|
429
|
+
headers: { 'Content-Type': 'application/json' },
|
|
430
|
+
body: JSON.stringify({
|
|
431
|
+
name,
|
|
432
|
+
description: dialog.querySelector('[data-field="ef-desc"]').value,
|
|
433
|
+
color: selectedColor,
|
|
434
|
+
}),
|
|
435
|
+
});
|
|
436
|
+
const updated = await res.json();
|
|
437
|
+
|
|
438
|
+
// Update in features list
|
|
439
|
+
const idx = features.findIndex(f => f.id === editTargetFeature.id);
|
|
440
|
+
if (idx !== -1) {
|
|
441
|
+
features[idx] = { ...features[idx], ...updated };
|
|
442
|
+
renderFeatures();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Update board header if viewing this feature
|
|
446
|
+
if (boardFeature && boardFeature.id === editTargetFeature.id) {
|
|
447
|
+
boardFeature = { ...boardFeature, ...updated };
|
|
448
|
+
const nameEl = $('.app-name');
|
|
449
|
+
if (nameEl) nameEl.textContent = updated.name;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
dialog.classList.add('hidden');
|
|
453
|
+
editTargetFeature = null;
|
|
454
|
+
} finally {
|
|
455
|
+
saveBtn.disabled = false;
|
|
456
|
+
saveBtn.textContent = 'Save Changes';
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
dialog.querySelector('[data-field="ef-name"]')?.addEventListener('keydown', (e) => {
|
|
461
|
+
if (e.key === 'Enter') dialog.querySelector('[data-action="ef-save"]')?.click();
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ── Modal overlays (close on click outside / Escape) ─────────────────────────
|
|
466
|
+
|
|
467
|
+
function _bindModalOverlays() {
|
|
468
|
+
$$('.modal-overlay').forEach(overlay => {
|
|
469
|
+
overlay.addEventListener('click', (e) => {
|
|
470
|
+
if (e.target === overlay) overlay.classList.add('hidden');
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
_bindNewFeatureDialog();
|
|
475
|
+
_bindDeleteFeatureDialog();
|
|
476
|
+
_bindDeleteIssueDialog();
|
|
477
|
+
_bindEditFeatureDialog();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ── Kanban Board ─────────────────────────────────────────────────────────────
|
|
481
|
+
|
|
482
|
+
async function renderBoard(featureId) {
|
|
483
|
+
stopBoardPolling();
|
|
484
|
+
searchQuery = '';
|
|
485
|
+
|
|
486
|
+
if (!_root) return;
|
|
487
|
+
_root.innerHTML = '<div class="loading">Loading...</div>';
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
const res = await apiFetch(`/api/kanban/features/${featureId}`);
|
|
491
|
+
if (!res.ok) { internalNavigate('#/'); return; }
|
|
492
|
+
boardFeature = await res.json();
|
|
493
|
+
} catch {
|
|
494
|
+
internalNavigate('#/');
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
renderBoardUI();
|
|
499
|
+
startBoardPolling();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function renderBoardUI() {
|
|
503
|
+
if (!_root || !boardFeature) return;
|
|
504
|
+
const f = boardFeature;
|
|
505
|
+
|
|
506
|
+
_root.innerHTML = `
|
|
507
|
+
<div class="sync-indicator" aria-live="polite">
|
|
508
|
+
<span class="sync-dot"></span>
|
|
509
|
+
Board updated
|
|
510
|
+
</div>
|
|
511
|
+
<header class="app-header">
|
|
512
|
+
<a href="#/" class="back-btn" title="Back to features">←</a>
|
|
513
|
+
<span class="app-name">${escapeHtml(f.name)}</span>
|
|
514
|
+
<button class="board-edit-btn" data-action="edit-board-feature" title="Edit feature"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg></button>
|
|
515
|
+
<div class="header-actions">
|
|
516
|
+
<span class="sync-badge hidden" data-sync="board-sync">
|
|
517
|
+
<span class="sync-dot"></span> Updated
|
|
518
|
+
</span>
|
|
519
|
+
</div>
|
|
520
|
+
</header>
|
|
521
|
+
<div class="board-search" role="search">
|
|
522
|
+
<div class="search-bar">
|
|
523
|
+
<input type="text" class="search-input" data-field="search-input" placeholder="Search issues... ( / )" value="${escapeHtml(searchQuery)}">
|
|
524
|
+
<button class="search-clear ${searchQuery ? '' : 'hidden'}" data-action="search-clear" aria-label="Clear search">×</button>
|
|
525
|
+
</div>
|
|
526
|
+
</div>
|
|
527
|
+
<div class="board-columns" data-region="board-columns">
|
|
528
|
+
${getFilteredColumns().map(col => renderColumn(col)).join('')}
|
|
529
|
+
</div>
|
|
530
|
+
${_getDialogHTML()}
|
|
531
|
+
`;
|
|
532
|
+
|
|
533
|
+
_bindModalOverlays();
|
|
534
|
+
$('[data-action="edit-board-feature"]')?.addEventListener('click', () => {
|
|
535
|
+
if (boardFeature) openEditFeatureDialog(boardFeature);
|
|
536
|
+
});
|
|
537
|
+
initSearch();
|
|
538
|
+
initSortable();
|
|
539
|
+
initAddIssueButtons();
|
|
540
|
+
initCardClicks();
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function getFilteredColumns() {
|
|
544
|
+
if (!boardFeature) return [];
|
|
545
|
+
if (!searchQuery.trim()) return boardFeature.columns;
|
|
546
|
+
const q = searchQuery.toLowerCase();
|
|
547
|
+
return boardFeature.columns.map(col => ({
|
|
548
|
+
...col,
|
|
549
|
+
issues: col.issues.filter(issue => {
|
|
550
|
+
let labels = [];
|
|
551
|
+
try { labels = JSON.parse(issue.labels || '[]'); } catch { labels = []; }
|
|
552
|
+
return (
|
|
553
|
+
issue.title.toLowerCase().includes(q) ||
|
|
554
|
+
(issue.description || '').toLowerCase().includes(q) ||
|
|
555
|
+
labels.some(l => l.toLowerCase().includes(q))
|
|
556
|
+
);
|
|
557
|
+
}),
|
|
558
|
+
}));
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function renderColumn(col) {
|
|
562
|
+
const INTAKE = ['Backlog', 'Todo'];
|
|
563
|
+
const canAdd = INTAKE.includes(col.name);
|
|
564
|
+
return `
|
|
565
|
+
<div class="kanban-column" data-column-id="${col.id}">
|
|
566
|
+
<div class="column-header">
|
|
567
|
+
<span class="column-dot" style="background-color: ${col.color}"></span>
|
|
568
|
+
<span class="column-name">${escapeHtml(col.name)}</span>
|
|
569
|
+
<span class="column-count">${col.issues.length}</span>
|
|
570
|
+
</div>
|
|
571
|
+
<div class="column-drop-zone" data-column-id="${col.id}">
|
|
572
|
+
${col.issues.map(issue => renderCard(issue)).join('')}
|
|
573
|
+
${canAdd ? `<button class="add-issue-btn" data-column-id="${col.id}">+ Add issue</button>` : ''}
|
|
574
|
+
</div>
|
|
575
|
+
</div>
|
|
576
|
+
`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function renderCard(issue) {
|
|
580
|
+
let labels = [];
|
|
581
|
+
try { labels = JSON.parse(issue.labels || '[]'); } catch { labels = []; }
|
|
582
|
+
const isOverdue = issue.dueDate && issue.dueDate.split('T')[0] < new Date().toISOString().split('T')[0];
|
|
583
|
+
const type = issue.type || 'TASK';
|
|
584
|
+
const priorityClass = `badge-${issue.priority.toLowerCase()}`;
|
|
585
|
+
const typeClass = type === 'BUG' ? 'badge-bug' : 'badge-secondary';
|
|
586
|
+
|
|
587
|
+
return `
|
|
588
|
+
<div class="issue-card" data-issue-id="${issue.id}" data-column-id="${issue.columnId}">
|
|
589
|
+
<p class="issue-card-title">${escapeHtml(issue.title)}</p>
|
|
590
|
+
<div class="issue-card-badges">
|
|
591
|
+
<span class="badge ${priorityClass}">${PRIORITY_LABELS[issue.priority]}</span>
|
|
592
|
+
<span class="badge ${typeClass}">${type === 'BUG' ? 'Bug' : 'Task'}</span>
|
|
593
|
+
${labels.map(l => `<span class="badge badge-secondary">${escapeHtml(l)}</span>`).join('')}
|
|
594
|
+
${issue.dueDate ? `<span class="badge ${isOverdue ? 'badge-due-overdue' : 'badge-due'}">${formatDate(issue.dueDate)}</span>` : ''}
|
|
595
|
+
${issue.reviewCount > 0 ? `<span class="badge badge-review" title="Has review feedback">Review ${issue.reviewCount}</span>` : ''}
|
|
596
|
+
${issue.workLogCount > 0 ? `<span class="badge badge-worklog" title="Has work log">Log ${issue.workLogCount}</span>` : ''}
|
|
597
|
+
</div>
|
|
598
|
+
</div>
|
|
599
|
+
`;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function formatDate(dateStr) {
|
|
603
|
+
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ── Search ───────────────────────────────────────────────────────────────────
|
|
607
|
+
|
|
608
|
+
function initSearch() {
|
|
609
|
+
const input = $('[data-field="search-input"]');
|
|
610
|
+
const clear = $('[data-action="search-clear"]');
|
|
611
|
+
if (!input) return;
|
|
612
|
+
|
|
613
|
+
input.addEventListener('input', () => {
|
|
614
|
+
searchQuery = input.value;
|
|
615
|
+
clear?.classList.toggle('hidden', !searchQuery);
|
|
616
|
+
rerenderColumns();
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
clear?.addEventListener('click', () => {
|
|
620
|
+
searchQuery = '';
|
|
621
|
+
input.value = '';
|
|
622
|
+
clear.classList.add('hidden');
|
|
623
|
+
input.focus();
|
|
624
|
+
rerenderColumns();
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function _handleSearchKeydown(e) {
|
|
629
|
+
const input = $('[data-field="search-input"]');
|
|
630
|
+
if (!input) return;
|
|
631
|
+
|
|
632
|
+
const action = typeof KeymapRegistry !== 'undefined' ? KeymapRegistry.resolve(e) : null;
|
|
633
|
+
const isFocusSearch = action === 'kanban:focus-search' || (!action && e.key === '/');
|
|
634
|
+
const isClearSearch = action === 'kanban:clear-search' || (!action && e.key === 'Escape');
|
|
635
|
+
|
|
636
|
+
if (isFocusSearch && !isDialogOpen &&
|
|
637
|
+
document.activeElement !== input &&
|
|
638
|
+
!(document.activeElement instanceof HTMLInputElement) &&
|
|
639
|
+
!(document.activeElement instanceof HTMLTextAreaElement)) {
|
|
640
|
+
e.preventDefault();
|
|
641
|
+
input.focus();
|
|
642
|
+
}
|
|
643
|
+
if (isClearSearch && document.activeElement === input) {
|
|
644
|
+
searchQuery = '';
|
|
645
|
+
input.value = '';
|
|
646
|
+
$('[data-action="search-clear"]')?.classList.add('hidden');
|
|
647
|
+
input.blur();
|
|
648
|
+
rerenderColumns();
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function rerenderColumns() {
|
|
653
|
+
const container = $('[data-region="board-columns"]');
|
|
654
|
+
if (!container) return;
|
|
655
|
+
container.innerHTML = getFilteredColumns().map(col => renderColumn(col)).join('');
|
|
656
|
+
initSortable();
|
|
657
|
+
initAddIssueButtons();
|
|
658
|
+
initCardClicks();
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ── SortableJS drag-and-drop ─────────────────────────────────────────────────
|
|
662
|
+
|
|
663
|
+
function initSortable() {
|
|
664
|
+
sortableInstances.forEach(s => s.destroy());
|
|
665
|
+
sortableInstances = [];
|
|
666
|
+
|
|
667
|
+
$$('.column-drop-zone').forEach(zone => {
|
|
668
|
+
const sortable = new Sortable(zone, {
|
|
669
|
+
group: 'kanban',
|
|
670
|
+
animation: 150,
|
|
671
|
+
ghostClass: 'sortable-ghost',
|
|
672
|
+
chosenClass: 'sortable-chosen',
|
|
673
|
+
dragClass: 'sortable-drag',
|
|
674
|
+
filter: '.add-issue-btn',
|
|
675
|
+
draggable: '.issue-card',
|
|
676
|
+
onStart: () => { isDragging = true; },
|
|
677
|
+
onEnd: async (evt) => {
|
|
678
|
+
isDragging = false;
|
|
679
|
+
const issueId = evt.item.dataset.issueId;
|
|
680
|
+
const newColumnId = evt.to.dataset.columnId;
|
|
681
|
+
const newOrder = evt.newIndex;
|
|
682
|
+
|
|
683
|
+
updateLocalState(issueId, newColumnId, newOrder);
|
|
684
|
+
|
|
685
|
+
try {
|
|
686
|
+
const res = await apiFetch('/api/kanban/issues/reorder', {
|
|
687
|
+
method: 'POST',
|
|
688
|
+
headers: { 'Content-Type': 'application/json' },
|
|
689
|
+
body: JSON.stringify({ issueId, newColumnId, newOrder }),
|
|
690
|
+
});
|
|
691
|
+
if (!res.ok) throw new Error('reorder failed');
|
|
692
|
+
} catch {
|
|
693
|
+
try {
|
|
694
|
+
const res = await apiFetch(`/api/kanban/features/${boardFeature.id}`);
|
|
695
|
+
if (res.ok) {
|
|
696
|
+
boardFeature = await res.json();
|
|
697
|
+
rerenderColumns();
|
|
698
|
+
}
|
|
699
|
+
} catch { /* ignore */ }
|
|
700
|
+
}
|
|
701
|
+
},
|
|
702
|
+
});
|
|
703
|
+
sortableInstances.push(sortable);
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function updateLocalState(issueId, newColumnId, newOrder) {
|
|
708
|
+
if (!boardFeature) return;
|
|
709
|
+
let movedIssue = null;
|
|
710
|
+
|
|
711
|
+
for (const col of boardFeature.columns) {
|
|
712
|
+
const idx = col.issues.findIndex(i => i.id === issueId);
|
|
713
|
+
if (idx >= 0) {
|
|
714
|
+
movedIssue = col.issues.splice(idx, 1)[0];
|
|
715
|
+
break;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (!movedIssue) return;
|
|
720
|
+
movedIssue.columnId = newColumnId;
|
|
721
|
+
|
|
722
|
+
const targetCol = boardFeature.columns.find(c => c.id === newColumnId);
|
|
723
|
+
if (targetCol) {
|
|
724
|
+
targetCol.issues.splice(newOrder, 0, movedIssue);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ── Add issue buttons ────────────────────────────────────────────────────────
|
|
729
|
+
|
|
730
|
+
function initAddIssueButtons() {
|
|
731
|
+
$$('.add-issue-btn').forEach(btn => {
|
|
732
|
+
btn.addEventListener('click', () => {
|
|
733
|
+
const columnId = btn.dataset.columnId;
|
|
734
|
+
isDialogOpen = true;
|
|
735
|
+
openIssueDialog({
|
|
736
|
+
issue: null,
|
|
737
|
+
columns: boardFeature.columns,
|
|
738
|
+
featureId: boardFeature.id,
|
|
739
|
+
defaultColumnId: columnId,
|
|
740
|
+
onSave: handleIssueSaved,
|
|
741
|
+
onDelete: handleIssueDeleted,
|
|
742
|
+
onClose: () => { isDialogOpen = false; },
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// ── Card clicks (edit) ───────────────────────────────────────────────────────
|
|
749
|
+
|
|
750
|
+
function initCardClicks() {
|
|
751
|
+
$$('.issue-card').forEach(card => {
|
|
752
|
+
card.addEventListener('click', () => {
|
|
753
|
+
const issueId = card.dataset.issueId;
|
|
754
|
+
let issue = null;
|
|
755
|
+
for (const col of boardFeature.columns) {
|
|
756
|
+
issue = col.issues.find(i => i.id === issueId);
|
|
757
|
+
if (issue) break;
|
|
758
|
+
}
|
|
759
|
+
if (!issue) return;
|
|
760
|
+
|
|
761
|
+
isDialogOpen = true;
|
|
762
|
+
openIssueDialog({
|
|
763
|
+
issue,
|
|
764
|
+
columns: boardFeature.columns,
|
|
765
|
+
featureId: boardFeature.id,
|
|
766
|
+
defaultColumnId: null,
|
|
767
|
+
onSave: handleIssueSaved,
|
|
768
|
+
onDelete: handleIssueDeleted,
|
|
769
|
+
onClose: () => { isDialogOpen = false; },
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ── Issue save/delete callbacks ──────────────────────────────────────────────
|
|
776
|
+
|
|
777
|
+
function handleIssueSaved(savedIssue) {
|
|
778
|
+
if (!boardFeature) return;
|
|
779
|
+
for (const col of boardFeature.columns) {
|
|
780
|
+
col.issues = col.issues.filter(i => i.id !== savedIssue.id);
|
|
781
|
+
}
|
|
782
|
+
const targetCol = boardFeature.columns.find(c => c.id === savedIssue.columnId);
|
|
783
|
+
if (targetCol) {
|
|
784
|
+
targetCol.issues.push(savedIssue);
|
|
785
|
+
targetCol.issues.sort((a, b) => a.order - b.order);
|
|
786
|
+
}
|
|
787
|
+
rerenderColumns();
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function handleIssueDeleted(issueId) {
|
|
791
|
+
if (!boardFeature) return;
|
|
792
|
+
for (const col of boardFeature.columns) {
|
|
793
|
+
col.issues = col.issues.filter(i => i.id !== issueId);
|
|
794
|
+
}
|
|
795
|
+
rerenderColumns();
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ── Board polling ────────────────────────────────────────────────────────────
|
|
799
|
+
|
|
800
|
+
function boardSignature() {
|
|
801
|
+
if (!boardFeature) return '';
|
|
802
|
+
return boardFeature.columns.flatMap(c =>
|
|
803
|
+
c.issues.map(i => `${i.id}:${i.columnId}:${i.order}:${i.updatedAt}`)
|
|
804
|
+
).join('|');
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function stopBoardPolling() {
|
|
808
|
+
if (boardPollTimer) { clearInterval(boardPollTimer); boardPollTimer = null; }
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function startBoardPolling() {
|
|
812
|
+
boardPollTimer = setInterval(async () => {
|
|
813
|
+
if (isDragging || isDialogOpen) return;
|
|
814
|
+
try {
|
|
815
|
+
const res = await apiFetch(`/api/kanban/features/${boardFeature.id}`);
|
|
816
|
+
if (!res.ok) return;
|
|
817
|
+
const fresh = await res.json();
|
|
818
|
+
const freshSig = fresh.columns.flatMap(c =>
|
|
819
|
+
c.issues.map(i => `${i.id}:${i.columnId}:${i.order}:${i.updatedAt}`)
|
|
820
|
+
).join('|');
|
|
821
|
+
|
|
822
|
+
if (freshSig === boardSignature()) return;
|
|
823
|
+
boardFeature = fresh;
|
|
824
|
+
rerenderColumns();
|
|
825
|
+
showSyncFlash('board-sync');
|
|
826
|
+
} catch { /* ignore */ }
|
|
827
|
+
}, 5000);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// ── Issue Dialog (create/edit) ───────────────────────────────────────────────
|
|
831
|
+
|
|
832
|
+
function revokePendingObjectURLs() {
|
|
833
|
+
pendingObjectURLs.forEach(url => URL.revokeObjectURL(url));
|
|
834
|
+
pendingObjectURLs = [];
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function openIssueDialog({ issue, columns, featureId, defaultColumnId, onSave, onDelete, onClose }) {
|
|
838
|
+
revokePendingObjectURLs();
|
|
839
|
+
|
|
840
|
+
dialogState = {
|
|
841
|
+
issue,
|
|
842
|
+
columns,
|
|
843
|
+
featureId,
|
|
844
|
+
defaultColumnId,
|
|
845
|
+
onSave,
|
|
846
|
+
onDelete,
|
|
847
|
+
onClose,
|
|
848
|
+
title: issue?.title ?? '',
|
|
849
|
+
description: issue?.description ?? '',
|
|
850
|
+
priority: issue?.priority ?? 'MEDIUM',
|
|
851
|
+
columnId: issue?.columnId ?? defaultColumnId ?? columns[0]?.id ?? '',
|
|
852
|
+
type: issue?.type ?? 'TASK',
|
|
853
|
+
dueDate: issue?.dueDate ? new Date(issue.dueDate).toISOString().split('T')[0] : '',
|
|
854
|
+
labels: JSON.parse(issue?.labels ?? '[]'),
|
|
855
|
+
attachments: [],
|
|
856
|
+
pendingFiles: [],
|
|
857
|
+
previewMode: !!issue,
|
|
858
|
+
saving: false,
|
|
859
|
+
uploading: false,
|
|
860
|
+
workLog: [],
|
|
861
|
+
reviewHistory: [],
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
renderDialog();
|
|
865
|
+
|
|
866
|
+
if (issue?.id) {
|
|
867
|
+
apiFetch(`/api/kanban/issues/${issue.id}`)
|
|
868
|
+
.then(r => r.json())
|
|
869
|
+
.then(data => {
|
|
870
|
+
if (data.attachments) {
|
|
871
|
+
dialogState.attachments = data.attachments;
|
|
872
|
+
renderAttachments();
|
|
873
|
+
}
|
|
874
|
+
if (data.workLog) dialogState.workLog = data.workLog;
|
|
875
|
+
if (data.reviewHistory) dialogState.reviewHistory = data.reviewHistory;
|
|
876
|
+
renderVersionedEntries();
|
|
877
|
+
})
|
|
878
|
+
.catch(() => {});
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const overlay = $('.modal-overlay[data-dialog="issue"]');
|
|
882
|
+
overlay?.classList.remove('hidden');
|
|
883
|
+
$('[data-field="dlg-title"]')?.focus();
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function renderDialog() {
|
|
887
|
+
const s = dialogState;
|
|
888
|
+
if (!s) return;
|
|
889
|
+
const isEdit = !!s.issue;
|
|
890
|
+
const overlay = $('.modal-overlay[data-dialog="issue"]');
|
|
891
|
+
if (!overlay) return;
|
|
892
|
+
const modal = overlay.querySelector('.modal');
|
|
893
|
+
|
|
894
|
+
modal.innerHTML = `
|
|
895
|
+
<div class="modal-header">
|
|
896
|
+
<h2>${isEdit ? 'Edit Item' : 'New Item'}</h2>
|
|
897
|
+
<p class="modal-desc">${isEdit ? 'Update task or bug details.' : 'Add a new task or bug to the board.'}</p>
|
|
898
|
+
</div>
|
|
899
|
+
<div class="modal-body">
|
|
900
|
+
<div class="form-group">
|
|
901
|
+
<label>Title</label>
|
|
902
|
+
<input type="text" data-field="dlg-title" value="${escapeAttr(s.title)}" placeholder="Issue title...">
|
|
903
|
+
</div>
|
|
904
|
+
|
|
905
|
+
<div class="form-group">
|
|
906
|
+
<div class="description-header">
|
|
907
|
+
<label>Description</label>
|
|
908
|
+
<div class="description-tabs">
|
|
909
|
+
<button type="button" class="description-tab ${!s.previewMode ? 'active' : ''}" data-mode="write">Write</button>
|
|
910
|
+
<button type="button" class="description-tab ${s.previewMode ? 'active' : ''}" data-mode="preview">Preview</button>
|
|
911
|
+
</div>
|
|
912
|
+
</div>
|
|
913
|
+
<div data-region="dlg-desc-write" class="${s.previewMode ? 'hidden' : ''}">
|
|
914
|
+
<textarea data-field="dlg-desc" rows="8" placeholder="Add a description... (Markdown supported)">${escapeHtml(s.description)}</textarea>
|
|
915
|
+
</div>
|
|
916
|
+
<div data-region="dlg-desc-preview" class="markdown-preview ${!s.previewMode ? 'hidden' : ''}">
|
|
917
|
+
${s.description ? marked.parse(normalizeEscapes(s.description)) : '<span class="text-muted">Nothing to preview</span>'}
|
|
918
|
+
</div>
|
|
919
|
+
</div>
|
|
920
|
+
|
|
921
|
+
${isEdit ? `
|
|
922
|
+
<div class="form-group">
|
|
923
|
+
<div class="versioned-columns">
|
|
924
|
+
<div class="versioned-tab-content">
|
|
925
|
+
<div class="versioned-column-header">Review</div>
|
|
926
|
+
<div data-region="dlg-review-entries" class="versioned-entries"></div>
|
|
927
|
+
<textarea data-field="dlg-new-review" rows="3" placeholder="Add review feedback..."></textarea>
|
|
928
|
+
<button type="button" class="btn btn-secondary" data-action="dlg-add-review">+ Add Feedback</button>
|
|
929
|
+
</div>
|
|
930
|
+
<div class="versioned-tab-content">
|
|
931
|
+
<div class="versioned-column-header">Work Log</div>
|
|
932
|
+
<div data-region="dlg-worklog-entries" class="versioned-entries"></div>
|
|
933
|
+
<textarea data-field="dlg-new-worklog" rows="3" placeholder="Add work log entry..."></textarea>
|
|
934
|
+
<button type="button" class="btn btn-secondary" data-action="dlg-add-worklog">+ Add Entry</button>
|
|
935
|
+
</div>
|
|
936
|
+
</div>
|
|
937
|
+
</div>
|
|
938
|
+
` : ''}
|
|
939
|
+
|
|
940
|
+
<div class="form-group">
|
|
941
|
+
<label>Attachments</label>
|
|
942
|
+
<div data-region="dlg-attachments" class="attachment-gallery"></div>
|
|
943
|
+
<div data-region="dlg-drop-zone" class="attachment-drop-zone">
|
|
944
|
+
<input type="file" data-field="dlg-file-input" accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml" multiple class="hidden">
|
|
945
|
+
<span>${s.uploading ? 'Uploading...' : 'Drop images here or click to upload'}</span>
|
|
946
|
+
</div>
|
|
947
|
+
</div>
|
|
948
|
+
|
|
949
|
+
<div class="field-row">
|
|
950
|
+
<div class="form-group">
|
|
951
|
+
<label>Priority</label>
|
|
952
|
+
<select data-field="dlg-priority">
|
|
953
|
+
${Object.entries(PRIORITY_LABELS).map(([v, l]) =>
|
|
954
|
+
`<option value="${v}" ${s.priority === v ? 'selected' : ''}>${l}</option>`
|
|
955
|
+
).join('')}
|
|
956
|
+
</select>
|
|
957
|
+
</div>
|
|
958
|
+
<div class="form-group">
|
|
959
|
+
<label>Column</label>
|
|
960
|
+
<select data-field="dlg-column">
|
|
961
|
+
${s.columns.map(c =>
|
|
962
|
+
`<option value="${c.id}" ${s.columnId === c.id ? 'selected' : ''}>${escapeHtml(c.name)}</option>`
|
|
963
|
+
).join('')}
|
|
964
|
+
</select>
|
|
965
|
+
</div>
|
|
966
|
+
<div class="form-group">
|
|
967
|
+
<label>Type</label>
|
|
968
|
+
<select data-field="dlg-type">
|
|
969
|
+
<option value="TASK" ${s.type === 'TASK' ? 'selected' : ''}>Task</option>
|
|
970
|
+
<option value="BUG" ${s.type === 'BUG' ? 'selected' : ''}>Bug</option>
|
|
971
|
+
</select>
|
|
972
|
+
</div>
|
|
973
|
+
</div>
|
|
974
|
+
|
|
975
|
+
<div class="form-group">
|
|
976
|
+
<label>Due Date</label>
|
|
977
|
+
<input type="date" data-field="dlg-due" value="${s.dueDate}" class="date-input">
|
|
978
|
+
</div>
|
|
979
|
+
|
|
980
|
+
<div class="form-group">
|
|
981
|
+
<label>Labels</label>
|
|
982
|
+
<div data-region="dlg-labels" class="labels-list">
|
|
983
|
+
${s.labels.map((l, i) => `
|
|
984
|
+
<span class="label-badge">
|
|
985
|
+
${escapeHtml(l)}
|
|
986
|
+
<button type="button" class="label-remove" data-index="${i}" aria-label="Remove label">×</button>
|
|
987
|
+
</span>
|
|
988
|
+
`).join('')}
|
|
989
|
+
</div>
|
|
990
|
+
<div class="label-input-row">
|
|
991
|
+
<input type="text" data-field="dlg-new-label" placeholder="Add label...">
|
|
992
|
+
<button type="button" class="btn btn-secondary" data-action="dlg-add-label">+</button>
|
|
993
|
+
</div>
|
|
994
|
+
</div>
|
|
995
|
+
|
|
996
|
+
</div>
|
|
997
|
+
<div class="modal-footer">
|
|
998
|
+
${isEdit ? `<button class="btn btn-danger" data-action="dlg-delete">Delete</button>` : ''}
|
|
999
|
+
<div class="modal-footer-right">
|
|
1000
|
+
<button class="btn btn-secondary" data-action="dlg-cancel">Cancel</button>
|
|
1001
|
+
<button class="btn btn-primary" data-action="dlg-save" ${!s.title.trim() ? 'disabled' : ''}>
|
|
1002
|
+
${s.saving ? 'Saving...' : (isEdit ? 'Save' : 'Create')}
|
|
1003
|
+
</button>
|
|
1004
|
+
</div>
|
|
1005
|
+
</div>
|
|
1006
|
+
`;
|
|
1007
|
+
|
|
1008
|
+
renderAttachments();
|
|
1009
|
+
bindDialogEvents();
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function renderAttachments() {
|
|
1013
|
+
const container = $('[data-region="dlg-attachments"]');
|
|
1014
|
+
if (!container || !dialogState) return;
|
|
1015
|
+
|
|
1016
|
+
const s = dialogState;
|
|
1017
|
+
let html = '';
|
|
1018
|
+
|
|
1019
|
+
s.attachments.forEach(att => {
|
|
1020
|
+
html += `
|
|
1021
|
+
<div class="attachment-thumb">
|
|
1022
|
+
<img src="/api/kanban/attachments/${att.id}" alt="${escapeAttr(att.filename)}" class="attachment-img" data-url="/api/kanban/attachments/${att.id}">
|
|
1023
|
+
<button type="button" class="attachment-thumb-delete" data-att-id="${att.id}" aria-label="Remove attachment">×</button>
|
|
1024
|
+
</div>
|
|
1025
|
+
`;
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
revokePendingObjectURLs();
|
|
1029
|
+
|
|
1030
|
+
s.pendingFiles.forEach((file, i) => {
|
|
1031
|
+
const objUrl = URL.createObjectURL(file);
|
|
1032
|
+
pendingObjectURLs.push(objUrl);
|
|
1033
|
+
html += `
|
|
1034
|
+
<div class="attachment-thumb attachment-pending">
|
|
1035
|
+
<img src="${objUrl}" alt="${escapeAttr(file.name)}" class="attachment-img">
|
|
1036
|
+
<button type="button" class="attachment-thumb-delete" data-pending-index="${i}" aria-label="Remove pending attachment">×</button>
|
|
1037
|
+
</div>
|
|
1038
|
+
`;
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
container.innerHTML = html;
|
|
1042
|
+
|
|
1043
|
+
// Image click -> preview
|
|
1044
|
+
container.querySelectorAll('.attachment-img').forEach(img => {
|
|
1045
|
+
img.addEventListener('click', () => {
|
|
1046
|
+
const url = img.dataset.url || img.src;
|
|
1047
|
+
const preview = $('.image-preview-overlay');
|
|
1048
|
+
if (!preview) return;
|
|
1049
|
+
preview.querySelector('img').src = url;
|
|
1050
|
+
preview.classList.remove('hidden');
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
// Delete server attachment
|
|
1055
|
+
container.querySelectorAll('[data-att-id]').forEach(btn => {
|
|
1056
|
+
btn.addEventListener('click', async (e) => {
|
|
1057
|
+
e.stopPropagation();
|
|
1058
|
+
const id = btn.dataset.attId;
|
|
1059
|
+
await apiFetch(`/api/kanban/attachments/${id}`, { method: 'DELETE' });
|
|
1060
|
+
s.attachments = s.attachments.filter(a => a.id !== id);
|
|
1061
|
+
renderAttachments();
|
|
1062
|
+
});
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
// Delete pending file
|
|
1066
|
+
container.querySelectorAll('[data-pending-index]').forEach(btn => {
|
|
1067
|
+
btn.addEventListener('click', (e) => {
|
|
1068
|
+
e.stopPropagation();
|
|
1069
|
+
const idx = parseInt(btn.dataset.pendingIndex);
|
|
1070
|
+
s.pendingFiles.splice(idx, 1);
|
|
1071
|
+
renderAttachments();
|
|
1072
|
+
});
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function bindDialogEvents() {
|
|
1077
|
+
const s = dialogState;
|
|
1078
|
+
if (!s) return;
|
|
1079
|
+
|
|
1080
|
+
// Title input
|
|
1081
|
+
const titleInput = $('[data-field="dlg-title"]');
|
|
1082
|
+
titleInput?.addEventListener('input', () => {
|
|
1083
|
+
s.title = titleInput.value;
|
|
1084
|
+
const saveBtn = $('[data-action="dlg-save"]');
|
|
1085
|
+
if (saveBtn) saveBtn.disabled = !s.title.trim();
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
// Description write/preview tabs
|
|
1089
|
+
$$('.description-tab').forEach(tab => {
|
|
1090
|
+
tab.addEventListener('click', () => {
|
|
1091
|
+
const mode = tab.dataset.mode;
|
|
1092
|
+
s.previewMode = mode === 'preview';
|
|
1093
|
+
$$('.description-tab').forEach(t => t.classList.remove('active'));
|
|
1094
|
+
tab.classList.add('active');
|
|
1095
|
+
|
|
1096
|
+
const writeDiv = $('[data-region="dlg-desc-write"]');
|
|
1097
|
+
const previewDiv = $('[data-region="dlg-desc-preview"]');
|
|
1098
|
+
|
|
1099
|
+
if (s.previewMode) {
|
|
1100
|
+
s.description = $('[data-field="dlg-desc"]')?.value ?? s.description;
|
|
1101
|
+
writeDiv?.classList.add('hidden');
|
|
1102
|
+
previewDiv?.classList.remove('hidden');
|
|
1103
|
+
if (previewDiv) {
|
|
1104
|
+
previewDiv.innerHTML = s.description ? marked.parse(normalizeEscapes(s.description)) : '<span class="text-muted">Nothing to preview</span>';
|
|
1105
|
+
}
|
|
1106
|
+
} else {
|
|
1107
|
+
writeDiv?.classList.remove('hidden');
|
|
1108
|
+
previewDiv?.classList.add('hidden');
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
// Description textarea
|
|
1114
|
+
$('[data-field="dlg-desc"]')?.addEventListener('input', (e) => {
|
|
1115
|
+
s.description = e.target.value;
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
// Add review feedback
|
|
1119
|
+
$('[data-action="dlg-add-review"]')?.addEventListener('click', async () => {
|
|
1120
|
+
const textarea = $('[data-field="dlg-new-review"]');
|
|
1121
|
+
const content = textarea?.value.trim();
|
|
1122
|
+
if (!content || !s.issue?.id) return;
|
|
1123
|
+
const btn = $('[data-action="dlg-add-review"]');
|
|
1124
|
+
btn.disabled = true;
|
|
1125
|
+
btn.textContent = 'Adding...';
|
|
1126
|
+
try {
|
|
1127
|
+
const res = await apiFetch(`/api/kanban/issues/${s.issue.id}/review`, {
|
|
1128
|
+
method: 'POST',
|
|
1129
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1130
|
+
body: JSON.stringify({ content }),
|
|
1131
|
+
});
|
|
1132
|
+
if (!res.ok) throw new Error(`Error: ${res.status}`);
|
|
1133
|
+
const entry = await res.json();
|
|
1134
|
+
s.reviewHistory.push(entry);
|
|
1135
|
+
textarea.value = '';
|
|
1136
|
+
renderVersionedEntries();
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
showToast('Failed to add review feedback: ' + err.message);
|
|
1139
|
+
} finally {
|
|
1140
|
+
btn.disabled = false;
|
|
1141
|
+
btn.textContent = '+ Add Feedback';
|
|
1142
|
+
}
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
// Add work log entry
|
|
1146
|
+
$('[data-action="dlg-add-worklog"]')?.addEventListener('click', async () => {
|
|
1147
|
+
const textarea = $('[data-field="dlg-new-worklog"]');
|
|
1148
|
+
const content = textarea?.value.trim();
|
|
1149
|
+
if (!content || !s.issue?.id) return;
|
|
1150
|
+
const btn = $('[data-action="dlg-add-worklog"]');
|
|
1151
|
+
btn.disabled = true;
|
|
1152
|
+
btn.textContent = 'Adding...';
|
|
1153
|
+
try {
|
|
1154
|
+
const res = await apiFetch(`/api/kanban/issues/${s.issue.id}/work-log`, {
|
|
1155
|
+
method: 'POST',
|
|
1156
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1157
|
+
body: JSON.stringify({ content }),
|
|
1158
|
+
});
|
|
1159
|
+
if (!res.ok) throw new Error(`Error: ${res.status}`);
|
|
1160
|
+
const entry = await res.json();
|
|
1161
|
+
s.workLog.push(entry);
|
|
1162
|
+
textarea.value = '';
|
|
1163
|
+
renderVersionedEntries();
|
|
1164
|
+
} catch (err) {
|
|
1165
|
+
showToast('Failed to add work log entry: ' + err.message);
|
|
1166
|
+
} finally {
|
|
1167
|
+
btn.disabled = false;
|
|
1168
|
+
btn.textContent = '+ Add Entry';
|
|
1169
|
+
}
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
renderVersionedEntries();
|
|
1173
|
+
|
|
1174
|
+
// Priority, Column, Type selects
|
|
1175
|
+
$('[data-field="dlg-priority"]')?.addEventListener('change', (e) => { s.priority = e.target.value; });
|
|
1176
|
+
$('[data-field="dlg-column"]')?.addEventListener('change', (e) => { s.columnId = e.target.value; });
|
|
1177
|
+
$('[data-field="dlg-type"]')?.addEventListener('change', (e) => { s.type = e.target.value; });
|
|
1178
|
+
|
|
1179
|
+
// Due date
|
|
1180
|
+
$('[data-field="dlg-due"]')?.addEventListener('change', (e) => { s.dueDate = e.target.value; });
|
|
1181
|
+
|
|
1182
|
+
// Labels
|
|
1183
|
+
$('[data-action="dlg-add-label"]')?.addEventListener('click', addLabel);
|
|
1184
|
+
$('[data-field="dlg-new-label"]')?.addEventListener('keydown', (e) => {
|
|
1185
|
+
if (e.key === 'Enter') addLabel();
|
|
1186
|
+
});
|
|
1187
|
+
$$('.label-remove').forEach(btn => {
|
|
1188
|
+
btn.addEventListener('click', () => {
|
|
1189
|
+
s.labels.splice(parseInt(btn.dataset.index), 1);
|
|
1190
|
+
renderLabels();
|
|
1191
|
+
});
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
// File upload
|
|
1195
|
+
const dropZone = $('[data-region="dlg-drop-zone"]');
|
|
1196
|
+
const fileInput = $('[data-field="dlg-file-input"]');
|
|
1197
|
+
|
|
1198
|
+
dropZone?.addEventListener('click', () => fileInput?.click());
|
|
1199
|
+
dropZone?.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('drag-over'); });
|
|
1200
|
+
dropZone?.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
|
|
1201
|
+
dropZone?.addEventListener('drop', (e) => {
|
|
1202
|
+
e.preventDefault();
|
|
1203
|
+
dropZone.classList.remove('drag-over');
|
|
1204
|
+
handleFiles(e.dataTransfer.files);
|
|
1205
|
+
});
|
|
1206
|
+
fileInput?.addEventListener('change', (e) => {
|
|
1207
|
+
handleFiles(e.target.files);
|
|
1208
|
+
e.target.value = '';
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
// Save
|
|
1212
|
+
$('[data-action="dlg-save"]')?.addEventListener('click', handleSave);
|
|
1213
|
+
|
|
1214
|
+
// Cancel
|
|
1215
|
+
$('[data-action="dlg-cancel"]')?.addEventListener('click', closeDialog);
|
|
1216
|
+
|
|
1217
|
+
// Delete — show styled confirmation dialog
|
|
1218
|
+
$('[data-action="dlg-delete"]')?.addEventListener('click', () => {
|
|
1219
|
+
if (!s.issue) return;
|
|
1220
|
+
const dialog = $('.modal-overlay[data-dialog="delete-issue"]');
|
|
1221
|
+
if (!dialog) return;
|
|
1222
|
+
const msg = dialog.querySelector('[data-field="delete-issue-msg"]');
|
|
1223
|
+
if (msg) msg.innerHTML = `Are you sure you want to delete <strong>${escapeHtml(s.issue.title)}</strong>? This action cannot be undone.`;
|
|
1224
|
+
$('.modal-overlay[data-dialog="issue"]')?.classList.add('hidden');
|
|
1225
|
+
dialog.classList.remove('hidden');
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
// Close on overlay click
|
|
1229
|
+
const overlay = $('.modal-overlay[data-dialog="issue"]');
|
|
1230
|
+
overlay?.addEventListener('click', (e) => {
|
|
1231
|
+
if (e.target === overlay) closeDialog();
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
// Image preview close
|
|
1235
|
+
$('.image-preview-close')?.addEventListener('click', () => {
|
|
1236
|
+
$('.image-preview-overlay')?.classList.add('hidden');
|
|
1237
|
+
});
|
|
1238
|
+
$('.image-preview-overlay')?.addEventListener('click', (e) => {
|
|
1239
|
+
if (e.target.classList.contains('image-preview-overlay')) {
|
|
1240
|
+
e.target.classList.add('hidden');
|
|
1241
|
+
}
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function addLabel() {
|
|
1246
|
+
const input = $('[data-field="dlg-new-label"]');
|
|
1247
|
+
const trimmed = input?.value.trim();
|
|
1248
|
+
if (trimmed && !dialogState.labels.includes(trimmed)) {
|
|
1249
|
+
dialogState.labels.push(trimmed);
|
|
1250
|
+
renderLabels();
|
|
1251
|
+
}
|
|
1252
|
+
if (input) input.value = '';
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
function renderLabels() {
|
|
1256
|
+
const container = $('[data-region="dlg-labels"]');
|
|
1257
|
+
if (!container) return;
|
|
1258
|
+
container.innerHTML = dialogState.labels.map((l, i) => `
|
|
1259
|
+
<span class="label-badge">
|
|
1260
|
+
${escapeHtml(l)}
|
|
1261
|
+
<button type="button" class="label-remove" data-index="${i}" aria-label="Remove label">×</button>
|
|
1262
|
+
</span>
|
|
1263
|
+
`).join('');
|
|
1264
|
+
container.querySelectorAll('.label-remove').forEach(btn => {
|
|
1265
|
+
btn.addEventListener('click', () => {
|
|
1266
|
+
dialogState.labels.splice(parseInt(btn.dataset.index), 1);
|
|
1267
|
+
renderLabels();
|
|
1268
|
+
});
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
async function handleFiles(fileList) {
|
|
1273
|
+
if (!fileList || fileList.length === 0) return;
|
|
1274
|
+
const files = Array.from(fileList);
|
|
1275
|
+
const s = dialogState;
|
|
1276
|
+
|
|
1277
|
+
if (s.issue?.id) {
|
|
1278
|
+
s.uploading = true;
|
|
1279
|
+
for (const f of files) {
|
|
1280
|
+
const form = new FormData();
|
|
1281
|
+
form.append('file', f);
|
|
1282
|
+
form.append('issueId', s.issue.id);
|
|
1283
|
+
const res = await apiFetch('/api/kanban/attachments', { method: 'POST', body: form });
|
|
1284
|
+
if (res.ok) {
|
|
1285
|
+
const att = await res.json();
|
|
1286
|
+
s.attachments.push(att);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
s.uploading = false;
|
|
1290
|
+
renderAttachments();
|
|
1291
|
+
} else {
|
|
1292
|
+
s.pendingFiles.push(...files);
|
|
1293
|
+
renderAttachments();
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
async function handleSave() {
|
|
1298
|
+
const s = dialogState;
|
|
1299
|
+
if (!s || !s.title.trim() || s.saving) return;
|
|
1300
|
+
|
|
1301
|
+
s.saving = true;
|
|
1302
|
+
const saveBtn = $('[data-action="dlg-save"]');
|
|
1303
|
+
if (saveBtn) {
|
|
1304
|
+
saveBtn.disabled = true;
|
|
1305
|
+
saveBtn.textContent = 'Saving...';
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
if (!s.previewMode) {
|
|
1309
|
+
s.description = $('[data-field="dlg-desc"]')?.value ?? s.description;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
const data = {
|
|
1313
|
+
title: s.title.trim(),
|
|
1314
|
+
description: s.description || undefined,
|
|
1315
|
+
priority: s.priority,
|
|
1316
|
+
columnId: s.columnId,
|
|
1317
|
+
dueDate: s.dueDate ? new Date(s.dueDate).toISOString() : null,
|
|
1318
|
+
labels: JSON.stringify(s.labels),
|
|
1319
|
+
type: s.type,
|
|
1320
|
+
};
|
|
1321
|
+
|
|
1322
|
+
let savedIssue;
|
|
1323
|
+
|
|
1324
|
+
try {
|
|
1325
|
+
if (s.issue) {
|
|
1326
|
+
const res = await apiFetch(`/api/kanban/issues/${s.issue.id}`, {
|
|
1327
|
+
method: 'PATCH',
|
|
1328
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1329
|
+
body: JSON.stringify(data),
|
|
1330
|
+
});
|
|
1331
|
+
if (!res.ok) throw new Error(`Server error: ${res.status}`);
|
|
1332
|
+
savedIssue = await res.json();
|
|
1333
|
+
} else {
|
|
1334
|
+
const res = await apiFetch('/api/kanban/issues', {
|
|
1335
|
+
method: 'POST',
|
|
1336
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1337
|
+
body: JSON.stringify({ ...data, featureId: s.featureId, columnId: s.columnId }),
|
|
1338
|
+
});
|
|
1339
|
+
if (!res.ok) throw new Error(`Server error: ${res.status}`);
|
|
1340
|
+
savedIssue = await res.json();
|
|
1341
|
+
|
|
1342
|
+
if (savedIssue.id && s.pendingFiles.length > 0) {
|
|
1343
|
+
for (const f of s.pendingFiles) {
|
|
1344
|
+
const form = new FormData();
|
|
1345
|
+
form.append('file', f);
|
|
1346
|
+
form.append('issueId', savedIssue.id);
|
|
1347
|
+
await apiFetch('/api/kanban/attachments', { method: 'POST', body: form });
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
s.saving = false;
|
|
1353
|
+
s.onSave(savedIssue);
|
|
1354
|
+
closeDialog();
|
|
1355
|
+
} catch (err) {
|
|
1356
|
+
s.saving = false;
|
|
1357
|
+
if (saveBtn) {
|
|
1358
|
+
saveBtn.disabled = false;
|
|
1359
|
+
saveBtn.textContent = s.issue ? 'Save' : 'Create';
|
|
1360
|
+
}
|
|
1361
|
+
showToast('Failed to save issue: ' + err.message);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function closeDialog() {
|
|
1366
|
+
revokePendingObjectURLs();
|
|
1367
|
+
$('.modal-overlay[data-dialog="issue"]')?.classList.add('hidden');
|
|
1368
|
+
if (dialogState?.onClose) dialogState.onClose();
|
|
1369
|
+
dialogState = null;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function formatEntryDate(dateStr) {
|
|
1373
|
+
const d = new Date(dateStr);
|
|
1374
|
+
const now = new Date();
|
|
1375
|
+
const diff = (now - d) / 1000;
|
|
1376
|
+
if (diff < 60) return 'just now';
|
|
1377
|
+
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
1378
|
+
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
1379
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function renderVersionedEntries() {
|
|
1383
|
+
if (!dialogState) return;
|
|
1384
|
+
const s = dialogState;
|
|
1385
|
+
|
|
1386
|
+
const renderList = (entries, selector) => {
|
|
1387
|
+
const container = $(selector);
|
|
1388
|
+
if (!container) return;
|
|
1389
|
+
if (entries.length === 0) {
|
|
1390
|
+
container.innerHTML = '<div class="versioned-entries-empty">No entries yet</div>';
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
container.innerHTML = [...entries].reverse().map(e => `
|
|
1394
|
+
<div class="versioned-entry">
|
|
1395
|
+
<div class="versioned-entry-header">
|
|
1396
|
+
<span class="badge badge-secondary">v${e.version}</span>
|
|
1397
|
+
<span class="versioned-entry-date">${formatEntryDate(e.createdAt)}</span>
|
|
1398
|
+
</div>
|
|
1399
|
+
<div class="versioned-entry-content markdown-preview">${marked.parse(normalizeEscapes(e.content))}</div>
|
|
1400
|
+
</div>
|
|
1401
|
+
`).join('');
|
|
1402
|
+
};
|
|
1403
|
+
|
|
1404
|
+
renderList(s.reviewHistory, '[data-region="dlg-review-entries"]');
|
|
1405
|
+
renderList(s.workLog, '[data-region="dlg-worklog-entries"]');
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// ── Shared Dialog HTML ───────────────────────────────────────────────────────
|
|
1409
|
+
|
|
1410
|
+
function _getDialogHTML() {
|
|
1411
|
+
return `
|
|
1412
|
+
<!-- New Feature Dialog -->
|
|
1413
|
+
<div class="modal-overlay hidden" data-dialog="new-feature" role="dialog" aria-modal="true">
|
|
1414
|
+
<div class="modal">
|
|
1415
|
+
<div class="modal-header">
|
|
1416
|
+
<h2>New Feature</h2>
|
|
1417
|
+
<p class="modal-desc">Create a new feature with a kanban board.</p>
|
|
1418
|
+
</div>
|
|
1419
|
+
<div class="modal-body">
|
|
1420
|
+
<div class="form-group">
|
|
1421
|
+
<label>Name</label>
|
|
1422
|
+
<input type="text" data-field="nf-name" placeholder="Feature name...">
|
|
1423
|
+
</div>
|
|
1424
|
+
<div class="form-group">
|
|
1425
|
+
<label>Description</label>
|
|
1426
|
+
<textarea data-field="nf-desc" placeholder="Optional description..." rows="2"></textarea>
|
|
1427
|
+
</div>
|
|
1428
|
+
<div class="form-group">
|
|
1429
|
+
<label>Color</label>
|
|
1430
|
+
<div data-field="nf-colors" class="color-picker"></div>
|
|
1431
|
+
</div>
|
|
1432
|
+
<div class="modal-actions">
|
|
1433
|
+
<button class="btn btn-secondary" data-action="nf-cancel">Cancel</button>
|
|
1434
|
+
<button class="btn btn-primary" data-action="nf-create">Create Feature</button>
|
|
1435
|
+
</div>
|
|
1436
|
+
</div>
|
|
1437
|
+
</div>
|
|
1438
|
+
</div>
|
|
1439
|
+
|
|
1440
|
+
<!-- Delete Feature Confirmation Dialog -->
|
|
1441
|
+
<div class="modal-overlay hidden" data-dialog="delete-feature" role="dialog" aria-modal="true">
|
|
1442
|
+
<div class="modal">
|
|
1443
|
+
<div class="modal-header">
|
|
1444
|
+
<h2>Delete Feature</h2>
|
|
1445
|
+
<p class="modal-desc" data-field="delete-feature-msg"></p>
|
|
1446
|
+
</div>
|
|
1447
|
+
<div class="modal-actions">
|
|
1448
|
+
<button class="btn btn-secondary" data-action="df-cancel">Cancel</button>
|
|
1449
|
+
<button class="btn btn-danger" data-action="df-confirm">Delete</button>
|
|
1450
|
+
</div>
|
|
1451
|
+
</div>
|
|
1452
|
+
</div>
|
|
1453
|
+
|
|
1454
|
+
<!-- Delete Issue Confirmation Dialog -->
|
|
1455
|
+
<div class="modal-overlay hidden" data-dialog="delete-issue" role="dialog" aria-modal="true">
|
|
1456
|
+
<div class="modal">
|
|
1457
|
+
<div class="modal-header">
|
|
1458
|
+
<h2>Delete Item</h2>
|
|
1459
|
+
<p class="modal-desc" data-field="delete-issue-msg"></p>
|
|
1460
|
+
</div>
|
|
1461
|
+
<div class="modal-actions">
|
|
1462
|
+
<button class="btn btn-secondary" data-action="di-cancel">Cancel</button>
|
|
1463
|
+
<button class="btn btn-danger" data-action="di-confirm">Delete</button>
|
|
1464
|
+
</div>
|
|
1465
|
+
</div>
|
|
1466
|
+
</div>
|
|
1467
|
+
|
|
1468
|
+
<!-- Edit Feature Dialog -->
|
|
1469
|
+
<div class="modal-overlay hidden" data-dialog="edit-feature" role="dialog" aria-modal="true">
|
|
1470
|
+
<div class="modal">
|
|
1471
|
+
<div class="modal-header">
|
|
1472
|
+
<h2>Edit Feature</h2>
|
|
1473
|
+
<p class="modal-desc">Update feature details.</p>
|
|
1474
|
+
</div>
|
|
1475
|
+
<div class="modal-body">
|
|
1476
|
+
<div class="form-group">
|
|
1477
|
+
<label>Name</label>
|
|
1478
|
+
<input type="text" data-field="ef-name" placeholder="Feature name...">
|
|
1479
|
+
</div>
|
|
1480
|
+
<div class="form-group">
|
|
1481
|
+
<label>Description</label>
|
|
1482
|
+
<textarea data-field="ef-desc" placeholder="Optional description..." rows="6"></textarea>
|
|
1483
|
+
</div>
|
|
1484
|
+
<div class="form-group">
|
|
1485
|
+
<label>Color</label>
|
|
1486
|
+
<div data-field="ef-colors" class="color-picker"></div>
|
|
1487
|
+
</div>
|
|
1488
|
+
<div class="modal-actions">
|
|
1489
|
+
<button class="btn btn-secondary" data-action="ef-cancel">Cancel</button>
|
|
1490
|
+
<button class="btn btn-primary" data-action="ef-save">Save Changes</button>
|
|
1491
|
+
</div>
|
|
1492
|
+
</div>
|
|
1493
|
+
</div>
|
|
1494
|
+
</div>
|
|
1495
|
+
|
|
1496
|
+
<!-- Issue Dialog (create/edit) -->
|
|
1497
|
+
<div class="modal-overlay hidden" data-dialog="issue" role="dialog" aria-modal="true">
|
|
1498
|
+
<div class="modal modal-lg">
|
|
1499
|
+
<!-- Populated by renderDialog() -->
|
|
1500
|
+
</div>
|
|
1501
|
+
</div>
|
|
1502
|
+
|
|
1503
|
+
<!-- Image Preview Overlay -->
|
|
1504
|
+
<div class="image-preview-overlay hidden">
|
|
1505
|
+
<img src="" alt="Preview">
|
|
1506
|
+
<button class="image-preview-close" aria-label="Close image preview">×</button>
|
|
1507
|
+
</div>
|
|
1508
|
+
`;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// ── Route handler ────────────────────────────────────────────────────────────
|
|
1512
|
+
|
|
1513
|
+
let _hashHandler = null;
|
|
1514
|
+
|
|
1515
|
+
async function onRouteChange() {
|
|
1516
|
+
if (!_root) return;
|
|
1517
|
+
const route = getRoute();
|
|
1518
|
+
stopPolling();
|
|
1519
|
+
stopBoardPolling();
|
|
1520
|
+
|
|
1521
|
+
if (route.page === 'board') {
|
|
1522
|
+
await renderBoard(route.featureId);
|
|
1523
|
+
} else {
|
|
1524
|
+
renderFeatureList();
|
|
1525
|
+
startFeaturePolling();
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// ── Visibility change handler ────────────────────────────────────────────────
|
|
1530
|
+
|
|
1531
|
+
function _handleVisibility() {
|
|
1532
|
+
if (document.hidden) {
|
|
1533
|
+
stopPolling();
|
|
1534
|
+
stopBoardPolling();
|
|
1535
|
+
} else {
|
|
1536
|
+
const route = getRoute();
|
|
1537
|
+
if (route.page === 'list') {
|
|
1538
|
+
startFeaturePolling();
|
|
1539
|
+
} else if (route.page === 'board' && boardFeature) {
|
|
1540
|
+
startBoardPolling();
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// ── Voice input handler ──────────────────────────────────────────────────────
|
|
1546
|
+
|
|
1547
|
+
function _handleVoiceResult(e) {
|
|
1548
|
+
if (!_root) return;
|
|
1549
|
+
const text = e.detail?.text;
|
|
1550
|
+
if (!text) return;
|
|
1551
|
+
|
|
1552
|
+
// Insert voice text into the currently focused input/textarea within the kanban container
|
|
1553
|
+
const active = document.activeElement;
|
|
1554
|
+
if (active && _root.contains(active) && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) {
|
|
1555
|
+
const start = active.selectionStart ?? active.value.length;
|
|
1556
|
+
const end = active.selectionEnd ?? active.value.length;
|
|
1557
|
+
active.value = active.value.slice(0, start) + text + active.value.slice(end);
|
|
1558
|
+
active.selectionStart = active.selectionEnd = start + text.length;
|
|
1559
|
+
active.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// ── Escape key handler (close modals) ────────────────────────────────────────
|
|
1564
|
+
|
|
1565
|
+
function _handleEscape(e) {
|
|
1566
|
+
if (e.key === 'Escape') {
|
|
1567
|
+
$$('.modal-overlay:not(.hidden)').forEach(m => m.classList.add('hidden'));
|
|
1568
|
+
const imgPreview = $('.image-preview-overlay:not(.hidden)');
|
|
1569
|
+
if (imgPreview) imgPreview.classList.add('hidden');
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// ── Exports ──────────────────────────────────────────────────────────────────
|
|
1574
|
+
|
|
1575
|
+
export async function mount(container, ctx) {
|
|
1576
|
+
_root = container;
|
|
1577
|
+
_projectId = ctx?.project?.id || null;
|
|
1578
|
+
_navigate = ctx?.navigate || null;
|
|
1579
|
+
|
|
1580
|
+
// 1. Scope the container
|
|
1581
|
+
container.classList.add('page-kanban');
|
|
1582
|
+
|
|
1583
|
+
// 2. Show loading state while vendors load
|
|
1584
|
+
container.innerHTML = '<div class="loading">Loading...</div>';
|
|
1585
|
+
|
|
1586
|
+
// 3. Load vendor libraries (SortableJS + marked.js)
|
|
1587
|
+
await loadVendors();
|
|
1588
|
+
|
|
1589
|
+
// 4. Configure marked to escape raw HTML
|
|
1590
|
+
if (typeof marked !== 'undefined' && marked.use) {
|
|
1591
|
+
marked.use({
|
|
1592
|
+
breaks: true,
|
|
1593
|
+
renderer: {
|
|
1594
|
+
html({ text }) {
|
|
1595
|
+
return escapeHtml(text);
|
|
1596
|
+
},
|
|
1597
|
+
},
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// 5. Reset state
|
|
1602
|
+
features = [];
|
|
1603
|
+
boardFeature = null;
|
|
1604
|
+
searchQuery = '';
|
|
1605
|
+
isDragging = false;
|
|
1606
|
+
isDialogOpen = false;
|
|
1607
|
+
dialogState = null;
|
|
1608
|
+
sortableInstances = [];
|
|
1609
|
+
pendingObjectURLs = [];
|
|
1610
|
+
|
|
1611
|
+
// 6. Fetch initial features
|
|
1612
|
+
try {
|
|
1613
|
+
const res = await apiFetch('/api/kanban/features');
|
|
1614
|
+
features = await res.json();
|
|
1615
|
+
} catch { features = []; }
|
|
1616
|
+
|
|
1617
|
+
// 7. Bind global event listeners
|
|
1618
|
+
_hashHandler = onRouteChange;
|
|
1619
|
+
window.addEventListener('hashchange', _hashHandler);
|
|
1620
|
+
|
|
1621
|
+
_visibilityHandler = _handleVisibility;
|
|
1622
|
+
document.addEventListener('visibilitychange', _visibilityHandler);
|
|
1623
|
+
|
|
1624
|
+
_searchKeydownHandler = _handleSearchKeydown;
|
|
1625
|
+
document.addEventListener('keydown', _searchKeydownHandler);
|
|
1626
|
+
|
|
1627
|
+
_escapeHandler = _handleEscape;
|
|
1628
|
+
document.addEventListener('keydown', _escapeHandler);
|
|
1629
|
+
|
|
1630
|
+
_voiceHandler = _handleVoiceResult;
|
|
1631
|
+
document.addEventListener('voice:result', _voiceHandler);
|
|
1632
|
+
|
|
1633
|
+
// 8. Render based on current route
|
|
1634
|
+
await onRouteChange();
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
export function unmount(container) {
|
|
1638
|
+
// 1. Stop all polling
|
|
1639
|
+
stopPolling();
|
|
1640
|
+
stopBoardPolling();
|
|
1641
|
+
|
|
1642
|
+
// 2. Clean up SortableJS instances
|
|
1643
|
+
sortableInstances.forEach(s => s.destroy());
|
|
1644
|
+
sortableInstances = [];
|
|
1645
|
+
|
|
1646
|
+
// 3. Revoke object URLs
|
|
1647
|
+
revokePendingObjectURLs();
|
|
1648
|
+
|
|
1649
|
+
// 4. Remove event listeners
|
|
1650
|
+
if (_hashHandler) {
|
|
1651
|
+
window.removeEventListener('hashchange', _hashHandler);
|
|
1652
|
+
_hashHandler = null;
|
|
1653
|
+
}
|
|
1654
|
+
if (_visibilityHandler) {
|
|
1655
|
+
document.removeEventListener('visibilitychange', _visibilityHandler);
|
|
1656
|
+
_visibilityHandler = null;
|
|
1657
|
+
}
|
|
1658
|
+
if (_searchKeydownHandler) {
|
|
1659
|
+
document.removeEventListener('keydown', _searchKeydownHandler);
|
|
1660
|
+
_searchKeydownHandler = null;
|
|
1661
|
+
}
|
|
1662
|
+
if (_escapeHandler) {
|
|
1663
|
+
document.removeEventListener('keydown', _escapeHandler);
|
|
1664
|
+
_escapeHandler = null;
|
|
1665
|
+
}
|
|
1666
|
+
if (_voiceHandler) {
|
|
1667
|
+
document.removeEventListener('voice:result', _voiceHandler);
|
|
1668
|
+
_voiceHandler = null;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// 5. Clear container
|
|
1672
|
+
container.classList.remove('page-kanban');
|
|
1673
|
+
container.innerHTML = '';
|
|
1674
|
+
|
|
1675
|
+
// 6. Reset module state
|
|
1676
|
+
_root = null;
|
|
1677
|
+
_projectId = null;
|
|
1678
|
+
_navigate = null;
|
|
1679
|
+
features = [];
|
|
1680
|
+
boardFeature = null;
|
|
1681
|
+
dialogState = null;
|
|
1682
|
+
deleteTargetFeature = null;
|
|
1683
|
+
editTargetFeature = null;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
export function onProjectChange(project) {
|
|
1687
|
+
// 1. Stop all polling from the previous project context
|
|
1688
|
+
stopPolling();
|
|
1689
|
+
stopBoardPolling();
|
|
1690
|
+
|
|
1691
|
+
// 2. Update project ID before any fetches so apiFetch sends the new header
|
|
1692
|
+
_projectId = project?.id || null;
|
|
1693
|
+
|
|
1694
|
+
// 3. Clear stale state from the previous project immediately
|
|
1695
|
+
features = [];
|
|
1696
|
+
boardFeature = null;
|
|
1697
|
+
searchQuery = '';
|
|
1698
|
+
dialogState = null;
|
|
1699
|
+
deleteTargetFeature = null;
|
|
1700
|
+
editTargetFeature = null;
|
|
1701
|
+
sortableInstances.forEach(s => s.destroy());
|
|
1702
|
+
sortableInstances = [];
|
|
1703
|
+
|
|
1704
|
+
// 4. If currently on the board view, navigate back to list first.
|
|
1705
|
+
// Use location.replace so the hashchange handler fires synchronously
|
|
1706
|
+
// (internalNavigate would cause onRouteChange to race with our fetch below).
|
|
1707
|
+
if (getRoute().page === 'board') {
|
|
1708
|
+
// Temporarily remove the hash handler to avoid onRouteChange racing with us
|
|
1709
|
+
if (_hashHandler) window.removeEventListener('hashchange', _hashHandler);
|
|
1710
|
+
location.hash = '#/';
|
|
1711
|
+
if (_hashHandler) window.addEventListener('hashchange', _hashHandler);
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// 5. Render the empty list view immediately (shows "No features yet" placeholder)
|
|
1715
|
+
renderFeatureList();
|
|
1716
|
+
|
|
1717
|
+
// 6. Fetch fresh features for the new project context
|
|
1718
|
+
apiFetch('/api/kanban/features')
|
|
1719
|
+
.then(r => r.json())
|
|
1720
|
+
.then(fresh => {
|
|
1721
|
+
features = fresh;
|
|
1722
|
+
renderFeatures();
|
|
1723
|
+
startFeaturePolling();
|
|
1724
|
+
})
|
|
1725
|
+
.catch(() => {
|
|
1726
|
+
features = [];
|
|
1727
|
+
renderFeatures();
|
|
1728
|
+
startFeaturePolling();
|
|
1729
|
+
});
|
|
1730
|
+
}
|