local-kanban 1.6.3

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +381 -0
  2. package/LICENSE +21 -0
  3. package/README.md +144 -0
  4. package/README.ru.md +144 -0
  5. package/bin/kb +306 -0
  6. package/bin/local-kanban +217 -0
  7. package/ecosystem.example.cjs +25 -0
  8. package/package.json +52 -0
  9. package/public/app.js +18 -0
  10. package/public/boot-fallback.js +3 -0
  11. package/public/drawer-sync.js +14 -0
  12. package/public/favicon-180.png +0 -0
  13. package/public/favicon-32.png +0 -0
  14. package/public/favicon-64.png +0 -0
  15. package/public/favicon.ico +0 -0
  16. package/public/favicon.svg +6 -0
  17. package/public/icon-192.png +0 -0
  18. package/public/icon-512.png +0 -0
  19. package/public/icon-maskable-192.png +0 -0
  20. package/public/icon-maskable-512.png +0 -0
  21. package/public/index.html +138 -0
  22. package/public/js/board.js +698 -0
  23. package/public/js/chaos.js +247 -0
  24. package/public/js/core.js +821 -0
  25. package/public/js/dash.js +114 -0
  26. package/public/js/drawer.js +919 -0
  27. package/public/js/horizon.js +140 -0
  28. package/public/js/init.js +208 -0
  29. package/public/js/project.js +338 -0
  30. package/public/js/settings.js +905 -0
  31. package/public/js/sidebar.js +640 -0
  32. package/public/js/sse.js +386 -0
  33. package/public/manifest.webmanifest +32 -0
  34. package/public/offline.html +39 -0
  35. package/public/style.css +1247 -0
  36. package/public/sw.js +31 -0
  37. package/scripts/raycast/open-kanban.sh +18 -0
  38. package/scripts/update.js +43 -0
  39. package/skills/deploy/SKILL.md +70 -0
  40. package/skills/kanban/SKILL.md +217 -0
  41. package/src/backup.js +111 -0
  42. package/src/bus.js +10 -0
  43. package/src/config.js +40 -0
  44. package/src/db.js +321 -0
  45. package/src/error-handler.js +11 -0
  46. package/src/file-snapshot.js +22 -0
  47. package/src/origin-guard.js +32 -0
  48. package/src/pm2-services.js +31 -0
  49. package/src/query-params.js +17 -0
  50. package/src/repo-base.js +23 -0
  51. package/src/restore.js +153 -0
  52. package/src/routes/dashboard.js +132 -0
  53. package/src/routes/events.js +30 -0
  54. package/src/routes/horizons.js +66 -0
  55. package/src/routes/projects.js +532 -0
  56. package/src/routes/skills.js +116 -0
  57. package/src/routes/system.js +285 -0
  58. package/src/routes/tasks.js +477 -0
  59. package/src/schema.sql +135 -0
  60. package/src/server.js +100 -0
  61. package/src/skills.js +119 -0
  62. package/src/sync/github.js +265 -0
  63. package/src/sync/worker.js +136 -0
  64. package/src/task-shape.js +29 -0
@@ -0,0 +1,247 @@
1
+
2
+ import { buildSelect, selVal } from './board.js';
3
+ import { $, LABEL_COLORS, LABEL_SELECTABLE, PRI_ICON, PRI_LEVELS, api, esc, ic, state, tr } from './core.js';
4
+ import { openDrawer, openLightbox, uploadAttachment } from './drawer.js';
5
+ import { loadProjects } from './sidebar.js';
6
+ import { autoGrow, refresh, titleOr } from './sse.js';
7
+
8
+ const CHAOS_IC = {
9
+ attach: '<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M11 5.2 6.3 9.9a1.7 1.7 0 0 0 2.4 2.4l4.7-4.7a3.2 3.2 0 0 0-4.6-4.5L4 7.9a4.8 4.8 0 0 0 6.8 6.8l3-3" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
10
+ noclaude: '<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><circle cx="8" cy="5" r="2.4" fill="none" stroke="currentColor" stroke-width="1.2"/><path d="M3.6 13a4.4 4.4 0 0 1 8.8 0" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>',
11
+ go: '<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M3 8h9M9.2 4.4 12.8 8l-3.6 3.6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>',
12
+ tag: '<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 2.2H3.2a1 1 0 0 0-1 1V8l5.6 5.6a1.2 1.2 0 0 0 1.7 0l3.9-3.9a1.2 1.2 0 0 0 0-1.7L8 2.2Z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/><circle cx="5.4" cy="5.4" r="1" fill="currentColor"/></svg>',
13
+ flag: '<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><path d="M4 14V2.6M4 3.2h7.5l-1.6 2.4 1.6 2.4H4" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
14
+ stBacklog: '<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><circle cx="8" cy="8" r="5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>',
15
+ stTodo: '<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><circle cx="8" cy="8" r="5" fill="none" stroke="currentColor" stroke-width="1.4"/><circle cx="8" cy="8" r="2.3" fill="currentColor"/></svg>',
16
+ pin: '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M9.6 2.2 13.8 6.4M11 4 7.6 7.4l-3 .6 4.4 4.4.6-3L13 6M5.2 10.8 2.6 13.4" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
17
+ lockClosed: '<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><rect x="3.5" y="7" width="9" height="6.4" rx="1.2" fill="none" stroke="currentColor" stroke-width="1.2"/><path d="M5.4 7V5.2a2.6 2.6 0 0 1 5.2 0V7" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>',
18
+ lockOpen: '<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><rect x="3.5" y="7" width="9" height="6.4" rx="1.2" fill="none" stroke="currentColor" stroke-width="1.2"/><path d="M5.4 7V5.2a2.6 2.6 0 0 1 5.0-.8" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>',
19
+ spark: '<svg viewBox="0 0 32 32" width="28" height="28" aria-hidden="true"><g stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M16 16 29 16M16 16 27.3 22.5M16 16 22.5 27.3M16 16 16 29M16 16 9.5 27.3M16 16 4.7 22.5M16 16 3 16M16 16 4.7 9.5M16 16 9.5 4.7M16 16 16 3M16 16 22.5 4.7M16 16 27.3 9.5"/></g></svg>',
20
+ send: '<svg viewBox="0 0 18 18" width="17" height="17" aria-hidden="true"><path d="M9 14.5V4M4.5 8 9 3.5 13.5 8" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>',
21
+ };
22
+ const CHAOS_TITLES = [
23
+ 'What are we working on?',
24
+ 'Where do we start?',
25
+ 'What goes into the queue?',
26
+ 'What’s on for today?',
27
+ 'Let’s jot down some tasks?',
28
+ 'What’s on your mind?',
29
+ 'What shall we turn into a task?',
30
+ 'What do we take on?',
31
+ 'What do we note before it slips?',
32
+ 'Which chaos do we sort out?',
33
+ ];
34
+ let chaosTitleIdx = -1;
35
+ function setChaosHeading() {
36
+ const el = $('chaos-title');
37
+ if (!el) return;
38
+ let i = Math.floor(Math.random() * CHAOS_TITLES.length);
39
+ if (i === chaosTitleIdx) i = (i + 1) % CHAOS_TITLES.length;
40
+ chaosTitleIdx = i;
41
+ el.textContent = tr(CHAOS_TITLES[i]);
42
+ }
43
+ let chaosBuilt = false;
44
+ let chaosStatus = 'backlog';
45
+ let chaosPriority = 0;
46
+ let chaosLabel = '';
47
+ let chaosNoclaude = false;
48
+ export let chaosPending = [];
49
+ let chaosHintTimer = null;
50
+ let chaosLock = localStorage.getItem('kb.chaos.lock') === '1';
51
+
52
+ function buildChaosProjectSelect() {
53
+ const host = $('chaos-project');
54
+ if (!host) return;
55
+ const keep = host.dataset.value || localStorage.getItem('kb.chaos.project') || localStorage.getItem('kb.ui.lastProject');
56
+ const projOpts = [...state.projects]
57
+ .sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0))
58
+ .map((p) => ({ value: p.slug, label: p.name || p.slug, rightHTML: p.pinned ? `<span class="kbsel-pin" title="${tr('pinned')}">${CHAOS_IC.pin}</span>` : '' }));
59
+ const defProj = state.projects.find((p) => p.slug === keep)?.slug || projOpts[0]?.value || '';
60
+ buildSelect(host, { value: defProj, placeholder: tr('project'), options: projOpts, search: true });
61
+ }
62
+
63
+ export async function renderChaos(opening) {
64
+ if (!state.projects.length) { try { await loadProjects(); } catch { } }
65
+ if (chaosBuilt && $('chaos-input')) {
66
+ if (opening) setChaosHeading();
67
+ buildChaosProjectSelect(); renderChaosNoclaudeList(opening); return;
68
+ }
69
+ $('chaos').innerHTML = `
70
+ <div class="chaos-wrap">
71
+ <div class="chaos-head">
72
+ <span class="chaos-spark">${CHAOS_IC.spark}</span>
73
+ <h1 class="chaos-title" id="chaos-title"></h1>
74
+ </div>
75
+ <div class="chaos-sub">${tr('Jot a task down quickly — it lands in the project you pick.')}</div>
76
+ <div class="chaos-card">
77
+ <div id="chaos-pending" class="chaos-pending hidden"></div>
78
+ <textarea id="chaos-input" class="chaos-input" rows="2" placeholder="${tr('What needs doing?')}"></textarea>
79
+ <div class="chaos-card-foot">
80
+ <button class="chaos-ic chaos-attach" id="chaos-attach-ic" title="${tr('Attach a screenshot')}">${CHAOS_IC.attach}</button>
81
+ <span class="chaos-foot-gap"></span>
82
+ <button class="chaos-ic chaos-go" id="chaos-create" title="${tr('Create (⌘/Ctrl+Enter)')}">${CHAOS_IC.send}</button>
83
+ </div>
84
+ <input id="chaos-file" type="file" accept="image/*" multiple hidden>
85
+ </div>
86
+ <div class="chaos-bar">
87
+ <div class="kbsel chaos-proj" id="chaos-project"></div>
88
+ <button class="chaos-ic" id="chaos-status-ic" title="${tr('Status')}"></button>
89
+ <button class="chaos-ic" id="chaos-pri-ic" title="${tr('Priority')}"></button>
90
+ <button class="chaos-ic" id="chaos-label-ic" title="${tr('Label')}"></button>
91
+ <button class="chaos-ic" id="chaos-noclaude-ic" title="${tr('noclaude label — done by hand')}">${CHAOS_IC.noclaude}</button>
92
+ <button class="chaos-ic" id="chaos-lock-ic" title="${tr('Lock the selection')}"></button>
93
+ </div>
94
+ <div id="chaos-hint" class="chaos-hint"></div>
95
+ <div class="chaos-list">
96
+ <div id="chaos-nc-list" class="chaos-nc-list"></div>
97
+ </div>
98
+ </div>`;
99
+ setChaosHeading();
100
+ buildChaosProjectSelect();
101
+
102
+ refreshChaosStatusIc(); refreshChaosPriIc(); refreshChaosLabelIc(); refreshChaosNoclaudeIc(); refreshChaosLockIc();
103
+ $('chaos-status-ic').onclick = (e) => chaosPopover(e.currentTarget,
104
+ [['backlog', tr('Backlog')], ['todo', tr('To do')]].map(([v, l]) => ({ v, html: `<span class="dot s-${v}"></span>${l}`, active: v === chaosStatus })),
105
+ (v) => { chaosStatus = v; refreshChaosStatusIc(); });
106
+ $('chaos-pri-ic').onclick = (e) => chaosPopover(e.currentTarget,
107
+ PRI_LEVELS.map(([v, l]) => ({ v, html: l, active: v === chaosPriority })),
108
+ (v) => { chaosPriority = Number(v); refreshChaosPriIc(); });
109
+ $('chaos-label-ic').onclick = (e) => chaosPopover(e.currentTarget,
110
+ [{ v: '', html: tr('no label'), active: chaosLabel === '' }].concat(LABEL_SELECTABLE.map((n) =>
111
+ ({ v: n, html: `<span class="lbl-dot" style="background:${LABEL_COLORS[n]}"></span>${esc(n)}`, active: chaosLabel === n }))),
112
+ (v) => { chaosLabel = v; refreshChaosLabelIc(); });
113
+ $('chaos-noclaude-ic').onclick = () => { chaosNoclaude = !chaosNoclaude; refreshChaosNoclaudeIc(); };
114
+ $('chaos-lock-ic').onclick = () => { chaosLock = !chaosLock; localStorage.setItem('kb.chaos.lock', chaosLock ? '1' : '0'); refreshChaosLockIc(); };
115
+
116
+ $('chaos-attach-ic').onclick = () => $('chaos-file').click();
117
+ $('chaos-file').onchange = (e) => { chaosAddImages([...e.target.files]); e.target.value = ''; };
118
+ $('chaos').onpaste = onChaosPaste;
119
+ $('chaos-create').onclick = chaosCreate;
120
+
121
+ const input = $('chaos-input');
122
+ input.oninput = () => autoGrow(input);
123
+ input.onkeydown = (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); chaosCreate(); } };
124
+ chaosBuilt = true;
125
+ renderChaosPending();
126
+ renderChaosNoclaudeList(true);
127
+ input.focus();
128
+ }
129
+ function refreshChaosStatusIc() {
130
+ const ic = $('chaos-status-ic');
131
+ ic.innerHTML = chaosStatus === 'todo' ? CHAOS_IC.stTodo : CHAOS_IC.stBacklog;
132
+ ic.classList.toggle('set', chaosStatus === 'todo');
133
+ ic.title = `${tr('Status:')} ${chaosStatus === 'todo' ? tr('To do') : tr('Backlog')}`;
134
+ }
135
+ function refreshChaosPriIc() {
136
+ const p = chaosPriority;
137
+ $('chaos-pri-ic').innerHTML = p ? `<span class="pri ${PRI_ICON[p][1]}">${PRI_ICON[p][0]}</span>` : CHAOS_IC.flag;
138
+ $('chaos-pri-ic').classList.toggle('set', !!p);
139
+ $('chaos-pri-ic').title = p ? `${tr('Priority:')} ${PRI_ICON[p][2]}` : tr('Priority: none');
140
+ }
141
+ function refreshChaosLabelIc() {
142
+ $('chaos-label-ic').innerHTML = CHAOS_IC.tag + (chaosLabel ? `<span class="lbl-dot" style="background:${LABEL_COLORS[chaosLabel]}"></span>` : '');
143
+ $('chaos-label-ic').classList.toggle('set', !!chaosLabel);
144
+ $('chaos-label-ic').title = chaosLabel ? `${tr('Label')}: ${chaosLabel}` : tr('Label');
145
+ }
146
+ function refreshChaosNoclaudeIc() { $('chaos-noclaude-ic').classList.toggle('on', chaosNoclaude); }
147
+ function refreshChaosLockIc() {
148
+ const ic = $('chaos-lock-ic');
149
+ if (!ic) return;
150
+ ic.innerHTML = chaosLock ? CHAOS_IC.lockClosed : CHAOS_IC.lockOpen;
151
+ ic.classList.toggle('on', chaosLock);
152
+ ic.title = chaosLock ? tr('Lock on: status/priority/labels are kept after creating') : tr('Lock off: the choice resets after creating');
153
+ }
154
+
155
+ function chaosPopover(anchor, items, onPick) {
156
+ document.querySelector('.chaos-pop')?.remove();
157
+ const pop = document.createElement('div');
158
+ pop.className = 'chaos-pop';
159
+ pop.innerHTML = items.map((o) => `<div class="chaos-pop-opt${o.active ? ' active' : ''}" data-v="${esc(String(o.v))}">${o.html}</div>`).join('');
160
+ document.body.appendChild(pop);
161
+ const r = anchor.getBoundingClientRect();
162
+ const margin = 10;
163
+ const below = innerHeight - r.bottom - margin;
164
+ const above = r.top - margin;
165
+ const openUp = below < 200 && above > below;
166
+ pop.style.maxHeight = `${Math.max(120, (openUp ? above : below) - 5)}px`;
167
+ let left = r.left;
168
+ if (left + pop.offsetWidth > innerWidth - 8) left = innerWidth - pop.offsetWidth - 8;
169
+ const top = openUp ? (r.top - pop.offsetHeight - 5) : (r.bottom + 5);
170
+ pop.style.left = `${Math.max(8, left)}px`; pop.style.top = `${Math.max(8, top)}px`;
171
+ const close = () => { pop.remove(); document.removeEventListener('mousedown', out); };
172
+ function out(e) { if (!pop.contains(e.target) && e.target !== anchor) close(); }
173
+ pop.querySelectorAll('.chaos-pop-opt').forEach((el) => { el.onclick = () => { onPick(el.dataset.v); close(); }; });
174
+ setTimeout(() => document.addEventListener('mousedown', out), 0);
175
+ }
176
+
177
+ function chaosAddImages(files) {
178
+ for (const f of files) { if (f && f.type.startsWith('image/')) chaosPending.push({ blob: f, url: URL.createObjectURL(f) }); }
179
+ renderChaosPending();
180
+ }
181
+ function onChaosPaste(e) {
182
+ const imgs = [...(e.clipboardData?.items || [])].filter((i) => i.type.startsWith('image/'));
183
+ if (!imgs.length) return;
184
+ e.preventDefault();
185
+ chaosAddImages(imgs.map((it) => it.getAsFile()).filter(Boolean));
186
+ }
187
+ function renderChaosPending() {
188
+ const box = $('chaos-pending');
189
+ if (!box) return;
190
+ if (!chaosPending.length) { box.innerHTML = ''; box.classList.add('hidden'); return; }
191
+ box.classList.remove('hidden');
192
+ box.innerHTML = chaosPending.map((p, i) =>
193
+ `<div class="attach"><img src="${p.url}" alt="${tr('screenshot')}"><span class="attach-x" data-i="${i}" title="${tr('remove')}">×</span></div>`).join('');
194
+ box.querySelectorAll('.attach img').forEach((img) => { img.onclick = () => openLightbox(img.getAttribute('src')); });
195
+ box.querySelectorAll('.attach-x').forEach((x) => { x.onclick = () => { URL.revokeObjectURL(chaosPending[Number(x.dataset.i)].url); chaosPending.splice(Number(x.dataset.i), 1); renderChaosPending(); }; });
196
+ }
197
+
198
+ let ncListAt = 0;
199
+ async function renderChaosNoclaudeList(force = false) {
200
+ const box = $('chaos-nc-list');
201
+ if (!box) return;
202
+ if (!force && Date.now() - ncListAt < 2000) return;
203
+ ncListAt = Date.now();
204
+ let tasks = [];
205
+ try { tasks = await api('GET', '/api/tasks?all=1'); } catch { box.innerHTML = '<div class="muted">—</div>'; return; }
206
+ const rows = tasks
207
+ .filter((t) => (t.labels || []).includes('noclaude') && ['backlog', 'todo'].includes(t.status))
208
+ .sort((a, b) => b.priority - a.priority || a.status.localeCompare(b.status));
209
+ const projName = (slug) => state.projects.find((p) => p.slug === slug)?.name || slug;
210
+ box.innerHTML = rows.length
211
+ ? rows.map((t) => `<div class="chaos-nc-row" data-key="${t.key}"><span class="dot s-${t.status}"></span>`
212
+ + `<span class="cnc-key">${t.key}</span><span class="cnc-title">${titleOr(t)}</span>`
213
+ + `<span class="cnc-proj">${esc(projName(t.project))}</span></div>`).join('')
214
+ : `<div class="muted">${tr('no manual tasks in backlog/to do yet')}</div>`;
215
+ box.querySelectorAll('.chaos-nc-row').forEach((el) => { el.onclick = () => openDrawer(el.dataset.key); });
216
+ }
217
+
218
+ async function chaosCreate() {
219
+ const input = $('chaos-input');
220
+ const text = input.value.trim();
221
+ const project = selVal($('chaos-project'));
222
+ const hint = $('chaos-hint');
223
+ if (!project) { hint.textContent = tr('pick a project first'); hint.className = 'chaos-hint err'; return; }
224
+ if (!text && !chaosPending.length) { input.focus(); return; }
225
+ const labels = [...new Set([...(chaosLabel ? [chaosLabel] : []), ...(chaosNoclaude ? ['noclaude'] : [])])];
226
+ const body = { project, title: text, status: chaosStatus, priority: chaosPriority, labels };
227
+ $('chaos-create').disabled = true;
228
+ try {
229
+ const t = await api('POST', '/api/tasks', body);
230
+ for (const p of chaosPending) { try { await uploadAttachment(p.blob, t.key); } catch { } URL.revokeObjectURL(p.url); }
231
+ chaosPending = []; renderChaosPending();
232
+ localStorage.setItem('kb.chaos.project', project);
233
+ input.value = ''; autoGrow(input);
234
+ if (!chaosLock) {
235
+ chaosStatus = 'backlog'; chaosPriority = 0; chaosLabel = ''; chaosNoclaude = false;
236
+ refreshChaosStatusIc(); refreshChaosPriIc(); refreshChaosLabelIc(); refreshChaosNoclaudeIc();
237
+ }
238
+ hint.textContent = `${tr('created')}: ${t.key}`; hint.className = 'chaos-hint ok';
239
+ clearTimeout(chaosHintTimer);
240
+ chaosHintTimer = setTimeout(() => { hint.textContent = ''; hint.className = 'chaos-hint'; }, 3000);
241
+ loadProjects();
242
+ renderChaosNoclaudeList(true);
243
+ input.focus();
244
+ } catch (e) {
245
+ hint.textContent = e && e.message ? String(e.message) : tr('could not create it'); hint.className = 'chaos-hint err';
246
+ } finally { $('chaos-create').disabled = false; }
247
+ }