@ycniuqton/devlens 0.1.0
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/README.md +164 -0
- package/bin/devlens.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +205 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +3 -0
- package/dist/init.js +239 -0
- package/dist/init.js.map +1 -0
- package/dist/routes/diff.d.ts +1 -0
- package/dist/routes/diff.js +39 -0
- package/dist/routes/diff.js.map +1 -0
- package/dist/routes/integrations.d.ts +1 -0
- package/dist/routes/integrations.js +132 -0
- package/dist/routes/integrations.js.map +1 -0
- package/dist/routes/rules.d.ts +1 -0
- package/dist/routes/rules.js +115 -0
- package/dist/routes/rules.js.map +1 -0
- package/dist/routes/tasks.d.ts +4 -0
- package/dist/routes/tasks.js +360 -0
- package/dist/routes/tasks.js.map +1 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +112 -0
- package/dist/server.js.map +1 -0
- package/dist/services/claudeTasks.d.ts +23 -0
- package/dist/services/claudeTasks.js +160 -0
- package/dist/services/claudeTasks.js.map +1 -0
- package/dist/services/config.d.ts +3 -0
- package/dist/services/config.js +25 -0
- package/dist/services/config.js.map +1 -0
- package/dist/services/git.d.ts +8 -0
- package/dist/services/git.js +90 -0
- package/dist/services/git.js.map +1 -0
- package/dist/services/jira.d.ts +11 -0
- package/dist/services/jira.js +52 -0
- package/dist/services/jira.js.map +1 -0
- package/dist/services/linear.d.ts +9 -0
- package/dist/services/linear.js +69 -0
- package/dist/services/linear.js.map +1 -0
- package/dist/services/rules.d.ts +14 -0
- package/dist/services/rules.js +133 -0
- package/dist/services/rules.js.map +1 -0
- package/dist/services/taskStore.d.ts +27 -0
- package/dist/services/taskStore.js +261 -0
- package/dist/services/taskStore.js.map +1 -0
- package/dist/services/tunnel.d.ts +8 -0
- package/dist/services/tunnel.js +152 -0
- package/dist/services/tunnel.js.map +1 -0
- package/dist/services/watcher.d.ts +2 -0
- package/dist/services/watcher.js +30 -0
- package/dist/services/watcher.js.map +1 -0
- package/dist/types/index.d.ts +87 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/package.json +53 -0
- package/public/css/style.css +1613 -0
- package/public/index.html +395 -0
- package/public/js/app.js +104 -0
- package/public/js/diff.js +337 -0
- package/public/js/integrations.js +194 -0
- package/public/js/rules.js +174 -0
- package/public/js/tasks.js +301 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Rules tab — manage .devlens/rules.md
|
|
2
|
+
|
|
3
|
+
let currentRules = [];
|
|
4
|
+
|
|
5
|
+
async function loadRules() {
|
|
6
|
+
try {
|
|
7
|
+
const res = await fetch('/api/rules');
|
|
8
|
+
currentRules = await res.json();
|
|
9
|
+
renderRules();
|
|
10
|
+
} catch (err) {
|
|
11
|
+
showToast('Failed to load rules', 'error');
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function renderRules() {
|
|
16
|
+
const list = document.getElementById('rules-list');
|
|
17
|
+
if (!list) return;
|
|
18
|
+
|
|
19
|
+
if (!currentRules.length) {
|
|
20
|
+
list.innerHTML = '<p class="panel-empty">No rules defined</p>';
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Sort: protected (default) first
|
|
25
|
+
const sorted = [...currentRules].sort((a, b) => (b.protected ? 1 : 0) - (a.protected ? 1 : 0));
|
|
26
|
+
|
|
27
|
+
list.innerHTML = sorted.map(rule => {
|
|
28
|
+
const lockIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`;
|
|
29
|
+
const trashIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`;
|
|
30
|
+
|
|
31
|
+
return `
|
|
32
|
+
<div class="rule-card ${rule.active ? 'active' : 'inactive'} ${rule.protected ? 'protected' : ''}">
|
|
33
|
+
<label class="rule-toggle">
|
|
34
|
+
<input type="checkbox" ${rule.active ? 'checked' : ''} onchange="toggleRule(${rule.index})">
|
|
35
|
+
<span class="rule-toggle-slider"></span>
|
|
36
|
+
</label>
|
|
37
|
+
<div class="rule-content">
|
|
38
|
+
<div class="rule-text">${escapeHtml(rule.content)}</div>
|
|
39
|
+
${rule.protected ? '<span class="rule-default-badge">DEFAULT</span>' : ''}
|
|
40
|
+
</div>
|
|
41
|
+
<div class="rule-actions">
|
|
42
|
+
${rule.protected
|
|
43
|
+
? `<span class="rule-lock" title="Protected — cannot be deleted">${lockIcon}</span>`
|
|
44
|
+
: `<button class="btn-icon btn-icon-sm rule-delete" onclick="deleteRule(${rule.index})" title="Delete rule">${trashIcon}</button>`}
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
`;
|
|
48
|
+
}).join('');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function toggleRule(index) {
|
|
52
|
+
try {
|
|
53
|
+
await fetch(`/api/rules/${index}/toggle`, { method: 'PATCH' });
|
|
54
|
+
showToast('Rule toggled', 'success');
|
|
55
|
+
loadRules();
|
|
56
|
+
} catch {
|
|
57
|
+
showToast('Failed to toggle rule', 'error');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function deleteRule(index) {
|
|
62
|
+
if (!confirm('Delete this rule?')) return;
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(`/api/rules/${index}`, { method: 'DELETE' });
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
const err = await res.json();
|
|
67
|
+
showToast(err.error || 'Cannot delete', 'error');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
showToast('Rule deleted', 'success');
|
|
71
|
+
loadRules();
|
|
72
|
+
} catch {
|
|
73
|
+
showToast('Failed to delete rule', 'error');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function addRule(content) {
|
|
78
|
+
if (!content || !content.trim()) return;
|
|
79
|
+
try {
|
|
80
|
+
await fetch('/api/rules', {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'Content-Type': 'application/json' },
|
|
83
|
+
body: JSON.stringify({ content: content.trim() }),
|
|
84
|
+
});
|
|
85
|
+
showToast('Rule added', 'success');
|
|
86
|
+
loadRules();
|
|
87
|
+
} catch {
|
|
88
|
+
showToast('Failed to add rule', 'error');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Add Rule button → simple prompt
|
|
93
|
+
document.getElementById('add-rule-btn')?.addEventListener('click', () => {
|
|
94
|
+
const content = prompt('Enter the new rule:');
|
|
95
|
+
if (content) addRule(content);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Preset buttons
|
|
99
|
+
document.querySelectorAll('.preset-btn').forEach(btn => {
|
|
100
|
+
btn.addEventListener('click', () => {
|
|
101
|
+
const preset = btn.getAttribute('data-preset');
|
|
102
|
+
if (preset) addRule(preset);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ============================================================
|
|
107
|
+
// Commit Approval
|
|
108
|
+
// ============================================================
|
|
109
|
+
async function loadCommitApproval() {
|
|
110
|
+
try {
|
|
111
|
+
const res = await fetch('/api/rules/commit-approval/status');
|
|
112
|
+
const data = await res.json();
|
|
113
|
+
renderCommitApproval(data);
|
|
114
|
+
} catch {}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function renderCommitApproval(data) {
|
|
118
|
+
const pendingBanner = document.getElementById('commit-approval-banner');
|
|
119
|
+
const approvedBanner = document.getElementById('commit-approved-banner');
|
|
120
|
+
if (!pendingBanner || !approvedBanner) return;
|
|
121
|
+
|
|
122
|
+
if (data.pending) {
|
|
123
|
+
pendingBanner.style.display = '';
|
|
124
|
+
document.getElementById('commit-approval-message').textContent = data.pending;
|
|
125
|
+
approvedBanner.style.display = 'none';
|
|
126
|
+
} else {
|
|
127
|
+
pendingBanner.style.display = 'none';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (data.approved && data.approvedAt) {
|
|
131
|
+
approvedBanner.style.display = '';
|
|
132
|
+
document.getElementById('commit-approved-time').textContent = new Date(data.approvedAt).toLocaleString();
|
|
133
|
+
} else if (!data.pending) {
|
|
134
|
+
approvedBanner.style.display = 'none';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
document.getElementById('approve-commit-btn')?.addEventListener('click', async () => {
|
|
139
|
+
try {
|
|
140
|
+
await fetch('/api/rules/commit-approval/approve', { method: 'POST' });
|
|
141
|
+
showToast('Commit approved', 'success');
|
|
142
|
+
loadCommitApproval();
|
|
143
|
+
} catch {
|
|
144
|
+
showToast('Failed to approve', 'error');
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
document.getElementById('reject-commit-btn')?.addEventListener('click', async () => {
|
|
149
|
+
try {
|
|
150
|
+
await fetch('/api/rules/commit-approval/reject', { method: 'POST' });
|
|
151
|
+
showToast('Commit rejected', 'info');
|
|
152
|
+
loadCommitApproval();
|
|
153
|
+
} catch {
|
|
154
|
+
showToast('Failed to reject', 'error');
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// WebSocket handlers
|
|
159
|
+
function handleRulesUpdate(payload) {
|
|
160
|
+
if (payload?.rules) {
|
|
161
|
+
currentRules = payload.rules;
|
|
162
|
+
renderRules();
|
|
163
|
+
} else {
|
|
164
|
+
loadRules();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function handleCommitApprovalUpdate(payload) {
|
|
169
|
+
if (payload) renderCommitApproval(payload);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Initial load
|
|
173
|
+
loadRules();
|
|
174
|
+
loadCommitApproval();
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// Task board
|
|
2
|
+
const modal = document.getElementById('task-modal');
|
|
3
|
+
const taskForm = document.getElementById('task-form');
|
|
4
|
+
let allTasks = [];
|
|
5
|
+
let currentSessionFilter = '';
|
|
6
|
+
|
|
7
|
+
document.getElementById('add-task-btn').addEventListener('click', () => openModal());
|
|
8
|
+
|
|
9
|
+
// Session filter dropdown
|
|
10
|
+
const sessionSelect = document.getElementById('session-filter');
|
|
11
|
+
sessionSelect?.addEventListener('change', () => {
|
|
12
|
+
currentSessionFilter = sessionSelect.value;
|
|
13
|
+
loadTasks();
|
|
14
|
+
});
|
|
15
|
+
document.addEventListener('keydown', (e) => {
|
|
16
|
+
if (e.key === 'Escape' && !modal.classList.contains('hidden')) closeModal();
|
|
17
|
+
});
|
|
18
|
+
document.getElementById('modal-cancel').addEventListener('click', () => closeModal());
|
|
19
|
+
document.getElementById('modal-close').addEventListener('click', () => closeModal());
|
|
20
|
+
document.querySelector('.modal-backdrop').addEventListener('click', () => closeModal());
|
|
21
|
+
|
|
22
|
+
taskForm.addEventListener('submit', async (e) => {
|
|
23
|
+
e.preventDefault();
|
|
24
|
+
const id = document.getElementById('task-id').value;
|
|
25
|
+
const body = {
|
|
26
|
+
title: document.getElementById('task-title-input').value,
|
|
27
|
+
description: document.getElementById('task-desc-input').value,
|
|
28
|
+
priority: document.getElementById('task-priority-input').value,
|
|
29
|
+
status: document.getElementById('task-status-input').value,
|
|
30
|
+
tags: document.getElementById('task-tags-input').value
|
|
31
|
+
.split(',').map(t => t.trim()).filter(Boolean),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
if (id) {
|
|
36
|
+
await fetch(`/api/tasks/${id}`, {
|
|
37
|
+
method: 'PUT',
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
body: JSON.stringify(body),
|
|
40
|
+
});
|
|
41
|
+
} else {
|
|
42
|
+
await fetch('/api/tasks', {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
body: JSON.stringify(body),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
closeModal();
|
|
49
|
+
loadTasks();
|
|
50
|
+
} catch (err) {
|
|
51
|
+
showToast('Failed to save task', 'error');
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function openModal(task = null) {
|
|
56
|
+
document.getElementById('modal-title').textContent = task ? 'Edit Task' : 'New Task';
|
|
57
|
+
document.getElementById('task-id').value = task ? task.id : '';
|
|
58
|
+
document.getElementById('task-title-input').value = task ? task.title : '';
|
|
59
|
+
document.getElementById('task-desc-input').value = task ? task.description : '';
|
|
60
|
+
document.getElementById('task-priority-input').value = task ? task.priority : 'medium';
|
|
61
|
+
document.getElementById('task-status-input').value = task ? task.status : 'pending';
|
|
62
|
+
document.getElementById('task-tags-input').value = task ? task.tags.join(', ') : '';
|
|
63
|
+
|
|
64
|
+
// Header: task number + status badge
|
|
65
|
+
const taskNum = document.getElementById('modal-task-number');
|
|
66
|
+
const statusBadge = document.getElementById('modal-status-badge');
|
|
67
|
+
if (task?.claudeTaskId) {
|
|
68
|
+
taskNum.textContent = `#${task.claudeTaskId}`;
|
|
69
|
+
} else {
|
|
70
|
+
taskNum.textContent = '';
|
|
71
|
+
}
|
|
72
|
+
if (task) {
|
|
73
|
+
statusBadge.textContent = task.status;
|
|
74
|
+
statusBadge.className = 'modal-status-badge ' + task.status;
|
|
75
|
+
} else {
|
|
76
|
+
statusBadge.textContent = '';
|
|
77
|
+
statusBadge.className = 'modal-status-badge';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Footer: session hint
|
|
81
|
+
const sessionHint = document.getElementById('modal-session-hint');
|
|
82
|
+
sessionHint.textContent = task?.claudeSessionId ? `Session ${task.claudeSessionId.substring(0, 8)}` : '';
|
|
83
|
+
|
|
84
|
+
// Parse context (stored as JSON string with userPrompt, claudeReasoning, filesTouched)
|
|
85
|
+
let ctx = null;
|
|
86
|
+
if (task?.context) {
|
|
87
|
+
try { ctx = typeof task.context === 'string' ? JSON.parse(task.context) : task.context; } catch { ctx = { userPrompt: task.context }; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// User prompt
|
|
91
|
+
const contextGroup = document.getElementById('task-context-group');
|
|
92
|
+
if (ctx?.userPrompt) {
|
|
93
|
+
document.getElementById('task-context-prompt').textContent = ctx.userPrompt;
|
|
94
|
+
contextGroup.style.display = '';
|
|
95
|
+
} else {
|
|
96
|
+
contextGroup.style.display = 'none';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Claude reasoning
|
|
100
|
+
const reasoningGroup = document.getElementById('task-reasoning-group');
|
|
101
|
+
if (ctx?.claudeReasoning) {
|
|
102
|
+
document.getElementById('task-context-reasoning').textContent = ctx.claudeReasoning;
|
|
103
|
+
reasoningGroup.style.display = '';
|
|
104
|
+
} else {
|
|
105
|
+
reasoningGroup.style.display = 'none';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Files touched
|
|
109
|
+
const filesGroup = document.getElementById('task-files-group');
|
|
110
|
+
if (ctx?.filesTouched?.length) {
|
|
111
|
+
document.getElementById('task-context-files').textContent = ctx.filesTouched.join('\n');
|
|
112
|
+
filesGroup.style.display = '';
|
|
113
|
+
} else {
|
|
114
|
+
filesGroup.style.display = 'none';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Completion context
|
|
118
|
+
let compCtx = null;
|
|
119
|
+
if (task?.completionContext) {
|
|
120
|
+
try { compCtx = typeof task.completionContext === 'string' ? JSON.parse(task.completionContext) : task.completionContext; } catch {}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const completionGroup = document.getElementById('task-completion-group');
|
|
124
|
+
if (compCtx?.claudeReasoning) {
|
|
125
|
+
document.getElementById('task-completion-display').textContent = compCtx.claudeReasoning;
|
|
126
|
+
completionGroup.style.display = '';
|
|
127
|
+
} else {
|
|
128
|
+
completionGroup.style.display = 'none';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const completionFilesGroup = document.getElementById('task-completion-files-group');
|
|
132
|
+
if (compCtx?.filesTouched?.length) {
|
|
133
|
+
document.getElementById('task-completion-files').textContent = compCtx.filesTouched.join('\n');
|
|
134
|
+
completionFilesGroup.style.display = '';
|
|
135
|
+
} else {
|
|
136
|
+
completionFilesGroup.style.display = 'none';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Show Claude metadata if available
|
|
140
|
+
const metaGroup = document.getElementById('task-claude-meta-group');
|
|
141
|
+
const metaDisplay = document.getElementById('task-claude-meta');
|
|
142
|
+
const metaParts = [];
|
|
143
|
+
if (task?.claudeTaskId) metaParts.push(`Task ID: #${task.claudeTaskId}`);
|
|
144
|
+
if (task?.claudeSessionId) metaParts.push(`Session: ${task.claudeSessionId.substring(0, 8)}`);
|
|
145
|
+
if (task?.activeForm) metaParts.push(`Active: ${task.activeForm}`);
|
|
146
|
+
if (task?.owner) metaParts.push(`Owner: ${task.owner}`);
|
|
147
|
+
if (task?.completedAt) metaParts.push(`Completed: ${task.completedAt}`);
|
|
148
|
+
if (task?.metadata) metaParts.push(`Metadata: ${JSON.stringify(task.metadata)}`);
|
|
149
|
+
|
|
150
|
+
if (metaParts.length > 0) {
|
|
151
|
+
metaDisplay.textContent = metaParts.join('\n');
|
|
152
|
+
metaGroup.style.display = '';
|
|
153
|
+
} else {
|
|
154
|
+
metaGroup.style.display = 'none';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Show/hide files row
|
|
158
|
+
const filesRow = document.getElementById('modal-row-files');
|
|
159
|
+
const hasFilesRow = ctx?.filesTouched?.length || compCtx?.filesTouched?.length || metaParts.length > 0;
|
|
160
|
+
if (filesRow) filesRow.style.display = hasFilesRow ? '' : 'none';
|
|
161
|
+
|
|
162
|
+
modal.classList.remove('hidden');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function closeModal() {
|
|
166
|
+
modal.classList.add('hidden');
|
|
167
|
+
taskForm.reset();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---- Kanban Board ----
|
|
171
|
+
async function loadTasks() {
|
|
172
|
+
try {
|
|
173
|
+
const params = currentSessionFilter ? `?session=${currentSessionFilter}` : '';
|
|
174
|
+
const res = await fetch(`/api/tasks${params}`);
|
|
175
|
+
allTasks = await res.json();
|
|
176
|
+
renderBoard();
|
|
177
|
+
} catch (err) {
|
|
178
|
+
showToast('Failed to load tasks', 'error');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function loadSessions() {
|
|
183
|
+
try {
|
|
184
|
+
const res = await fetch('/api/tasks/sessions');
|
|
185
|
+
const sessions = await res.json();
|
|
186
|
+
renderSessionDropdown(sessions);
|
|
187
|
+
} catch {}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function renderSessionDropdown(sessions) {
|
|
191
|
+
const select = document.getElementById('session-filter');
|
|
192
|
+
if (!select) return;
|
|
193
|
+
|
|
194
|
+
const current = select.value;
|
|
195
|
+
select.innerHTML = '<option value="">All Sessions</option>';
|
|
196
|
+
|
|
197
|
+
for (const s of sessions) {
|
|
198
|
+
const name = s.name || s.sessionId.substring(0, 8);
|
|
199
|
+
const dot = s.status === 'active' ? '\u{1F7E2}' : '\u26AA';
|
|
200
|
+
const opt = document.createElement('option');
|
|
201
|
+
opt.value = s.sessionId;
|
|
202
|
+
opt.textContent = `${dot} ${name} (${s.taskCount})`;
|
|
203
|
+
select.appendChild(opt);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Restore selection
|
|
207
|
+
if (current) select.value = current;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function renderBoard() {
|
|
211
|
+
['pending', 'in-progress', 'completed', 'archived'].forEach(status => {
|
|
212
|
+
const column = document.querySelector(`.column-cards[data-status="${status}"]`);
|
|
213
|
+
const tasks = allTasks.filter(t => t.status === status);
|
|
214
|
+
const count = column.closest('.kanban-column').querySelector('.count');
|
|
215
|
+
count.textContent = tasks.length;
|
|
216
|
+
|
|
217
|
+
column.innerHTML = tasks.map(task => `
|
|
218
|
+
<div class="task-card priority-${task.priority}" draggable="true" data-id="${task.id}">
|
|
219
|
+
<div class="task-title">${escapeHtml(task.title)}</div>
|
|
220
|
+
${task.description ? `<div style="font-size:12px;color:var(--color-text-secondary);margin-bottom:6px">${escapeHtml(task.description).substring(0, 100)}</div>` : ''}
|
|
221
|
+
<div class="task-meta">
|
|
222
|
+
${task.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}
|
|
223
|
+
</div>
|
|
224
|
+
${task.dependencies.length ? `<div class="task-deps">Blocked by: ${task.dependencies.length} task(s)</div>` : ''}
|
|
225
|
+
<div class="card-actions">
|
|
226
|
+
<button onclick="editTask('${task.id}')">Edit</button>
|
|
227
|
+
<button class="delete" onclick="deleteTask('${task.id}')">Delete</button>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
`).join('');
|
|
231
|
+
|
|
232
|
+
// Click to edit + drag events
|
|
233
|
+
column.querySelectorAll('.task-card').forEach(card => {
|
|
234
|
+
card.addEventListener('click', (e) => {
|
|
235
|
+
if (e.target.closest('.card-actions')) return; // don't trigger on Edit/Delete buttons
|
|
236
|
+
editTask(card.dataset.id);
|
|
237
|
+
});
|
|
238
|
+
card.addEventListener('dragstart', (e) => {
|
|
239
|
+
e.dataTransfer.setData('text/plain', card.dataset.id);
|
|
240
|
+
card.classList.add('dragging');
|
|
241
|
+
});
|
|
242
|
+
card.addEventListener('dragend', () => card.classList.remove('dragging'));
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Drop targets
|
|
247
|
+
document.querySelectorAll('.column-cards').forEach(col => {
|
|
248
|
+
col.addEventListener('dragover', (e) => {
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
col.classList.add('drag-over');
|
|
251
|
+
});
|
|
252
|
+
col.addEventListener('dragleave', () => col.classList.remove('drag-over'));
|
|
253
|
+
col.addEventListener('drop', async (e) => {
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
col.classList.remove('drag-over');
|
|
256
|
+
const taskId = e.dataTransfer.getData('text/plain');
|
|
257
|
+
const newStatus = col.dataset.status;
|
|
258
|
+
try {
|
|
259
|
+
await fetch(`/api/tasks/${taskId}`, {
|
|
260
|
+
method: 'PUT',
|
|
261
|
+
headers: { 'Content-Type': 'application/json' },
|
|
262
|
+
body: JSON.stringify({ status: newStatus }),
|
|
263
|
+
});
|
|
264
|
+
loadTasks();
|
|
265
|
+
} catch (err) {
|
|
266
|
+
showToast('Failed to update task', 'error');
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function editTask(id) {
|
|
273
|
+
const task = allTasks.find(t => t.id === id);
|
|
274
|
+
if (task) openModal(task);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function deleteTask(id) {
|
|
278
|
+
if (!confirm('Delete this task?')) return;
|
|
279
|
+
try {
|
|
280
|
+
await fetch(`/api/tasks/${id}`, { method: 'DELETE' });
|
|
281
|
+
loadTasks();
|
|
282
|
+
} catch (err) {
|
|
283
|
+
showToast('Failed to delete task', 'error');
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---- WebSocket handlers ----
|
|
288
|
+
function handleTaskUpdate() {
|
|
289
|
+
loadSessions();
|
|
290
|
+
loadTasks();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function escapeHtml(str) {
|
|
294
|
+
const div = document.createElement('div');
|
|
295
|
+
div.textContent = str;
|
|
296
|
+
return div.innerHTML;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Initial load
|
|
300
|
+
loadSessions();
|
|
301
|
+
loadTasks();
|