beads-ui 0.1.2 → 0.3.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/CHANGES.md +29 -2
- package/README.md +39 -45
- package/app/data/list-selectors.js +98 -0
- package/app/data/providers.js +25 -127
- package/app/data/sort.js +45 -0
- package/app/data/subscription-issue-store.js +161 -0
- package/app/data/subscription-issue-stores.js +102 -0
- package/app/data/subscriptions-store.js +219 -0
- package/app/index.html +8 -0
- package/app/main.js +483 -61
- package/app/protocol.js +10 -14
- package/app/protocol.md +21 -19
- package/app/router.js +45 -9
- package/app/state.js +27 -11
- package/app/styles.css +373 -184
- package/app/utils/issue-id-renderer.js +71 -0
- package/app/utils/issue-url.js +9 -0
- package/app/utils/markdown.js +15 -194
- package/app/utils/priority-badge.js +0 -2
- package/app/utils/status-badge.js +0 -1
- package/app/utils/toast.js +34 -0
- package/app/utils/type-badge.js +0 -3
- package/app/views/board.js +439 -87
- package/app/views/detail.js +364 -154
- package/app/views/epics.js +128 -76
- package/app/views/issue-dialog.js +163 -0
- package/app/views/issue-row.js +10 -11
- package/app/views/list.js +164 -93
- package/app/views/new-issue-dialog.js +345 -0
- package/app/ws.js +36 -9
- package/bin/bdui.js +1 -1
- package/docs/adr/001-push-only-lists.md +134 -0
- package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
- package/docs/architecture.md +35 -85
- package/docs/data-exchange-subscription-plan.md +198 -0
- package/docs/db-watching.md +2 -1
- package/docs/migration-v2.md +54 -0
- package/docs/protocol/issues-push-v2.md +179 -0
- package/docs/subscription-issue-store.md +112 -0
- package/package.json +11 -3
- package/server/bd.js +0 -2
- package/server/cli/commands.js +12 -5
- package/server/cli/daemon.js +12 -5
- package/server/cli/index.js +34 -5
- package/server/cli/usage.js +2 -2
- package/server/config.js +12 -6
- package/server/db.js +0 -1
- package/server/index.js +9 -5
- package/server/list-adapters.js +218 -0
- package/server/subscriptions.js +277 -0
- package/server/validators.js +111 -0
- package/server/watcher.js +6 -9
- package/server/ws.js +466 -227
- package/docs/quickstart.md +0 -142
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { issueDisplayId } from './issue-id.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create a reusable, copy-to-clipboard issue ID renderer.
|
|
5
|
+
* Looks like the current inline ID (monospace `#123`) but acts as a button
|
|
6
|
+
* that copies the full, prefixed ID (e.g., `UI-123`) when activated.
|
|
7
|
+
* Shows transient "Copied" feedback and then restores the ID.
|
|
8
|
+
* @param {string} id - Full issue id including the prefix (e.g., "UI-123").
|
|
9
|
+
* @param {{ class_name?: string, duration_ms?: number }} [opts]
|
|
10
|
+
* @returns {HTMLButtonElement}
|
|
11
|
+
*/
|
|
12
|
+
export function createIssueIdRenderer(id, opts) {
|
|
13
|
+
/** @type {number} */
|
|
14
|
+
const duration =
|
|
15
|
+
typeof opts?.duration_ms === 'number' ? opts.duration_ms : 1200;
|
|
16
|
+
/** @type {HTMLButtonElement} */
|
|
17
|
+
const btn = document.createElement('button');
|
|
18
|
+
// Visual: match inline ID look; keep it neutral and text-like
|
|
19
|
+
btn.className =
|
|
20
|
+
(opts?.class_name ? opts.class_name + ' ' : '') + 'mono id-copy';
|
|
21
|
+
btn.type = 'button';
|
|
22
|
+
btn.setAttribute('aria-live', 'polite');
|
|
23
|
+
btn.setAttribute('title', 'Copy issue ID');
|
|
24
|
+
btn.setAttribute('aria-label', `Copy issue ID ${id}`);
|
|
25
|
+
const label = issueDisplayId(id);
|
|
26
|
+
btn.textContent = label;
|
|
27
|
+
|
|
28
|
+
/** Copy handler with feedback */
|
|
29
|
+
async function doCopy() {
|
|
30
|
+
// Prevent accidental row navigation and parent handlers
|
|
31
|
+
// (click/key handlers call this inside an event context)
|
|
32
|
+
try {
|
|
33
|
+
if (
|
|
34
|
+
navigator.clipboard &&
|
|
35
|
+
typeof navigator.clipboard.writeText === 'function'
|
|
36
|
+
) {
|
|
37
|
+
await navigator.clipboard.writeText(String(id));
|
|
38
|
+
}
|
|
39
|
+
const prev = btn.textContent || label;
|
|
40
|
+
btn.textContent = 'Copied';
|
|
41
|
+
// Keep accessible label consistent with feedback
|
|
42
|
+
const oldAria = btn.getAttribute('aria-label') || '';
|
|
43
|
+
btn.setAttribute('aria-label', 'Copied');
|
|
44
|
+
setTimeout(
|
|
45
|
+
() => {
|
|
46
|
+
btn.textContent = prev;
|
|
47
|
+
btn.setAttribute('aria-label', oldAria);
|
|
48
|
+
},
|
|
49
|
+
Math.max(80, duration)
|
|
50
|
+
);
|
|
51
|
+
} catch {
|
|
52
|
+
// On failure, leave text as-is; no throw to avoid disruptive UX
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
btn.addEventListener('click', (ev) => {
|
|
57
|
+
ev.preventDefault();
|
|
58
|
+
ev.stopPropagation();
|
|
59
|
+
void doCopy();
|
|
60
|
+
});
|
|
61
|
+
btn.addEventListener('keydown', (ev) => {
|
|
62
|
+
// Ensure keyboard activation works even in non-interactive test envs
|
|
63
|
+
if (ev.key === 'Enter' || ev.key === ' ') {
|
|
64
|
+
ev.preventDefault();
|
|
65
|
+
ev.stopPropagation();
|
|
66
|
+
void doCopy();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return btn;
|
|
71
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a canonical issue hash that retains the view.
|
|
3
|
+
* @param {'issues'|'epics'|'board'} view
|
|
4
|
+
* @param {string} id
|
|
5
|
+
*/
|
|
6
|
+
export function issueHashFor(view, id) {
|
|
7
|
+
const v = view === 'epics' || view === 'board' ? view : 'issues';
|
|
8
|
+
return `#/${v}?issue=${encodeURIComponent(id)}`;
|
|
9
|
+
}
|
package/app/utils/markdown.js
CHANGED
|
@@ -1,201 +1,22 @@
|
|
|
1
|
+
import DOMPurify from 'dompurify';
|
|
2
|
+
import { html } from 'lit-html';
|
|
3
|
+
import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
|
|
4
|
+
import { marked } from 'marked';
|
|
5
|
+
|
|
1
6
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
7
|
+
* Render Markdown safely as HTML using marked and DOMPurify.
|
|
8
|
+
* Returns a lit-html TemplateResult via the unsafeHTML directive so it can be
|
|
9
|
+
* embedded directly in templates.
|
|
5
10
|
* @function renderMarkdown
|
|
6
11
|
* @param {string} src Markdown source text
|
|
7
|
-
* @returns {
|
|
12
|
+
* @returns {import('lit-html').TemplateResult}
|
|
8
13
|
*/
|
|
9
14
|
export function renderMarkdown(src) {
|
|
10
|
-
/** @type {DocumentFragment} */
|
|
11
|
-
const frag = document.createDocumentFragment();
|
|
12
|
-
|
|
13
|
-
/** @type {string[]} */
|
|
14
|
-
const lines = (src || '').replace(/\r\n?/g, '\n').split('\n');
|
|
15
|
-
|
|
16
|
-
/** @type {boolean} */
|
|
17
|
-
let in_code = false;
|
|
18
|
-
/** @type {string[]} */
|
|
19
|
-
let code_acc = [];
|
|
20
|
-
|
|
21
|
-
/** @type {HTMLElement|null} */
|
|
22
|
-
let list_el = null;
|
|
23
15
|
/** @type {string} */
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
function flushParagraph(buf) {
|
|
31
|
-
if (buf.length === 0) {
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
const p = document.createElement('p');
|
|
35
|
-
appendInline(p, buf.join(' '));
|
|
36
|
-
frag.appendChild(p);
|
|
37
|
-
buf.length = 0;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** @type {string[]} */
|
|
41
|
-
const para = [];
|
|
42
|
-
|
|
43
|
-
for (let i = 0; i < lines.length; i++) {
|
|
44
|
-
const raw = lines[i];
|
|
45
|
-
const line = raw;
|
|
46
|
-
|
|
47
|
-
// fenced code blocks
|
|
48
|
-
if (/^```/.test(line)) {
|
|
49
|
-
if (!in_code) {
|
|
50
|
-
in_code = true;
|
|
51
|
-
code_acc = [];
|
|
52
|
-
} else {
|
|
53
|
-
// flush code block
|
|
54
|
-
const pre = document.createElement('pre');
|
|
55
|
-
const code = document.createElement('code');
|
|
56
|
-
code.textContent = code_acc.join('\n');
|
|
57
|
-
pre.appendChild(code);
|
|
58
|
-
frag.appendChild(pre);
|
|
59
|
-
in_code = false;
|
|
60
|
-
code_acc = [];
|
|
61
|
-
}
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
if (in_code) {
|
|
65
|
-
code_acc.push(raw);
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// blank line -> paragraph / list break
|
|
70
|
-
if (/^\s*$/.test(line)) {
|
|
71
|
-
flushParagraph(para);
|
|
72
|
-
if (list_el !== null) {
|
|
73
|
-
frag.appendChild(list_el);
|
|
74
|
-
list_el = null;
|
|
75
|
-
list_type = '';
|
|
76
|
-
}
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// heading
|
|
81
|
-
const hx = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
82
|
-
if (hx) {
|
|
83
|
-
flushParagraph(para);
|
|
84
|
-
if (list_el !== null) {
|
|
85
|
-
frag.appendChild(list_el);
|
|
86
|
-
list_el = null;
|
|
87
|
-
list_type = '';
|
|
88
|
-
}
|
|
89
|
-
const level = Math.min(6, hx[1].length);
|
|
90
|
-
const h = /** @type {HTMLHeadingElement} */ (
|
|
91
|
-
document.createElement('h' + String(level))
|
|
92
|
-
);
|
|
93
|
-
appendInline(h, hx[2]);
|
|
94
|
-
frag.appendChild(h);
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// list item
|
|
99
|
-
let m = /^\s*[-*]\s+(.*)$/.exec(line);
|
|
100
|
-
if (m) {
|
|
101
|
-
if (list_el === null || list_type !== 'ul') {
|
|
102
|
-
flushParagraph(para);
|
|
103
|
-
if (list_el !== null) {
|
|
104
|
-
frag.appendChild(list_el);
|
|
105
|
-
}
|
|
106
|
-
list_el = document.createElement('ul');
|
|
107
|
-
list_type = 'ul';
|
|
108
|
-
}
|
|
109
|
-
const li = document.createElement('li');
|
|
110
|
-
appendInline(li, m[1]);
|
|
111
|
-
list_el.appendChild(li);
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
m = /^\s*(\d+)\.\s+(.*)$/.exec(line);
|
|
115
|
-
if (m) {
|
|
116
|
-
if (list_el === null || list_type !== 'ol') {
|
|
117
|
-
flushParagraph(para);
|
|
118
|
-
if (list_el !== null) {
|
|
119
|
-
frag.appendChild(list_el);
|
|
120
|
-
}
|
|
121
|
-
list_el = document.createElement('ol');
|
|
122
|
-
list_type = 'ol';
|
|
123
|
-
}
|
|
124
|
-
const li = document.createElement('li');
|
|
125
|
-
appendInline(li, m[2]);
|
|
126
|
-
list_el.appendChild(li);
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// otherwise: paragraph text; accumulate to join with spaces
|
|
131
|
-
para.push(line.trim());
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// flush leftovers
|
|
135
|
-
if (in_code) {
|
|
136
|
-
const pre = document.createElement('pre');
|
|
137
|
-
const code = document.createElement('code');
|
|
138
|
-
code.textContent = code_acc.join('\n');
|
|
139
|
-
pre.appendChild(code);
|
|
140
|
-
frag.appendChild(pre);
|
|
141
|
-
}
|
|
142
|
-
flushParagraph(para);
|
|
143
|
-
if (list_el !== null) {
|
|
144
|
-
frag.appendChild(list_el);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
return frag;
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Append inline content parsing backticks and links safely.
|
|
151
|
-
* - Inline code: `text`
|
|
152
|
-
* - Links: [text](url) where url starts with http, https, or mailto
|
|
153
|
-
* @param {HTMLElement} el
|
|
154
|
-
* @param {string} text
|
|
155
|
-
*/
|
|
156
|
-
function appendInline(el, text) {
|
|
157
|
-
/** @type {RegExp} */
|
|
158
|
-
const re = /(`[^`]+`)|(\[[^\]]+\]\([^)]+\))/g;
|
|
159
|
-
/** @type {number} */
|
|
160
|
-
let last = 0;
|
|
161
|
-
/** @type {any} */
|
|
162
|
-
let match = re.exec(text);
|
|
163
|
-
while (match) {
|
|
164
|
-
const idx = match.index;
|
|
165
|
-
if (idx > last) {
|
|
166
|
-
el.appendChild(document.createTextNode(text.slice(last, idx)));
|
|
167
|
-
}
|
|
168
|
-
const token = match[0];
|
|
169
|
-
if (token[0] === '\u0060') {
|
|
170
|
-
const code = document.createElement('code');
|
|
171
|
-
code.textContent = token.slice(1, -1);
|
|
172
|
-
el.appendChild(code);
|
|
173
|
-
} else if (token[0] === '[') {
|
|
174
|
-
// parse [text](url)
|
|
175
|
-
const m2 = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token);
|
|
176
|
-
if (m2) {
|
|
177
|
-
const a = document.createElement('a');
|
|
178
|
-
const href = m2[2].trim();
|
|
179
|
-
const allowed = /^(https?:|mailto:)/i.test(href);
|
|
180
|
-
if (allowed) {
|
|
181
|
-
a.setAttribute('href', href);
|
|
182
|
-
} else {
|
|
183
|
-
// if not allowed, render as text to avoid XSS vectors
|
|
184
|
-
a.remove();
|
|
185
|
-
el.appendChild(document.createTextNode(m2[1] + ' (' + href + ')'));
|
|
186
|
-
last = re.lastIndex;
|
|
187
|
-
match = re.exec(text);
|
|
188
|
-
continue;
|
|
189
|
-
}
|
|
190
|
-
a.appendChild(document.createTextNode(m2[1]));
|
|
191
|
-
el.appendChild(a);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
last = re.lastIndex;
|
|
195
|
-
match = re.exec(text);
|
|
196
|
-
}
|
|
197
|
-
if (last < text.length) {
|
|
198
|
-
el.appendChild(document.createTextNode(text.slice(last)));
|
|
199
|
-
}
|
|
200
|
-
}
|
|
16
|
+
const markdown = String(src || '');
|
|
17
|
+
/** @type {string} */
|
|
18
|
+
const parsed = /** @type {string} */ (marked.parse(markdown));
|
|
19
|
+
/** @type {string} */
|
|
20
|
+
const html_string = DOMPurify.sanitize(parsed);
|
|
21
|
+
return html`${unsafeHTML(html_string)}`;
|
|
201
22
|
}
|
|
@@ -6,9 +6,7 @@ import { priority_levels } from './priority.js';
|
|
|
6
6
|
* @returns {HTMLSpanElement}
|
|
7
7
|
*/
|
|
8
8
|
export function createPriorityBadge(priority) {
|
|
9
|
-
/** @type {number} */
|
|
10
9
|
const p = typeof priority === 'number' ? priority : 2;
|
|
11
|
-
/** @type {HTMLSpanElement} */
|
|
12
10
|
const el = document.createElement('span');
|
|
13
11
|
el.className = 'priority-badge';
|
|
14
12
|
el.classList.add(`is-p${Math.max(0, Math.min(4, p))}`);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Show a transient global toast message anchored to the viewport.
|
|
3
|
+
* @param {string} text - Message text.
|
|
4
|
+
* @param {'info'|'success'|'error'} [variant] - Visual variant.
|
|
5
|
+
* @param {number} [duration_ms] - Auto-dismiss delay in milliseconds.
|
|
6
|
+
*/
|
|
7
|
+
export function showToast(text, variant = 'info', duration_ms = 2800) {
|
|
8
|
+
const el = document.createElement('div');
|
|
9
|
+
el.className = 'toast';
|
|
10
|
+
el.textContent = text;
|
|
11
|
+
el.style.position = 'fixed';
|
|
12
|
+
el.style.right = '12px';
|
|
13
|
+
el.style.bottom = '12px';
|
|
14
|
+
el.style.zIndex = '1000';
|
|
15
|
+
el.style.color = '#fff';
|
|
16
|
+
el.style.padding = '8px 10px';
|
|
17
|
+
el.style.borderRadius = '4px';
|
|
18
|
+
el.style.fontSize = '12px';
|
|
19
|
+
if (variant === 'success') {
|
|
20
|
+
el.style.background = '#156d36';
|
|
21
|
+
} else if (variant === 'error') {
|
|
22
|
+
el.style.background = '#9f2011';
|
|
23
|
+
} else {
|
|
24
|
+
el.style.background = 'rgba(0,0,0,0.85)';
|
|
25
|
+
}
|
|
26
|
+
(document.body || document.documentElement).appendChild(el);
|
|
27
|
+
setTimeout(() => {
|
|
28
|
+
try {
|
|
29
|
+
el.remove();
|
|
30
|
+
} catch {
|
|
31
|
+
/* ignore */
|
|
32
|
+
}
|
|
33
|
+
}, duration_ms);
|
|
34
|
+
}
|
package/app/utils/type-badge.js
CHANGED
|
@@ -4,13 +4,10 @@
|
|
|
4
4
|
* @returns {HTMLSpanElement}
|
|
5
5
|
*/
|
|
6
6
|
export function createTypeBadge(issue_type) {
|
|
7
|
-
/** @type {HTMLSpanElement} */
|
|
8
7
|
const el = document.createElement('span');
|
|
9
8
|
el.className = 'type-badge';
|
|
10
9
|
|
|
11
|
-
/** @type {string} */
|
|
12
10
|
const t = (issue_type || '').toString().toLowerCase();
|
|
13
|
-
/** @type {Set<string>} */
|
|
14
11
|
const KNOWN = new Set(['bug', 'feature', 'task', 'epic', 'chore']);
|
|
15
12
|
const kind = KNOWN.has(t) ? t : 'neutral';
|
|
16
13
|
el.classList.add(`type-badge--${kind}`);
|