k0-board 0.5.0 → 0.6.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/CHANGELOG.md +66 -1
- package/LICENSE +83 -18
- package/README.md +238 -36
- package/bin/k0-board.js +48 -1
- package/package.json +2 -2
- package/server/cloud.js +355 -0
- package/server/db.js +375 -58
- package/server/index.js +92 -2
- package/server/mirror.js +21 -0
- package/server/sync.js +383 -0
- package/server/update.js +3 -2
- package/web/account.js +265 -0
- package/web/base.css +93 -0
- package/web/index.html +1 -0
package/web/account.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// ── The account, on the board ────────────────────────────────────────────────
|
|
2
|
+
// Two things, and only two. A chip at the end of the top bar saying who is signed in and whether
|
|
3
|
+
// the cloud has everything — a dot, green when it does, amber while something is waiting to go
|
|
4
|
+
// up, grey when k0 Cloud cannot be reached and the board is carrying on by itself. And, until an
|
|
5
|
+
// account is linked and let in, a screen in front of the board that says what to do about it.
|
|
6
|
+
//
|
|
7
|
+
// The screen only appears when there is a way past it: if the cloud has no way of signing in
|
|
8
|
+
// switched on yet, there is nothing the person in front of it could do, and blocking their board
|
|
9
|
+
// for that would be k0's failure dressed up as theirs. Offline at the first start is the same: the
|
|
10
|
+
// screen says so rather than offering buttons that cannot work.
|
|
11
|
+
|
|
12
|
+
const $ = (sel, root = document) => root.querySelector(sel)
|
|
13
|
+
|
|
14
|
+
const NAMES = { github: 'GitHub', google: 'Google' }
|
|
15
|
+
|
|
16
|
+
let last = null
|
|
17
|
+
|
|
18
|
+
async function get() {
|
|
19
|
+
const res = await fetch('/api/cloud', { cache: 'no-store' })
|
|
20
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
21
|
+
return res.json()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function post(action, body = {}) {
|
|
25
|
+
const res = await fetch(`/api/cloud/${action}`, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'content-type': 'application/json' },
|
|
28
|
+
body: JSON.stringify(body),
|
|
29
|
+
})
|
|
30
|
+
const data = await res.json().catch(() => ({}))
|
|
31
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
32
|
+
return data
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function el(tag, attrs = {}, ...children) {
|
|
36
|
+
const node = document.createElement(tag)
|
|
37
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
38
|
+
if (k === 'class') node.className = v
|
|
39
|
+
else if (k.startsWith('on')) node.addEventListener(k.slice(2), v)
|
|
40
|
+
else if (v != null) node.setAttribute(k, v)
|
|
41
|
+
}
|
|
42
|
+
for (const c of children) if (c != null) node.append(c)
|
|
43
|
+
return node
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── The screen in front of the board ─────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
let later = false
|
|
49
|
+
|
|
50
|
+
function gate(s) {
|
|
51
|
+
let box = $('#account-gate')
|
|
52
|
+
// The page that says the move is done stays until it is read and closed.
|
|
53
|
+
if (box?.dataset.kind === 'done') return
|
|
54
|
+
const signIn = s.enabled && (!s.linked || !s.approved) && (s.providers?.length || (!s.linked && s.online === false))
|
|
55
|
+
// The move is offered, not imposed: the board keeps working on its own numbers until its owner
|
|
56
|
+
// has looked at the new ones and said yes, and "Not now" really means not now.
|
|
57
|
+
const moving = s.enabled && s.linked && s.approved && s.needs_move && !later
|
|
58
|
+
if (!signIn && !moving) {
|
|
59
|
+
box?.remove()
|
|
60
|
+
document.body.classList.remove('gated')
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
if (box?.dataset.kind === 'move' && moving) return
|
|
64
|
+
if (!box) {
|
|
65
|
+
box = el('div', { id: 'account-gate', class: 'account-gate', role: 'dialog', 'aria-modal': 'true' })
|
|
66
|
+
document.body.append(box)
|
|
67
|
+
}
|
|
68
|
+
document.body.classList.add('gated')
|
|
69
|
+
box.dataset.kind = moving ? 'move' : 'gate'
|
|
70
|
+
box.replaceChildren(moving ? move(s) : s.linked ? waiting(s) : signInCard(s))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function move(s) {
|
|
74
|
+
const card = el('div', { class: 'gate-card wide' },
|
|
75
|
+
el('h2', {}, 'Move your board to k0 Cloud'),
|
|
76
|
+
el('p', {}, `You are signed in as ${s.email || s.name || 'you'}. The stories already on this machine were numbered here, ` +
|
|
77
|
+
'and from now on a number is one for your whole account, whichever repository and whichever machine. ' +
|
|
78
|
+
'Moving the board renumbers every story and epic once, in the order they were made, renames their files in .k0/, ' +
|
|
79
|
+
'and sends the board up. A copy of the database is kept first.'),
|
|
80
|
+
)
|
|
81
|
+
const table = el('div', { class: 'move-table' })
|
|
82
|
+
const buttons = el('div', { class: 'gate-buttons' })
|
|
83
|
+
const show = el('button', {
|
|
84
|
+
type: 'button',
|
|
85
|
+
onclick: async () => {
|
|
86
|
+
show.disabled = true
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch('/api/cloud/move', { cache: 'no-store' })
|
|
89
|
+
const plan = await res.json()
|
|
90
|
+
if (!res.ok) throw new Error(plan.error || `HTTP ${res.status}`)
|
|
91
|
+
table.replaceChildren(numbers(plan.rows, plan.texts))
|
|
92
|
+
go.hidden = false
|
|
93
|
+
show.hidden = true
|
|
94
|
+
} catch (err) {
|
|
95
|
+
show.disabled = false
|
|
96
|
+
note(card, err.message)
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
}, 'Show me the new numbers')
|
|
100
|
+
const go = el('button', {
|
|
101
|
+
type: 'button',
|
|
102
|
+
class: 'primary',
|
|
103
|
+
hidden: '',
|
|
104
|
+
onclick: async () => {
|
|
105
|
+
go.disabled = true
|
|
106
|
+
try {
|
|
107
|
+
const done = await post('move')
|
|
108
|
+
const box = $('#account-gate')
|
|
109
|
+
if (box) box.dataset.kind = 'done'
|
|
110
|
+
card.replaceChildren(
|
|
111
|
+
el('h2', {}, 'Moved'),
|
|
112
|
+
el('p', {}, `${done.moved} stories and epics now carry their account-wide numbers, starting at K${done.start}.`),
|
|
113
|
+
el('p', { class: 'gate-note' }, `The copy taken before the move: ${done.backup}`),
|
|
114
|
+
el('p', { class: 'gate-note' }, `Every old number beside its new one: ${done.map}`),
|
|
115
|
+
el('div', { class: 'gate-buttons' }, el('button', { type: 'button', class: 'primary', onclick: () => location.reload() }, 'Back to the board')),
|
|
116
|
+
)
|
|
117
|
+
} catch (err) {
|
|
118
|
+
go.disabled = false
|
|
119
|
+
note(card, err.message)
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
}, 'Move it')
|
|
123
|
+
const notNow = el('button', {
|
|
124
|
+
type: 'button',
|
|
125
|
+
onclick: () => {
|
|
126
|
+
later = true
|
|
127
|
+
$('#account-gate')?.remove()
|
|
128
|
+
document.body.classList.remove('gated')
|
|
129
|
+
},
|
|
130
|
+
}, 'Not now')
|
|
131
|
+
buttons.append(show, go, notNow)
|
|
132
|
+
card.append(buttons, table)
|
|
133
|
+
return card
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Old number beside new, a repository at a time, the way the person reading it thinks of them. */
|
|
137
|
+
function numbers(rows, texts = 0) {
|
|
138
|
+
const byRepo = new Map()
|
|
139
|
+
for (const r of rows) {
|
|
140
|
+
const name = (r.project_path || '').split(/[\\/]/).filter(Boolean).pop() || r.project_path
|
|
141
|
+
if (!byRepo.has(name)) byRepo.set(name, [])
|
|
142
|
+
byRepo.get(name).push(r)
|
|
143
|
+
}
|
|
144
|
+
const wrap = el('div')
|
|
145
|
+
wrap.append(el('p', { class: 'gate-note' }, `${rows.length} in all, across ${byRepo.size} repositor${byRepo.size === 1 ? 'y' : 'ies'}.` +
|
|
146
|
+
(texts ? ` ${texts} pieces of text name a story by its number — a plan, a prompt, a decision — and are updated to the new one, inside the same repository.` : '')))
|
|
147
|
+
for (const [name, list] of byRepo) {
|
|
148
|
+
wrap.append(el('h3', {}, name))
|
|
149
|
+
const t = el('table')
|
|
150
|
+
t.append(el('tr', {}, el('th', {}, 'Was'), el('th', {}, 'Will be'), el('th', {}, '')))
|
|
151
|
+
for (const r of list) t.append(el('tr', {}, el('td', {}, r.old), el('td', {}, r.new), el('td', {}, `${r.kind === 'epic' ? 'Epic · ' : ''}${r.title}`)))
|
|
152
|
+
wrap.append(t)
|
|
153
|
+
}
|
|
154
|
+
return wrap
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function signInCard(s) {
|
|
158
|
+
const providers = s.providers || []
|
|
159
|
+
const card = el('div', { class: 'gate-card' },
|
|
160
|
+
el('h2', {}, 'Connect your account'),
|
|
161
|
+
el('p', {}, 'k0 keeps your board in k0 Cloud, so it survives a lost laptop, reaches your other machines and can be read from a browser. ' +
|
|
162
|
+
'It keeps working here without a connection and catches up when it has one.'),
|
|
163
|
+
)
|
|
164
|
+
if (!providers.length) {
|
|
165
|
+
card.append(el('p', { class: 'gate-note' }, 'k0 Cloud cannot be reached right now. Connect to the internet and this page will offer the way in.'))
|
|
166
|
+
return card
|
|
167
|
+
}
|
|
168
|
+
const row = el('div', { class: 'gate-buttons' })
|
|
169
|
+
for (const p of providers) {
|
|
170
|
+
row.append(el('button', {
|
|
171
|
+
type: 'button',
|
|
172
|
+
class: 'primary',
|
|
173
|
+
onclick: async (e) => {
|
|
174
|
+
e.currentTarget.disabled = true
|
|
175
|
+
try {
|
|
176
|
+
const { url } = await post('login', { provider: p })
|
|
177
|
+
location.href = url
|
|
178
|
+
} catch (err) {
|
|
179
|
+
e.currentTarget.disabled = false
|
|
180
|
+
note(card, err.message)
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
}, `Continue with ${NAMES[p] || p}`))
|
|
184
|
+
}
|
|
185
|
+
card.append(row)
|
|
186
|
+
card.append(el('p', { class: 'gate-note' }, 'k0 is in early access: after you sign in, your account opens as soon as it has been approved.'))
|
|
187
|
+
return card
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function waiting(s) {
|
|
191
|
+
const card = el('div', { class: 'gate-card' },
|
|
192
|
+
el('h2', {}, 'You are on the list'),
|
|
193
|
+
el('p', {}, `Signed in as ${s.email || s.name || 'you'}. k0 is in early access, and your account opens as soon as it has been approved — this page notices by itself.`),
|
|
194
|
+
)
|
|
195
|
+
card.append(el('div', { class: 'gate-buttons' },
|
|
196
|
+
el('button', { type: 'button', onclick: () => post('logout').then(refresh) }, 'Use another account'),
|
|
197
|
+
))
|
|
198
|
+
return card
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function note(card, text) {
|
|
202
|
+
let n = $('.gate-error', card)
|
|
203
|
+
if (!n) card.append((n = el('p', { class: 'gate-error' })))
|
|
204
|
+
n.textContent = text
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── The chip on the bar ──────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
function chip(s) {
|
|
210
|
+
let c = $('#account')
|
|
211
|
+
if (!s.enabled || !s.linked) {
|
|
212
|
+
c?.remove()
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
if (!c) {
|
|
216
|
+
c = el('div', { class: 'account', id: 'account' })
|
|
217
|
+
$('.topbar')?.append(c)
|
|
218
|
+
}
|
|
219
|
+
const state = s.online === false ? 'offline' : s.pending > 0 || s.provisional > 0 ? 'pending' : s.sync_error ? 'offline' : 'synced'
|
|
220
|
+
const said = {
|
|
221
|
+
synced: 'Everything is in k0 Cloud.',
|
|
222
|
+
pending: `${s.pending} change${s.pending === 1 ? '' : 's'} on the way to k0 Cloud.`,
|
|
223
|
+
offline: s.sync_error || 'k0 Cloud cannot be reached; the board carries on here and catches up later.',
|
|
224
|
+
}[state]
|
|
225
|
+
const menu = el('div', { class: 'menu account-menu', role: 'menu', hidden: '' },
|
|
226
|
+
el('p', {}, s.email || s.name || ''),
|
|
227
|
+
el('p', { class: 'account-said' }, said),
|
|
228
|
+
el('button', { type: 'button', role: 'menuitem', onclick: () => post('sync').then(refresh) }, 'Sync now'),
|
|
229
|
+
el('button', { type: 'button', role: 'menuitem', onclick: () => post('logout').then(refresh) }, 'Sign out'),
|
|
230
|
+
)
|
|
231
|
+
const button = el('button', {
|
|
232
|
+
type: 'button',
|
|
233
|
+
class: `account-chip ${state}`,
|
|
234
|
+
title: said,
|
|
235
|
+
'aria-label': `Account: ${said}`,
|
|
236
|
+
'aria-haspopup': 'true',
|
|
237
|
+
onclick: (e) => {
|
|
238
|
+
e.stopPropagation()
|
|
239
|
+
menu.hidden = !menu.hidden
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
s.avatar_url ? el('img', { src: s.avatar_url, alt: '', referrerpolicy: 'no-referrer' }) : el('span', { class: 'initial' }, (s.email || s.name || '?')[0].toUpperCase()),
|
|
243
|
+
el('span', { class: 'dot', 'aria-hidden': 'true' }),
|
|
244
|
+
)
|
|
245
|
+
c.replaceChildren(button, menu)
|
|
246
|
+
document.addEventListener('click', () => (menu.hidden = true), { once: true })
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function refresh() {
|
|
250
|
+
try {
|
|
251
|
+
const s = await get()
|
|
252
|
+
last = s
|
|
253
|
+
gate(s)
|
|
254
|
+
chip(s)
|
|
255
|
+
} catch {
|
|
256
|
+
// The server itself is not answering: the board has its own way of saying that.
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
refresh()
|
|
261
|
+
// Often enough to notice an approval or a sync finishing, rarely enough to cost nothing; and not
|
|
262
|
+
// at all while the tab is hidden, like everything else on this page.
|
|
263
|
+
setInterval(() => {
|
|
264
|
+
if (!document.hidden) refresh()
|
|
265
|
+
}, last?.linked && last?.approved ? 15000 : 5000)
|
package/web/base.css
CHANGED
|
@@ -136,3 +136,96 @@ dialog .spacer { flex: 1; }
|
|
|
136
136
|
dialog.ask { width: min(380px, 88vw); padding: 22px 24px 18px; }
|
|
137
137
|
dialog.ask p { margin: 0 0 20px; font-size: 1rem; }
|
|
138
138
|
dialog.ask footer { margin: 0; }
|
|
139
|
+
|
|
140
|
+
/* ── The account ──────────────────────────────────────────────────────────────
|
|
141
|
+
The chip at the end of the bar, and the screen in front of the board until an account is
|
|
142
|
+
linked and let in. See web/account.js. */
|
|
143
|
+
.account { position: relative; flex: none; }
|
|
144
|
+
.account-chip {
|
|
145
|
+
position: relative;
|
|
146
|
+
width: 34px;
|
|
147
|
+
height: 34px;
|
|
148
|
+
padding: 0;
|
|
149
|
+
border-radius: 999px;
|
|
150
|
+
display: flex;
|
|
151
|
+
align-items: center;
|
|
152
|
+
justify-content: center;
|
|
153
|
+
overflow: visible;
|
|
154
|
+
}
|
|
155
|
+
.account-chip img { width: 100%; height: 100%; border-radius: 999px; object-fit: cover; }
|
|
156
|
+
.account-chip .initial { font-weight: 600; }
|
|
157
|
+
.account-chip .dot {
|
|
158
|
+
position: absolute;
|
|
159
|
+
right: -1px;
|
|
160
|
+
bottom: -1px;
|
|
161
|
+
width: 11px;
|
|
162
|
+
height: 11px;
|
|
163
|
+
border-radius: 999px;
|
|
164
|
+
border: 2px solid var(--bg);
|
|
165
|
+
background: var(--calm);
|
|
166
|
+
}
|
|
167
|
+
.account-chip.pending .dot { background: var(--warn); }
|
|
168
|
+
.account-chip.offline .dot { background: var(--muted); }
|
|
169
|
+
.account-menu {
|
|
170
|
+
position: absolute;
|
|
171
|
+
z-index: 6;
|
|
172
|
+
top: calc(100% + 6px);
|
|
173
|
+
right: 0;
|
|
174
|
+
min-width: 240px;
|
|
175
|
+
padding: 10px;
|
|
176
|
+
background: var(--paper);
|
|
177
|
+
border: 1px solid var(--line);
|
|
178
|
+
border-radius: 11px;
|
|
179
|
+
box-shadow: 0 12px 30px rgba(0, 0, 0, .18);
|
|
180
|
+
display: flex;
|
|
181
|
+
flex-direction: column;
|
|
182
|
+
gap: 2px;
|
|
183
|
+
}
|
|
184
|
+
.account-menu[hidden] { display: none; }
|
|
185
|
+
.account-menu button {
|
|
186
|
+
width: 100%;
|
|
187
|
+
border: none;
|
|
188
|
+
background: none;
|
|
189
|
+
border-radius: 7px;
|
|
190
|
+
padding: 8px 11px;
|
|
191
|
+
text-align: left;
|
|
192
|
+
font-weight: 600;
|
|
193
|
+
}
|
|
194
|
+
.account-menu button:hover { background: #f0ede3; }
|
|
195
|
+
.account-menu p { margin: 0 0 6px; overflow-wrap: anywhere; }
|
|
196
|
+
.account-menu .account-said { color: var(--muted); font-size: 0.85rem; }
|
|
197
|
+
|
|
198
|
+
body.gated { overflow: hidden; }
|
|
199
|
+
.account-gate {
|
|
200
|
+
position: fixed;
|
|
201
|
+
inset: 0;
|
|
202
|
+
z-index: 1000;
|
|
203
|
+
display: flex;
|
|
204
|
+
align-items: center;
|
|
205
|
+
justify-content: center;
|
|
206
|
+
padding: 16px;
|
|
207
|
+
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
|
208
|
+
backdrop-filter: blur(6px);
|
|
209
|
+
}
|
|
210
|
+
.gate-card {
|
|
211
|
+
width: 100%;
|
|
212
|
+
max-width: 520px;
|
|
213
|
+
padding: 28px;
|
|
214
|
+
border: 1px solid var(--line);
|
|
215
|
+
border-radius: 14px;
|
|
216
|
+
background: var(--paper);
|
|
217
|
+
box-shadow: 0 10px 40px rgb(0 0 0 / 0.08);
|
|
218
|
+
}
|
|
219
|
+
.gate-card h2 { margin: 0 0 10px; font-family: var(--hand); font-size: 2rem; font-weight: 600; }
|
|
220
|
+
.gate-card p { margin: 0 0 14px; line-height: 1.5; }
|
|
221
|
+
.gate-buttons { display: flex; flex-wrap: wrap; gap: 10px; margin: 18px 0 14px; }
|
|
222
|
+
.gate-note { color: var(--muted); font-size: 0.9rem; }
|
|
223
|
+
.gate-error { color: var(--alert); font-size: 0.9rem; }
|
|
224
|
+
|
|
225
|
+
.auth-page main { max-width: 520px; margin: 15vh auto; padding: 0 16px; font-family: var(--sans); }
|
|
226
|
+
.gate-card.wide { max-width: 860px; max-height: calc(100vh - 32px); overflow: auto; }
|
|
227
|
+
.move-table h3 { margin: 18px 0 6px; font-size: 1rem; }
|
|
228
|
+
.move-table table { width: 100%; border-collapse: collapse; font-size: 0.9rem; table-layout: fixed; }
|
|
229
|
+
.move-table th:first-child, .move-table th:nth-child(2) { width: 90px; }
|
|
230
|
+
.move-table th, .move-table td { text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
231
|
+
.move-table td:first-child, .move-table td:nth-child(2) { font-family: var(--mono); white-space: nowrap; }
|