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,640 @@
1
+
2
+ import { renderBoard } from './board.js';
3
+ import { renderChaos } from './chaos.js';
4
+ import { $, ALL, CALENDAR, CHAOS, DASH, HIDDEN_SECTIONS, HORIZON, LANG, SETTINGS, SIDEBAR_SECTIONS, api, esc, ic, seg, setupPrompt, state, tr } from './core.js';
5
+ import { renderDashboard } from './dash.js';
6
+ import { renderCalendar, renderHorizon } from './horizon.js';
7
+ import { openProjectSettings, renderProjectSettings } from './project.js';
8
+ import { openSettingsModal, plural, setSetting, syncLangToServer } from './settings.js';
9
+ import { copyText, refresh, showView } from './sse.js';
10
+
11
+ export async function loadProjects() {
12
+ state.projects = await api('GET', '/api/projects');
13
+ try { state.folders = await api('GET', '/api/projects/folders'); } catch { }
14
+ try { state.categories = await api('GET', '/api/categories'); } catch { }
15
+ const openable = [ALL, DASH, SETTINGS, HORIZON, CALENDAR, CHAOS].filter((s) => !HIDDEN_SECTIONS.includes(s));
16
+ if (!state.slug || (!openable.includes(state.slug) && !state.projects.find((p) => p.slug === state.slug))) {
17
+ state.slug = state.projects[0]?.slug || DASH;
18
+ }
19
+ renderSidebar();
20
+ renderTopbar();
21
+ }
22
+
23
+ export async function loadTasks() {
24
+ if (state.slug === SETTINGS) state.slug = DASH;
25
+ if (state.slug === DASH) { showView('dashboard'); await renderDashboard(); return; }
26
+ if (state.slug === HORIZON) { showView('horizon'); await renderHorizon(); return; }
27
+ if (state.slug === CALENDAR) { showView('calendar'); await renderCalendar(); return; }
28
+ if (state.slug === CHAOS) { const opening = showView('chaos'); await renderChaos(opening); return; }
29
+ if (state.projSettings && state.projSettings === state.slug) {
30
+ const opening = showView('projset');
31
+ await renderProjectSettings(opening);
32
+ return;
33
+ }
34
+ showView('board');
35
+ if (!state.slug) { state.tasks = []; renderBoard(); return; }
36
+ try {
37
+ const q = state.search ? `&q=${encodeURIComponent(state.search)}` : '';
38
+ state.tasks = state.slug === ALL
39
+ ? await api('GET', `/api/tasks?all=1${q}`)
40
+ : await api('GET', `/api/tasks?project=${encodeURIComponent(state.slug)}&all=1${q}`);
41
+ state.searchApplied = state.search;
42
+ } catch {
43
+ state.slug = null;
44
+ state.tasks = [];
45
+ renderBoard();
46
+ return;
47
+ }
48
+ renderBoard();
49
+ }
50
+
51
+ const PIN_ICON = ic('pin', 12);
52
+
53
+ export function renderSidebar() {
54
+ const box = $('projects');
55
+ box.innerHTML = '';
56
+
57
+ const dashEl = document.createElement('div');
58
+ dashEl.className = 'proj proj-dash' + (state.slug === DASH ? ' active' : '');
59
+ dashEl.innerHTML = `<span class="proj-name">${tr('Dashboard')}</span>`;
60
+ dashEl.dataset.slug = DASH;
61
+ dashEl.onclick = () => selectProject(DASH);
62
+ box.appendChild(dashEl);
63
+
64
+ for (const [slug, name] of SIDEBAR_SECTIONS) {
65
+ const el = document.createElement('div');
66
+ el.className = 'proj proj-section' + (state.slug === slug ? ' active' : '');
67
+ el.innerHTML = `<span class="proj-name">${name}</span>`;
68
+ el.dataset.slug = slug;
69
+ el.onclick = () => selectProject(slug);
70
+ box.appendChild(el);
71
+ }
72
+
73
+ const totalActive = state.projects.reduce((s, p) => s + (p.c_todo || 0) + (p.c_prep || 0) + (p.c_doing || 0) + (p.c_deploy || 0) + (p.c_review || 0), 0);
74
+ const allEl = document.createElement('div');
75
+ allEl.className = 'proj proj-all' + (state.slug === ALL ? ' active' : '');
76
+ allEl.innerHTML = `<span class="proj-name">${tr('All projects')}</span>
77
+ <span class="proj-counts">${totalActive || ''}</span>`;
78
+ allEl.dataset.slug = ALL;
79
+ allEl.onclick = () => selectProject(ALL);
80
+ box.appendChild(allEl);
81
+
82
+ const setEl = document.createElement('div');
83
+ setEl.className = 'proj proj-settings';
84
+ setEl.innerHTML = `<span class="proj-name">${ic('gear', 13)} ${tr('Settings')}</span>`;
85
+ setEl.onclick = openSettingsModal;
86
+ box.appendChild(setEl);
87
+
88
+ const missingSlugs = new Set((state.folders.missing || []).map((m) => m.slug));
89
+
90
+ const pinnedList = state.projects.filter((p) => p.pinned);
91
+ if (pinnedList.length) {
92
+ const h = document.createElement('div');
93
+ h.className = 'cat-head';
94
+ h.innerHTML = `<span>${tr('Pinned')}</span>`;
95
+ box.appendChild(h);
96
+ for (const p of pinnedList) {
97
+ const el = makeProjEl(p, missingSlugs.has(p.slug));
98
+ el.dataset.category = '__pinned__';
99
+ box.appendChild(el);
100
+ }
101
+ }
102
+
103
+ const byCat = new Map();
104
+ for (const p of state.projects) {
105
+ const cat = p.category || 'Other';
106
+ if (!byCat.has(cat)) byCat.set(cat, []);
107
+ byCat.get(cat).push(p);
108
+ }
109
+
110
+ for (const [cat, list] of byCat) {
111
+ list.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
112
+ const h = document.createElement('div');
113
+ h.className = 'cat-head';
114
+ h.innerHTML = `<span>${esc(tr(cat))}</span>`;
115
+ if (cat === 'Local') {
116
+ const add = document.createElement('button');
117
+ add.className = 'btn-icon cat-add';
118
+ add.textContent = '+';
119
+ add.title = tr('Create a project folder');
120
+ add.onclick = (e) => { e.stopPropagation(); createFolder(); };
121
+ h.appendChild(add);
122
+ }
123
+ box.appendChild(h);
124
+ for (const p of list) box.appendChild(makeProjEl(p, missingSlugs.has(p.slug)));
125
+ if (cat === 'Local') {
126
+ for (const folder of (state.folders.unregistered || [])) {
127
+ const el = document.createElement('div');
128
+ el.className = 'proj proj-ghost';
129
+ el.innerHTML = `<span class="proj-name" title="${tr('new folder')} ${esc(folder)} — ${tr('not on the board yet')}">${esc(folder)}</span>
130
+ <span class="ghost-add" title="${tr('Add to the board')}">+ ${tr('add')}</span>`;
131
+ el.querySelector('.ghost-add').onclick = (e) => { e.stopPropagation(); adoptFolder(folder); };
132
+ box.appendChild(el);
133
+ }
134
+ }
135
+ }
136
+
137
+ const addProj = document.createElement('div');
138
+ addProj.className = 'proj proj-add';
139
+ addProj.innerHTML = `<span class="proj-name">${tr('+ Add project')}</span>`;
140
+ addProj.onclick = openProjectWizard;
141
+ box.appendChild(addProj);
142
+ }
143
+
144
+ function makeProjEl(p, missing) {
145
+ const el = document.createElement('div');
146
+ el.className = 'proj' + (p.slug === state.slug ? ' active' : '');
147
+ const active = (p.c_todo || 0) + (p.c_prep || 0) + (p.c_doing || 0) + (p.c_deploy || 0) + (p.c_review || 0);
148
+ const nrev = p.c_review || 0;
149
+ el.innerHTML = `<span class="proj-name" title="${esc(p.slug)}">${esc(p.name || p.slug)}</span>
150
+ ${missing ? `<span class="proj-missing" title="${esc(tr('folder is gone — click to archive'))}">${ic('warn', 12)}</span>` : ''}
151
+ ${nrev ? `<span class="proj-review" title="${nrev} ${esc(tr('in review — waiting for you'))}"></span>` : ''}
152
+ <span class="proj-counts">${active || ''}</span>
153
+ <span class="proj-pin${p.pinned ? ' on' : ''}" title="${p.pinned ? tr('Unpin') : tr('Pin to the top')}">${PIN_ICON}</span>`;
154
+ el.dataset.slug = p.slug;
155
+ el.dataset.category = p.category || 'Other';
156
+ el.onclick = () => selectProject(p.slug);
157
+ el.oncontextmenu = (e) => { e.preventDefault(); openProjectSettings(p); };
158
+ if (missing) el.querySelector('.proj-missing').onclick = (e) => { e.stopPropagation(); archiveMissing(p.slug, p.name || p.slug); };
159
+ el.querySelector('.proj-pin').onclick = (e) => { e.stopPropagation(); togglePin(p); };
160
+ attachProjDnd(el);
161
+ return el;
162
+ }
163
+
164
+ let dragProjSlug = null;
165
+ function attachProjDnd(el) {
166
+ el.draggable = true;
167
+ el.addEventListener('dragstart', (e) => {
168
+ dragProjSlug = el.dataset.slug;
169
+ el.classList.add('proj-dragging');
170
+ e.dataTransfer.effectAllowed = 'move';
171
+ e.dataTransfer.setData('text/plain', el.dataset.slug);
172
+ });
173
+ el.addEventListener('dragend', () => {
174
+ dragProjSlug = null;
175
+ document.querySelectorAll('.proj-dragging, .proj-drop-before, .proj-drop-after')
176
+ .forEach((x) => x.classList.remove('proj-dragging', 'proj-drop-before', 'proj-drop-after'));
177
+ });
178
+ el.addEventListener('dragover', (e) => {
179
+ if (!dragProjSlug || dragProjSlug === el.dataset.slug) return;
180
+ const src = document.querySelector(`.proj[data-slug="${CSS.escape(dragProjSlug)}"]`);
181
+ if (!src || src.dataset.category !== el.dataset.category) return;
182
+ e.preventDefault();
183
+ e.dataTransfer.dropEffect = 'move';
184
+ const after = (e.clientY - el.getBoundingClientRect().top) > el.offsetHeight / 2;
185
+ el.classList.toggle('proj-drop-after', after);
186
+ el.classList.toggle('proj-drop-before', !after);
187
+ });
188
+ el.addEventListener('dragleave', () => el.classList.remove('proj-drop-before', 'proj-drop-after'));
189
+ el.addEventListener('drop', async (e) => {
190
+ if (!dragProjSlug || dragProjSlug === el.dataset.slug) return;
191
+ const src = document.querySelector(`.proj[data-slug="${CSS.escape(dragProjSlug)}"]`);
192
+ if (!src || src.dataset.category !== el.dataset.category) return;
193
+ e.preventDefault();
194
+ const after = el.classList.contains('proj-drop-after');
195
+ el.classList.remove('proj-drop-before', 'proj-drop-after');
196
+ el.parentNode.insertBefore(src, after ? el.nextSibling : el);
197
+ const slugs = [...document.querySelectorAll(`.proj[data-category="${CSS.escape(el.dataset.category)}"]`)]
198
+ .map((x) => x.dataset.slug);
199
+ await api('POST', '/api/projects/reorder', { slugs });
200
+ await refresh();
201
+ });
202
+ }
203
+
204
+ export function selectProject(slug, { settings = false } = {}) {
205
+ state.slug = slug;
206
+ state.projSettings = settings ? slug : null;
207
+ localStorage.setItem('kb.ui.project', slug);
208
+ sessionStorage.setItem('kb.session.slug', slug);
209
+ if (slug && slug !== ALL && !slug.startsWith('#')) localStorage.setItem('kb.ui.lastProject', slug);
210
+ renderSidebar(); renderTopbar(); loadTasks();
211
+ }
212
+
213
+ export function switchProject(dir) {
214
+ const slugs = [...new Set([...document.querySelectorAll('#projects .proj[data-slug]')].map((el) => el.dataset.slug))];
215
+ if (!slugs.length) return;
216
+ let i = slugs.indexOf(state.slug);
217
+ i = i < 0 ? 0 : Math.max(0, Math.min(slugs.length - 1, i + dir));
218
+ if (slugs[i] !== state.slug) selectProject(slugs[i]);
219
+ }
220
+
221
+ async function togglePin(p) {
222
+ await api('PATCH', `/api/projects/${seg(p.slug)}`, { pinned: p.pinned ? 0 : 1 });
223
+ await refresh();
224
+ }
225
+
226
+ const openLayers = [];
227
+ export function pushLayer(key, close) {
228
+ popLayer(key);
229
+ openLayers.push({ key, close });
230
+ }
231
+ export function popLayer(key) {
232
+ const i = openLayers.findIndex((l) => l.key === key);
233
+ if (i >= 0) openLayers.splice(i, 1);
234
+ }
235
+ export function closeTopLayer() {
236
+ const top = openLayers.pop();
237
+ if (!top) return false;
238
+ top.close();
239
+ return true;
240
+ }
241
+ export function overlayLayer(overlay, { onClose, backdrop = true } = {}) {
242
+ const close = () => {
243
+ document.removeEventListener('keydown', onKey, true);
244
+ popLayer(overlay);
245
+ overlay.remove();
246
+ onClose?.();
247
+ };
248
+ const onKey = (e) => { if (e.key === 'Escape') { e.stopImmediatePropagation(); close(); } };
249
+ document.addEventListener('keydown', onKey, true);
250
+ if (backdrop) overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
251
+ pushLayer(overlay, close);
252
+ return close;
253
+ }
254
+
255
+ export function styledPrompt(title, { placeholder = '', okLabel = 'OK', onSubmit } = {}) {
256
+ const overlay = document.createElement('div');
257
+ overlay.className = 'modal-overlay';
258
+ overlay.innerHTML = `
259
+ <div class="modal">
260
+ <div class="modal-title">${esc(title)}</div>
261
+ <input class="modal-input" type="text" placeholder="${esc(placeholder)}">
262
+ <div class="modal-err hidden"></div>
263
+ <div class="modal-actions">
264
+ <button class="btn-primary modal-ok">${esc(okLabel)}</button>
265
+ <button class="btn-ghost modal-cancel">${tr('Cancel')}</button>
266
+ </div>
267
+ </div>`;
268
+ document.body.appendChild(overlay);
269
+ const input = overlay.querySelector('.modal-input');
270
+ const errEl = overlay.querySelector('.modal-err');
271
+ const okBtn = overlay.querySelector('.modal-ok');
272
+ const close = overlayLayer(overlay);
273
+ const submit = async () => {
274
+ const v = input.value.trim();
275
+ if (!v) return;
276
+ okBtn.disabled = true;
277
+ try { await onSubmit(v); close(); }
278
+ catch (e) { errEl.textContent = e.message; errEl.classList.remove('hidden'); okBtn.disabled = false; }
279
+ };
280
+ okBtn.onclick = submit;
281
+ overlay.querySelector('.modal-cancel').onclick = close;
282
+ input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
283
+ input.focus();
284
+ }
285
+
286
+ export function styledConfirm(message, { okLabel = 'OK', danger = false, noCancel = false } = {}) {
287
+ return new Promise((resolve) => {
288
+ const overlay = document.createElement('div');
289
+ overlay.className = 'modal-overlay';
290
+ overlay.innerHTML = `
291
+ <div class="modal">
292
+ <div class="modal-title">${esc(message)}</div>
293
+ <div class="modal-actions">
294
+ <button class="btn-primary modal-ok${danger ? ' danger' : ''}">${esc(okLabel)}</button>
295
+ ${noCancel ? '' : `<button class="btn-ghost modal-cancel">${tr('Cancel')}</button>`}
296
+ </div>
297
+ </div>`;
298
+ document.body.appendChild(overlay);
299
+ const done = (v) => { popLayer(overlay); overlay.remove(); document.removeEventListener('keydown', onKey, true); resolve(v); };
300
+ pushLayer(overlay, () => done(false));
301
+ const onKey = (e) => {
302
+ if (e.key === 'Escape') { e.stopImmediatePropagation(); done(false); }
303
+ else if (e.key === 'Enter') { e.stopImmediatePropagation(); done(true); }
304
+ };
305
+ overlay.querySelector('.modal-ok').onclick = () => done(true);
306
+ overlay.querySelector('.modal-cancel')?.addEventListener('click', () => done(false));
307
+ overlay.onclick = (e) => { if (e.target === overlay) done(false); };
308
+ document.addEventListener('keydown', onKey, true);
309
+ });
310
+ }
311
+
312
+ export const styledAlert = (message) => styledConfirm(message, { okLabel: tr('Got it'), noCancel: true });
313
+
314
+ function createFolder() {
315
+ styledPrompt(tr('New project folder'), {
316
+ placeholder: tr('name (created in ~/claude-projects)'),
317
+ okLabel: tr('Create'),
318
+ onSubmit: async (name) => { await api('POST', '/api/projects/folders', { name }); await refresh(); },
319
+ });
320
+ }
321
+ async function adoptFolder(name) {
322
+ try { await api('POST', '/api/projects/folders', { name }); }
323
+ catch (e) { styledAlert(e.message); return; }
324
+ await refresh();
325
+ }
326
+ async function archiveMissing(slug, name) {
327
+ if (!await styledConfirm(`${tr('The project folder')} "${name}" ${tr('is gone from disk. Archive the project? Tasks are kept.')}`, { okLabel: tr('Archive') })) return;
328
+ await api('PATCH', `/api/projects/${seg(slug)}`, { archived: 1 });
329
+ if (state.slug === slug) state.slug = null;
330
+ await refresh();
331
+ }
332
+
333
+ export function currentProject() { return state.projects.find((x) => x.slug === state.slug); }
334
+
335
+ const WIZ_TYPES = [
336
+ ['local', tr('Local project'), tr('a folder on this computer — or just a task board without one')],
337
+ ['localserver', tr('Local + server'), tr('local copy of the code + deploy to a server over ssh')],
338
+ ['server', tr('Server only'), tr('no local copy — Claude works over ssh; git required on the server')],
339
+ ];
340
+
341
+ function wizSlugify(name) {
342
+ const ru = { а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'i', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'c', ч: 'ch', ш: 'sh', щ: 'sch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya' };
343
+ return name.toLowerCase().split('').map((c) => ru[c] ?? c).join('')
344
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
345
+ }
346
+
347
+ function openProjectWizard() {
348
+ document.getElementById('proj-wizard')?.remove();
349
+ const overlay = document.createElement('div');
350
+ overlay.className = 'modal-overlay';
351
+ overlay.id = 'proj-wizard';
352
+ overlay.innerHTML = `
353
+ <div class="modal wiz">
354
+ <div class="modal-title">${tr('Add project')}</div>
355
+ <div class="wiz-body"></div>
356
+ <div class="modal-err hidden"></div>
357
+ <div class="modal-actions wiz-actions"></div>
358
+ </div>`;
359
+ document.body.appendChild(overlay);
360
+ const body = overlay.querySelector('.wiz-body');
361
+ const actions = overlay.querySelector('.wiz-actions');
362
+ const errEl = overlay.querySelector('.modal-err');
363
+ const close = overlayLayer(overlay);
364
+ const err = (msg) => { errEl.textContent = msg || ''; errEl.classList.toggle('hidden', !msg); };
365
+
366
+ const stepType = () => {
367
+ err('');
368
+ body.innerHTML = WIZ_TYPES.map(([id, title, sub]) =>
369
+ `<button type="button" class="wiz-type" data-type="${id}"><b>${esc(title)}</b><small>${esc(sub)}</small></button>`).join('');
370
+ actions.innerHTML = `<button class="btn-ghost wiz-cancel">${tr('Cancel')}</button>`;
371
+ actions.querySelector('.wiz-cancel').onclick = close;
372
+ body.querySelectorAll('.wiz-type').forEach((b) => { b.onclick = () => stepForm(b.dataset.type); });
373
+ };
374
+
375
+ const stepForm = (type) => {
376
+ err('');
377
+ const withServer = type !== 'local';
378
+ const withLocal = type !== 'server';
379
+ const folders = state.folders.unregistered || [];
380
+ let html = `<label class="pp-field">${tr('Project name')}<input id="wiz-name" type="text" placeholder="my project"></label>`
381
+ + `<label class="pp-field">${tr('Key (slug + task prefix)')}<input id="wiz-slug" type="text" placeholder="my-project"></label>`;
382
+ if (withLocal) {
383
+ html += `<div class="pp-field">${tr('Project key')}
384
+ <select id="wiz-folder" class="wiz-select">
385
+ ${type === 'local' ? `<option value="">${tr('no folder — just a task list')}</option>` : ''}
386
+ ${folders.map((f) => `<option value="${esc(f)}">${tr('folder')} ${esc(f)} (${esc(state.folders.root || '~')})</option>`).join('')}
387
+ <option value="__manual__">${tr('enter the folder path…')}</option>
388
+ ${type === 'localserver' ? `<option value="__clone__">${tr('clone from a git URL…')}</option>` : ''}
389
+ </select>
390
+ <input id="wiz-path" type="text" class="hidden" placeholder="/full/path/to/the/folder">
391
+ <input id="wiz-git" type="text" class="hidden" placeholder="git@github.com:me/repo.git or https://…"></div>`;
392
+ }
393
+ if (withServer) {
394
+ html += `<label class="pp-field">${tr('SSH host')}<input id="wiz-server" type="text" placeholder="host for ssh (keys in ~/.ssh/config)"></label>`
395
+ + `<label class="pp-field">${tr('Server path')}<input id="wiz-spath" type="text" placeholder="/var/www/my-project"></label>`
396
+ + `<label class="pp-field">${tr('pm2 processes (comma-separated)')}<input id="wiz-pm2" type="text" placeholder="my-api, my-web"></label>`
397
+ + `<label class="pp-field">${tr('Domain (to verify after deploy)')}<input id="wiz-domain" type="text" placeholder="example.com"></label>`
398
+ + `<label class="pp-field">${tr('Custom deploy skill')}<input id="wiz-skill" type="text"></label>`;
399
+ }
400
+ body.innerHTML = html;
401
+ actions.innerHTML = `<button class="btn-primary wiz-create">${tr('Create')}</button><button class="btn-ghost wiz-back">${tr('‹ Back')}</button>`;
402
+ actions.querySelector('.wiz-back').onclick = stepType;
403
+ const nameEl = $('wiz-name');
404
+ const slugEl = $('wiz-slug');
405
+ let slugTouched = false;
406
+ slugEl.oninput = () => { slugTouched = true; };
407
+ nameEl.oninput = () => { if (!slugTouched) slugEl.value = wizSlugify(nameEl.value); };
408
+ const folderSel = $('wiz-folder');
409
+ if (folderSel) {
410
+ folderSel.onchange = () => {
411
+ $('wiz-path').classList.toggle('hidden', folderSel.value !== '__manual__');
412
+ $('wiz-git').classList.toggle('hidden', folderSel.value !== '__clone__');
413
+ };
414
+ }
415
+ actions.querySelector('.wiz-create').onclick = () => wizCreate(type).catch((e) => err(e.message));
416
+ nameEl.focus();
417
+ };
418
+
419
+ const wizCreate = async (type) => {
420
+ err('');
421
+ const btn = actions.querySelector('.wiz-create');
422
+ const name = $('wiz-name').value.trim();
423
+ const slug = ($('wiz-slug').value.trim() || wizSlugify(name));
424
+ if (!name) throw new Error(tr('the project needs a name'));
425
+ if (!/^[a-z0-9-]{2,40}$/.test(slug)) throw new Error(tr('key: lowercase latin letters, digits and hyphens'));
426
+ const body = { slug, name };
427
+ const withServer = type !== 'server' ? type === 'localserver' : true;
428
+ btn.disabled = true;
429
+ try {
430
+ if (type !== 'server') {
431
+ const mode = $('wiz-folder')?.value ?? '';
432
+ if (mode === '__manual__') {
433
+ const p = $('wiz-path').value.trim();
434
+ if (!p) throw new Error(tr('give the folder path'));
435
+ body.path = p;
436
+ } else if (mode === '__clone__') {
437
+ const url = $('wiz-git').value.trim();
438
+ if (!url) throw new Error(tr('give the git URL'));
439
+ btn.textContent = tr('cloning…');
440
+ const r = await api('POST', '/api/projects/clone', { url, name: slug });
441
+ body.path = r.path;
442
+ } else if (mode) {
443
+ body.path = `${state.folders.root}/${mode}`;
444
+ }
445
+ if (type === 'local') body.category = 'Local';
446
+ }
447
+ if (withServer) {
448
+ const server = $('wiz-server').value.trim();
449
+ const spath = $('wiz-spath').value.trim();
450
+ if (type === 'server' && (!server || !spath)) throw new Error(tr('a server project needs an SSH host and a path'));
451
+ if (server) body.server = server;
452
+ if (spath) body.server_path = spath;
453
+ const pm2 = $('wiz-pm2').value.split(',').map((s) => s.trim()).filter(Boolean);
454
+ if (pm2.length) body.pm2_services = JSON.stringify(pm2);
455
+ const domain = $('wiz-domain').value.trim();
456
+ if (domain) body.domain = domain;
457
+ body.deploy_skill = $('wiz-skill').value.trim() || 'deploy';
458
+ }
459
+ if (type === 'server') {
460
+ btn.textContent = tr('checking git on the server…');
461
+ const chk = await api('POST', '/api/projects/server-git', { server: body.server, path: body.server_path });
462
+ if (!chk.git) {
463
+ btn.textContent = tr('Create');
464
+ const ok = await styledConfirm(`${tr('No git repository on the server at')} ${body.server_path}. ${tr('Run git init? Without git a server project cannot be created.')}`, { okLabel: 'git init' });
465
+ if (!ok) throw new Error(tr('no git on the server — the project was not created'));
466
+ btn.disabled = true; btn.textContent = 'git init…';
467
+ await api('POST', '/api/projects/server-git', { server: body.server, path: body.server_path, init: true });
468
+ }
469
+ }
470
+ btn.textContent = tr('creating…');
471
+ await api('POST', '/api/projects', body);
472
+ close();
473
+ await refresh();
474
+ selectProject(slug);
475
+ } finally { btn.disabled = false; btn.textContent = tr('Create'); }
476
+ };
477
+
478
+ stepType();
479
+ }
480
+
481
+ export async function maybeOnboarding() {
482
+ try {
483
+ const st = await api('GET', '/api/onboarding');
484
+ if (st.done) return;
485
+ if (state.projects.length) { api('POST', '/api/onboarding', { done: true }); return; }
486
+ openOnboarding();
487
+ } catch { }
488
+ }
489
+
490
+ function openOnboarding() {
491
+ document.getElementById('onboarding')?.remove();
492
+ const overlay = document.createElement('div');
493
+ overlay.className = 'modal-overlay';
494
+ overlay.id = 'onboarding';
495
+ overlay.innerHTML = `<div class="modal wiz onb">
496
+ <div class="onb-steps"></div>
497
+ <div class="modal-title"></div>
498
+ <div class="wiz-body"></div>
499
+ <div class="modal-err hidden"></div>
500
+ <div class="modal-actions wiz-actions"></div>
501
+ </div>`;
502
+ document.body.appendChild(overlay);
503
+ const title = overlay.querySelector('.modal-title');
504
+ const body = overlay.querySelector('.wiz-body');
505
+ const actions = overlay.querySelector('.wiz-actions');
506
+ const stepsEl = overlay.querySelector('.onb-steps');
507
+ const errEl = overlay.querySelector('.modal-err');
508
+ const err = (msg) => { errEl.textContent = msg || ''; errEl.classList.toggle('hidden', !msg); };
509
+ const finish = (goDemo) => {
510
+ api('POST', '/api/onboarding', { done: true }).catch(() => {});
511
+ overlay.remove();
512
+ refresh().then(() => { if (goDemo) selectProject('demo'); });
513
+ };
514
+ const STEPS = ['lang', 'sync', 'demo', 'done'];
515
+ let demoCreated = false;
516
+ const dots = (cur) => { stepsEl.innerHTML = STEPS.map((x) => `<span class="onb-dot${x === cur ? ' on' : ''}"></span>`).join(''); };
517
+
518
+ const stepLang = () => {
519
+ dots('lang'); err('');
520
+ title.textContent = tr('Welcome!');
521
+ body.innerHTML = `<div class="muted">${tr('This is a local kanban board built to work in tandem with Claude Code. A couple of steps and you are ready.')}</div>
522
+ <div class="onb-langs">
523
+ <button type="button" class="wiz-type${LANG === 'ru' ? ' active' : ''}" data-lang="ru"><b>Русский</b></button>
524
+ <button type="button" class="wiz-type${LANG === 'en' ? ' active' : ''}" data-lang="en"><b>English</b></button>
525
+ </div>`;
526
+ actions.innerHTML = '';
527
+ body.querySelectorAll('[data-lang]').forEach((b) => {
528
+ b.onclick = async () => {
529
+ setSetting('lang', b.dataset.lang);
530
+ await syncLangToServer(b.dataset.lang);
531
+ if (b.dataset.lang !== LANG) { location.reload(); return; }
532
+ stepSync();
533
+ };
534
+ });
535
+ };
536
+
537
+ const stepSync = async () => {
538
+ dots('sync'); err('');
539
+ title.textContent = tr('GitHub sync (optional)');
540
+ body.innerHTML = `<div class="muted">${tr('Every task can be mirrored to an issue and every project to a GitHub Project. Requires the gh CLI authenticated with the project scope (gh auth login && gh auth refresh -s project). You can also enable this later in Settings.')}</div>
541
+ <div class="sync-cfg-row"><input type="text" id="onb-owner" placeholder="${tr('owner (GitHub user)')}"><input type="text" id="onb-repo" placeholder="${tr('owner/repo for issues')}"></div>
542
+ <div class="muted" id="onb-gh-state"></div>`;
543
+ actions.innerHTML = `<button class="btn-primary" id="onb-sync-on">${tr('Enable sync')}</button>
544
+ <button class="btn-ghost" id="onb-sync-skip">${tr('Skip — work locally')}</button>`;
545
+ api('GET', '/api/sync').then((st) => {
546
+ const el = $('onb-gh-state');
547
+ if (el) el.textContent = st.gh === false ? tr('gh CLI missing or not authenticated — you can enable sync later') : tr('gh CLI found and authenticated ✓');
548
+ }).catch(() => {});
549
+ $('onb-sync-skip').onclick = () => stepDemo();
550
+ $('onb-sync-on').onclick = async () => {
551
+ try {
552
+ await api('POST', '/api/sync/config', { owner: $('onb-owner').value, repo: $('onb-repo').value });
553
+ stepDemo();
554
+ } catch (e) { err(e.message); }
555
+ };
556
+ };
557
+
558
+ const stepDemo = () => {
559
+ dots('demo'); err('');
560
+ title.textContent = tr('Demo project');
561
+ body.innerHTML = `<div class="muted">${tr('Create an example project with tasks in every column? It shows links, checklists and labels, and can be deleted with one click in the project settings.')}</div>`;
562
+ actions.innerHTML = `<button class="btn-primary" id="onb-demo-yes">${tr('Create demo')}</button>
563
+ <button class="btn-ghost" id="onb-demo-no">${tr('Skip')}</button>`;
564
+ $('onb-demo-no').onclick = () => stepDone();
565
+ $('onb-demo-yes').onclick = async () => {
566
+ try { await api('POST', '/api/projects/demo', { lang: LANG }, { quiet: true }); demoCreated = true; } catch { demoCreated = true; }
567
+ stepDone();
568
+ };
569
+ };
570
+
571
+ const stepDone = async () => {
572
+ dots('done'); err('');
573
+ title.textContent = tr('All set!');
574
+ let packaged = false;
575
+ try { packaged = !!(await api('GET', '/api/about', null, { quiet: true })).packaged; } catch { }
576
+ body.innerHTML = `<div class="muted">${tr('Claude can finish the setup for you — copy this and paste it into its chat:')}</div>`
577
+ + setupPromptBlockHTML()
578
+ + manualSetupHTML(packaged);
579
+ wireSetupPrompt(body);
580
+ actions.innerHTML = `<button class="btn-primary" id="onb-finish">${tr('Start working')}</button>`;
581
+ $('onb-finish').onclick = () => finish(demoCreated);
582
+ };
583
+
584
+ if (localStorage.getItem('kb.set.lang') !== null) stepSync(); else stepLang();
585
+ }
586
+
587
+ export function renderTopbar() {
588
+ $('project-menu').classList.toggle('hidden', [DASH, SETTINGS, HORIZON, CALENDAR, CHAOS].includes(state.slug));
589
+ $('project-menu').classList.toggle('on', Boolean(state.projSettings));
590
+ if (state.slug === DASH) {
591
+ $('project-title').innerHTML = `${ic('chart', 15)} ${esc(tr('Dashboard'))}`;
592
+ $('project-meta').textContent = '';
593
+ return;
594
+ }
595
+ if (state.slug === HORIZON) { $('project-title').textContent = tr('Horizon'); $('project-meta').textContent = tr('goals by time horizon'); return; }
596
+ if (state.slug === CALENDAR) { $('project-title').textContent = tr('Calendar'); $('project-meta').textContent = ''; return; }
597
+ if (state.slug === CHAOS) { $('project-title').textContent = tr('Chaos'); $('project-meta').textContent = tr('quick task capture'); return; }
598
+ if (state.slug === SETTINGS) {
599
+ $('project-title').innerHTML = `${ic('gear', 15)} ${esc(tr('Settings'))}`;
600
+ $('project-meta').textContent = '';
601
+ return;
602
+ }
603
+ if (state.slug === ALL) {
604
+ $('project-title').textContent = tr('All projects');
605
+ $('project-meta').textContent = `${state.projects.length} ${plural(state.projects.length, 'project', 'projects')}`;
606
+ return;
607
+ }
608
+ const p = currentProject();
609
+ $('project-title').textContent = p ? (p.name || p.slug) : '—';
610
+ $('project-meta').textContent = p ? [p.prefix, p.name !== p.slug ? p.slug : '', p.domain].filter(Boolean).join(' · ') : '';
611
+ }
612
+
613
+ export function setupPromptBlockHTML() {
614
+ return `<div class="setup-prompt"><pre class="onb-code setup-prompt-text">${esc(setupPrompt())}</pre>`
615
+ + `<button class="btn-primary setup-prompt-copy">${ic('copy', 13)} ${tr('Copy the prompt')}</button></div>`;
616
+ }
617
+ export function manualSetupHTML(packaged) {
618
+ if (packaged) {
619
+ return `<div class="muted onb-manual-h">${tr('Or do it by hand. Everything is already installed with the package:')}</div>`
620
+ + `<div class="muted">${tr('the kb command came with it, and the wizard copied the Claude Code skills into ~/.claude/skills. To update later:')}</div>`
621
+ + '<pre class="onb-code">npm i -g local-kanban@latest</pre>'
622
+ + `<div class="muted">${tr('Details are in the README. Enjoy!')}</div>`;
623
+ }
624
+ return `<div class="muted onb-manual-h">${tr('Or do it by hand. Install the kb command for Claude like this:')}</div>
625
+ <pre class="onb-code">npm link</pre>
626
+ <div class="muted">${tr('Claude Code skills live in skills/ — symlink or copy them into ~/.claude/skills:')}</div>
627
+ <pre class="onb-code">ln -s "$(pwd)/skills/kanban" ~/.claude/skills/kanban
628
+ ln -s "$(pwd)/skills/deploy" ~/.claude/skills/deploy</pre>
629
+ <div class="muted">${tr('Details are in the README. Enjoy!')}</div>`;
630
+ }
631
+ export function wireSetupPrompt(root) {
632
+ const btn = root.querySelector('.setup-prompt-copy');
633
+ if (!btn) return;
634
+ btn.onclick = async () => {
635
+ const okCopy = await copyText(setupPrompt());
636
+ const was = btn.innerHTML;
637
+ btn.innerHTML = okCopy ? `✓ ${tr('Copied')}` : `✕ ${tr('did not work — select and copy manually')}`;
638
+ setTimeout(() => { btn.innerHTML = was; }, 1800);
639
+ };
640
+ }