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,386 @@
1
+
2
+ import { patchCard, renderBoard } from './board.js';
3
+ import { $, ALL, ALL_STATUSES, CALENDAR, CHAOS, DASH, HORIZON, LANG, SETTINGS, api, esc, ic, retryUnsaved, setGhSyncOn, state, tr } from './core.js';
4
+ import { openDrawer, renderComments, renderIssueRow, syncOpenDrawerStatus } from './drawer.js';
5
+ import { getSetting } from './settings.js';
6
+ import { loadProjects, loadTasks, renderSidebar, renderTopbar, selectProject } from './sidebar.js';
7
+
8
+ export async function refreshSync() {
9
+ try {
10
+ const st = await api('GET', '/api/stats');
11
+ const wk = st.week_seconds >= 3600
12
+ ? `${Math.floor(st.week_seconds / 3600)}${tr('h')} ${Math.round((st.week_seconds % 3600) / 60)}${tr('m')}`
13
+ : `${Math.max(1, Math.round(st.week_seconds / 60))}${tr('m')}`;
14
+ $('week-stats').textContent = st.week_done ? `${tr('last 7 days:')} ${st.week_done} ✓ · ${wk}` : '';
15
+ const s = await api('GET', '/api/sync');
16
+ setGhSyncOn(s.configured !== false);
17
+ const el = $('sync-badge');
18
+ if (s.configured === false) { el.textContent = ''; el.className = 'sync-badge'; }
19
+ else if (s.failed.length) { el.innerHTML = `${ic('warn', 12)} ${esc(tr('sync'))}: ${s.failed.length} ${esc(tr('errors'))}`; el.className = 'sync-badge error'; }
20
+ else if (s.gh === false && s.pending) { el.textContent = `gh ✗ · ${tr('queued')} ${s.pending}`; el.className = 'sync-badge error'; }
21
+ else if (s.pending) { el.textContent = `${tr('sync')}: ${s.pending} ${tr('queued')}`; el.className = 'sync-badge busy'; }
22
+ else { el.textContent = tr('sync: ok'); el.className = 'sync-badge'; }
23
+ } catch { }
24
+ }
25
+
26
+ export async function refresh() {
27
+ await loadProjects();
28
+ await loadTasks();
29
+ await syncOpenDrawerStatus();
30
+ }
31
+
32
+ const TASK_EVENTS = new Set(['task.created', 'task.updated', 'task.deleted']);
33
+ const SPECIAL_VIEWS = [DASH, HORIZON, CALENDAR, CHAOS, SETTINGS];
34
+
35
+ async function refreshProjectCounters() {
36
+ try { state.projects = await api('GET', '/api/projects'); } catch { return; }
37
+ renderSidebar();
38
+ renderTopbar();
39
+ }
40
+
41
+ async function applySseEvent(ev) {
42
+ if (!TASK_EVENTS.has(ev.type)) return false;
43
+ if (SPECIAL_VIEWS.includes(state.slug)) return false;
44
+ const d = ev.data;
45
+ if (!d || d.id == null) return false;
46
+ const onThisBoard = state.slug === ALL || d.project === state.slug;
47
+ if (onThisBoard) {
48
+ const i = state.tasks.findIndex((t) => t.id === d.id);
49
+ if (ev.type === 'task.deleted') {
50
+ if (i >= 0) state.tasks.splice(i, 1);
51
+ } else if (i >= 0) {
52
+ const before = state.tasks[i];
53
+ const after = { ...before, ...d };
54
+ state.tasks[i] = after;
55
+ if (patchCard(before, after)) { await refreshProjectCounters(); await syncOpenDrawerStatus(); return true; }
56
+ } else if (ev.type === 'task.created') {
57
+ state.tasks.push(d);
58
+ } else {
59
+ return false;
60
+ }
61
+ renderBoard();
62
+ }
63
+ await refreshProjectCounters();
64
+ await syncOpenDrawerStatus();
65
+ return true;
66
+ }
67
+
68
+ /* ── SSE ── */
69
+ export let audioCtx = null;
70
+ export function ensureAudio() {
71
+ try {
72
+ if (!audioCtx) {
73
+ audioCtx = new (window.AudioContext || window.webkitAudioContext)();
74
+ audioCtx.onstatechange = updateSoundBadge;
75
+ }
76
+ if (audioCtx.state === 'suspended') audioCtx.resume().catch(() => {});
77
+ } catch { audioCtx = null; }
78
+ updateSoundBadge();
79
+ return audioCtx;
80
+ }
81
+ window.addEventListener('pointerdown', ensureAudio);
82
+ window.addEventListener('keydown', ensureAudio);
83
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) ensureAudio(); });
84
+
85
+ export function updateSoundBadge() {
86
+ const b = $('sound-badge');
87
+ if (!b) return;
88
+ b.classList.toggle('hidden', !(getSetting('sound') && audioCtx && audioCtx.state !== 'running'));
89
+ }
90
+ $('sound-badge').onclick = async () => {
91
+ const ctx = ensureAudio();
92
+ try { await ctx?.resume(); } catch { }
93
+ updateSoundBadge();
94
+ if (ctx?.state === 'running') playNamedSound(getSetting('soundReview'));
95
+ };
96
+
97
+ function tones(ctx, notes, { type = 'sine', step = 0.1, dur = 0.32, vol = 0.12 } = {}) {
98
+ const now = ctx.currentTime;
99
+ notes.forEach((f, i) => {
100
+ const osc = ctx.createOscillator(); const gain = ctx.createGain();
101
+ osc.type = type; osc.frequency.value = f;
102
+ const t = now + i * step;
103
+ gain.gain.setValueAtTime(0.0001, t);
104
+ gain.gain.exponentialRampToValueAtTime(vol, t + 0.02);
105
+ gain.gain.exponentialRampToValueAtTime(0.0001, t + dur);
106
+ osc.connect(gain); gain.connect(ctx.destination);
107
+ osc.start(t); osc.stop(t + dur + 0.05);
108
+ });
109
+ }
110
+ function chord(ctx, freqs, { vol = 0.08, dur = 0.5 } = {}) {
111
+ const now = ctx.currentTime;
112
+ freqs.forEach((f) => {
113
+ const o = ctx.createOscillator(); const g = ctx.createGain();
114
+ o.type = 'sine'; o.frequency.value = f;
115
+ g.gain.setValueAtTime(0.0001, now);
116
+ g.gain.exponentialRampToValueAtTime(vol, now + 0.03);
117
+ g.gain.exponentialRampToValueAtTime(0.0001, now + dur);
118
+ o.connect(g); g.connect(ctx.destination);
119
+ o.start(now); o.stop(now + dur + 0.05);
120
+ });
121
+ }
122
+ function sweep(ctx, from, to, { dur = 0.2, vol = 0.15, type = 'sine' } = {}) {
123
+ const now = ctx.currentTime;
124
+ const o = ctx.createOscillator(); const g = ctx.createGain();
125
+ o.type = type;
126
+ o.frequency.setValueAtTime(from, now);
127
+ o.frequency.exponentialRampToValueAtTime(to, now + dur);
128
+ g.gain.setValueAtTime(0.0001, now);
129
+ g.gain.exponentialRampToValueAtTime(vol, now + 0.02);
130
+ g.gain.exponentialRampToValueAtTime(0.0001, now + dur + 0.02);
131
+ o.connect(g); g.connect(ctx.destination);
132
+ o.start(now); o.stop(now + dur + 0.06);
133
+ }
134
+ function noiseClick(ctx) {
135
+ const len = Math.ceil(ctx.sampleRate * 0.05);
136
+ const buf = ctx.createBuffer(1, len, ctx.sampleRate);
137
+ const data = buf.getChannelData(0);
138
+ for (let i = 0; i < len; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / len) ** 3;
139
+ const src = ctx.createBufferSource(); src.buffer = buf;
140
+ const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 2400;
141
+ const g = ctx.createGain(); g.gain.value = 0.16;
142
+ src.connect(lp); lp.connect(g); g.connect(ctx.destination);
143
+ src.start(ctx.currentTime); src.stop(ctx.currentTime + 0.06);
144
+ }
145
+ export const SOUND_LIB = {
146
+ none: { name: 'No sound', play: () => {} },
147
+ ding: { name: 'Ding', play: (c) => tones(c, [1319, 1760]) },
148
+ bell: { name: 'Bell', play: (c) => tones(c, [1568, 2093], { type: 'triangle', step: 0.09, dur: 0.42 }) },
149
+ chime: { name: 'Chime', play: (c) => tones(c, [1047, 1319, 1760], { step: 0.08, dur: 0.3 }) },
150
+ drop: { name: 'Drop', play: (c) => sweep(c, 1600, 680, { dur: 0.18, vol: 0.14 }) },
151
+ chord: { name: 'Chord', play: (c) => chord(c, [523.25, 659.25, 783.99]) },
152
+ click: { name: 'Click', play: noiseClick },
153
+ pop: { name: 'Pop', play: (c) => sweep(c, 880, 200, { dur: 0.12, vol: 0.18 }) },
154
+ };
155
+
156
+ export let missedSounds = 0;
157
+ export let lastMissedAt = null;
158
+ export const pageLoadedAt = new Date();
159
+ export async function playNamedSound(id) {
160
+ const ctx = ensureAudio();
161
+ if (!ctx) return;
162
+ if (ctx.state !== 'running') { try { await ctx.resume(); } catch { } }
163
+ if (ctx.state !== 'running') { missedSounds++; lastMissedAt = new Date(); updateSoundBadge(); return; }
164
+ (SOUND_LIB[id] || SOUND_LIB.ding).play(ctx);
165
+ }
166
+ function playReviewSound() {
167
+ if (!getSetting('sound')) return;
168
+ playNamedSound(getSetting('soundReview'));
169
+ }
170
+ function playDoneSound() {
171
+ if (!getSetting('sound')) return;
172
+ playNamedSound(getSetting('soundDone'));
173
+ }
174
+
175
+ function realTransition(ev, to) {
176
+ if (ev.type !== 'task.updated' || ev.data?.status !== to) return false;
177
+ const prev = ev.data.prev_status ?? state.tasks.find((t) => t.id === ev.data.id)?.status;
178
+ return !!prev && prev !== to;
179
+ }
180
+ function maybeReviewSound(ev) {
181
+ if (realTransition(ev, 'review')) playReviewSound();
182
+ }
183
+
184
+ function maybeReviewNotify(ev) {
185
+ if (!getSetting('notifyReview')) return;
186
+ if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return;
187
+ if (!realTransition(ev, 'review')) return;
188
+ try {
189
+ const notif = new Notification(tr('A task is ready for review'), {
190
+ body: `${ev.data.key} · ${ev.data.title || ''}`,
191
+ icon: '/icon-192.png',
192
+ tag: `kb-review-${ev.data.id}`,
193
+ });
194
+ notif.onclick = () => { window.focus(); openTaskFromNotification(ev.data.key, ev.data.project); notif.close(); };
195
+ } catch { }
196
+ }
197
+
198
+ function openTaskFromNotification(key, project) {
199
+ if (project && state.slug !== project) selectProject(project);
200
+ openDrawer(key);
201
+ }
202
+
203
+ function maybeDoneSound(ev) {
204
+ if (realTransition(ev, 'done')) playDoneSound();
205
+ }
206
+
207
+ export const SSE_STALE_MS = 75000;
208
+ let sse = null;
209
+ export let lastSseAt = Date.now();
210
+
211
+ let sseQueue = [];
212
+ let sseFlushing = false;
213
+ async function flushSseQueue() {
214
+ if (sseFlushing) return;
215
+ sseFlushing = true;
216
+ try {
217
+ while (sseQueue.length) {
218
+ const batch = sseQueue;
219
+ sseQueue = [];
220
+ let applied = true;
221
+ for (const ev of batch) { if (!await applySseEvent(ev)) { applied = false; break; } }
222
+ if (!applied) await refresh();
223
+ if (state.drawerKey && batch.some((ev) => ev.type === 'task.updated'
224
+ && ev.data.key === state.drawerKey && ev.data.gh_issue_number)) {
225
+ const t = state.tasks.find((x) => x.key === state.drawerKey);
226
+ if (t) renderIssueRow(t);
227
+ }
228
+ }
229
+ } finally { sseFlushing = false; }
230
+ }
231
+
232
+ export function connectSSE() {
233
+ if (sse) { try { sse.close(); } catch { } }
234
+ const es = new EventSource('/api/events');
235
+ sse = es;
236
+ let timer = null;
237
+ es.onopen = () => {
238
+ lastSseAt = Date.now();
239
+ refresh();
240
+ retryUnsaved();
241
+ };
242
+ es.onmessage = (e) => {
243
+ const gap = Date.now() - lastSseAt;
244
+ lastSseAt = Date.now();
245
+ if (gap > SSE_STALE_MS) refresh().catch(() => {});
246
+ const ev = JSON.parse(e.data);
247
+ if (ev.type === 'ping') return;
248
+ if (ev.type === 'sync.status') { refreshSync(); return; }
249
+ if (ev.type === 'task.comment' && state.drawerKey === ev.data.key) renderComments(ev.data.key);
250
+ maybeReviewSound(ev);
251
+ maybeReviewNotify(ev);
252
+ maybeDoneSound(ev);
253
+ sseQueue.push(ev);
254
+ clearTimeout(timer);
255
+ timer = setTimeout(flushSseQueue, 150);
256
+ };
257
+ es.onerror = () => { es.close(); if (sse === es) setTimeout(connectSSE, 3000); };
258
+ }
259
+ export function ensureSSE() {
260
+ if (document.hidden) return;
261
+ if (!sse || sse.readyState === 2 || Date.now() - lastSseAt > SSE_STALE_MS) connectSSE();
262
+ }
263
+ document.addEventListener('visibilitychange', ensureSSE);
264
+ window.addEventListener('focus', ensureSSE);
265
+
266
+ export function relTime(iso) {
267
+ const d = (Date.now() - new Date(iso + 'Z').getTime()) / 1000;
268
+ if (d < 60) return tr('just now');
269
+ if (d < 3600) return `${Math.floor(d / 60)}${tr('m ago')}`;
270
+ if (d < 86400) return `${Math.floor(d / 3600)}${tr('h ago')}`;
271
+ return `${Math.floor(d / 86400)}${tr('d ago')}`;
272
+ }
273
+
274
+ export const SHORT_MONTHS = LANG === 'ru'
275
+ ? ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
276
+ : ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
277
+ function fmtDate(iso) {
278
+ if (!iso) return '';
279
+ const d = new Date(iso + 'Z');
280
+ const hh = String(d.getHours()).padStart(2, '0');
281
+ const mm = String(d.getMinutes()).padStart(2, '0');
282
+ return `${d.getDate()} ${SHORT_MONTHS[d.getMonth()]} ${hh}:${mm}`;
283
+ }
284
+
285
+ export const STATUS_LABELS = Object.fromEntries(ALL_STATUSES);
286
+
287
+ const TL_KEEP = 3;
288
+ const TL_MAX = 6;
289
+
290
+ export function renderTimeline(t) {
291
+ const box = $('d-timeline');
292
+ const items = [{
293
+ when: t.created_at,
294
+ dot: '<span class="tl-dot" style="background:var(--tl-created)"></span>',
295
+ text: tr('Created'),
296
+ }];
297
+ for (const ev of (t.events || [])) {
298
+ items.push({
299
+ when: ev.created_at,
300
+ dot: `<span class="tl-dot dot s-${ev.status}"></span>`,
301
+ text: esc(STATUS_LABELS[ev.status] || ev.status),
302
+ });
303
+ }
304
+
305
+ const hiddenCount = items.length > TL_MAX ? items.length - TL_KEEP : 0;
306
+ const rows = items.map((it, i) => `<div class="tl-item${i < hiddenCount ? ' tl-old' : ''}">`
307
+ + `<span class="tl-when">${fmtDate(it.when)}</span>`
308
+ + `<span class="tl-what">${it.dot}${it.text}</span></div>`);
309
+
310
+ const toggle = hiddenCount
311
+ ? `<button type="button" class="tl-toggle" id="tl-toggle">`
312
+ + `<span class="tl-toggle-label">${tr('Show the rest')} (${hiddenCount})</span>${ic('chevron', 13)}</button>`
313
+ : '';
314
+
315
+ const backToWork = (t.events || []).filter((e) => e.status === 'doing').length;
316
+ const cycle = backToWork > 1 ? `<div class="tl-cycle">↻ ${tr('went back to work')} ${backToWork}×</div>` : '';
317
+ box.innerHTML = toggle + rows.join('') + cycle;
318
+ box.classList.toggle('tl-collapsed', hiddenCount > 0);
319
+ box.classList.remove('hidden');
320
+
321
+ if (hiddenCount) {
322
+ $('tl-toggle').onclick = () => {
323
+ const collapsed = box.classList.toggle('tl-collapsed');
324
+ $('tl-toggle').querySelector('.tl-toggle-label').textContent = collapsed
325
+ ? `${tr('Show the rest')} (${hiddenCount})`
326
+ : tr('Collapse');
327
+ };
328
+ }
329
+ }
330
+
331
+ export function autoGrow(el) {
332
+ if (!el.isConnected || !el.offsetParent) return;
333
+ el.style.height = 'auto';
334
+ const frame = el.offsetHeight - el.clientHeight;
335
+ el.style.height = el.scrollHeight + frame + 'px';
336
+ }
337
+
338
+ export function titleOr(t) {
339
+ return (t && t.title && t.title.trim()) ? esc(t.title) : `<span class="row-untitled">${tr('untitled')}</span>`;
340
+ }
341
+
342
+ export async function copyTodoPrompt(tasks, btn) {
343
+ const slug = state.slug === ALL ? null : state.slug;
344
+ const head = slug ? `${tr('Do the tasks from')} ${slug} ${tr('with the to-do status:')}` : tr('Do the tasks with the to-do status:');
345
+ const text = [tr('Run the kanban skill'), head, ...tasks.map((t) => t.key)].join('\n');
346
+ const ok = await copyText(text);
347
+ btn.textContent = ok ? '✓' : '✕';
348
+ btn.classList.toggle('copied', ok);
349
+ setTimeout(() => { btn.innerHTML = ic('copy', 13); btn.classList.remove('copied'); }, 1400);
350
+ }
351
+
352
+ export async function copyBacklogPrompt(tasks, btn) {
353
+ const slug = state.slug === ALL ? null : state.slug;
354
+ const head = slug ? `${tr('Groom the backlog of')} ${slug} ${tr('(grooming mode):')}` : tr('Groom the backlog (grooming mode):');
355
+ const text = [tr('Run the kanban skill'), head, ...tasks.map((t) => t.key)].join('\n');
356
+ const ok = await copyText(text);
357
+ btn.textContent = ok ? '✓' : '✕';
358
+ btn.classList.toggle('copied', ok);
359
+ setTimeout(() => { btn.innerHTML = ic('copy', 13); btn.classList.remove('copied'); }, 1400);
360
+ }
361
+
362
+ export async function copyText(text) {
363
+ try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; } } catch { }
364
+ try {
365
+ const ta = document.createElement('textarea');
366
+ ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
367
+ document.body.appendChild(ta); ta.focus(); ta.select();
368
+ const ok = document.execCommand('copy'); ta.remove(); return ok;
369
+ } catch { return false; }
370
+ }
371
+
372
+ let lastView = null;
373
+ export function showView(view) {
374
+ $('toolbar').classList.toggle('hidden', view !== 'board');
375
+ $('board').classList.toggle('hidden', view !== 'board');
376
+ $('link-lines').classList.toggle('hidden', view !== 'board');
377
+ $('dashboard').classList.toggle('hidden', view !== 'dashboard');
378
+ $('settings').classList.toggle('hidden', view !== 'settings');
379
+ $('horizon').classList.toggle('hidden', view !== 'horizon');
380
+ $('calendar').classList.toggle('hidden', view !== 'calendar');
381
+ $('chaos').classList.toggle('hidden', view !== 'chaos');
382
+ $('projset').classList.toggle('hidden', view !== 'projset');
383
+ const changed = lastView !== view;
384
+ lastView = view;
385
+ return changed;
386
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "Claude Kanban",
3
+ "short_name": "Kanban",
4
+ "description": "A local kanban board for working with Claude Code",
5
+ "start_url": "/",
6
+ "scope": "/",
7
+ "display": "standalone",
8
+ "background_color": "#1f1f1e",
9
+ "theme_color": "#1f1f1e",
10
+ "lang": "ru",
11
+ "icons": [
12
+ { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" },
13
+ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
14
+ { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
15
+ { "src": "/icon-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
16
+ { "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
17
+ ],
18
+ "shortcuts": [
19
+ {
20
+ "name": "New task",
21
+ "short_name": "New",
22
+ "url": "/?action=new",
23
+ "icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
24
+ },
25
+ {
26
+ "name": "Dashboard",
27
+ "short_name": "Dashboard",
28
+ "url": "/?view=dash",
29
+ "icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
30
+ }
31
+ ]
32
+ }
@@ -0,0 +1,39 @@
1
+ <!doctype html>
2
+ <html lang="ru">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Offline · Kanban</title>
7
+ <style>
8
+ html, body { height: 100%; margin: 0; }
9
+ body {
10
+ display: flex; align-items: center; justify-content: center;
11
+ background: #1f1f1e; color: #e8e6e3;
12
+ font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
13
+ -webkit-font-smoothing: antialiased;
14
+ }
15
+ .card { text-align: center; max-width: 320px; padding: 32px; }
16
+ .logo { width: 56px; height: 56px; margin: 0 auto 18px; display: block; }
17
+ h1 { font-size: 18px; font-weight: 600; margin: 0 0 8px; }
18
+ p { color: #9b9690; margin: 0 0 20px; }
19
+ button {
20
+ font: inherit; color: #fff; background: #e8704e; border: 0;
21
+ padding: 9px 18px; border-radius: 8px; cursor: pointer;
22
+ }
23
+ button:hover { filter: brightness(1.07); }
24
+ </style>
25
+ </head>
26
+ <body>
27
+ <div class="card">
28
+ <svg class="logo" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
29
+ <rect width="32" height="32" rx="7" fill="#e8704e"/>
30
+ <rect x="6" y="7" width="5.5" height="12" rx="2" fill="#fff" opacity=".95"/>
31
+ <rect x="13.25" y="7" width="5.5" height="18" rx="2" fill="#fff" opacity=".95"/>
32
+ <rect x="20.5" y="7" width="5.5" height="8" rx="2" fill="#fff" opacity=".95"/>
33
+ </svg>
34
+ <h1>No connection to the board</h1>
35
+ <p>The kanban server is not responding. It may be restarting, or the computer running it is off. The board will load as soon as the connection is back.</p>
36
+ <button onclick="location.reload()">Try again</button>
37
+ </div>
38
+ </body>
39
+ </html>