@rsktash/beads-ui 0.1.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.
Files changed (68) hide show
  1. package/.github/workflows/publish.yml +28 -0
  2. package/app/protocol.js +216 -0
  3. package/bin/bdui +19 -0
  4. package/client/index.html +12 -0
  5. package/client/postcss.config.js +11 -0
  6. package/client/src/App.tsx +35 -0
  7. package/client/src/components/IssueCard.tsx +73 -0
  8. package/client/src/components/Layout.tsx +175 -0
  9. package/client/src/components/Markdown.tsx +77 -0
  10. package/client/src/components/PriorityBadge.tsx +26 -0
  11. package/client/src/components/SearchDialog.tsx +137 -0
  12. package/client/src/components/SectionEditor.tsx +212 -0
  13. package/client/src/components/StatusBadge.tsx +64 -0
  14. package/client/src/components/TypeBadge.tsx +26 -0
  15. package/client/src/hooks/use-mutation.ts +55 -0
  16. package/client/src/hooks/use-search.ts +19 -0
  17. package/client/src/hooks/use-subscription.ts +187 -0
  18. package/client/src/index.css +133 -0
  19. package/client/src/lib/avatar.ts +17 -0
  20. package/client/src/lib/types.ts +115 -0
  21. package/client/src/lib/ws-client.ts +214 -0
  22. package/client/src/lib/ws-context.tsx +28 -0
  23. package/client/src/main.tsx +10 -0
  24. package/client/src/views/Board.tsx +200 -0
  25. package/client/src/views/Detail.tsx +398 -0
  26. package/client/src/views/List.tsx +461 -0
  27. package/client/tailwind.config.ts +68 -0
  28. package/client/tsconfig.json +16 -0
  29. package/client/vite.config.ts +20 -0
  30. package/package.json +43 -0
  31. package/server/app.js +120 -0
  32. package/server/app.test.js +30 -0
  33. package/server/bd.js +227 -0
  34. package/server/bd.test.js +194 -0
  35. package/server/cli/cli.test.js +207 -0
  36. package/server/cli/commands.integration.test.js +148 -0
  37. package/server/cli/commands.js +285 -0
  38. package/server/cli/commands.unit.test.js +408 -0
  39. package/server/cli/daemon.js +340 -0
  40. package/server/cli/daemon.test.js +31 -0
  41. package/server/cli/index.js +135 -0
  42. package/server/cli/open.js +178 -0
  43. package/server/cli/open.test.js +26 -0
  44. package/server/cli/usage.js +27 -0
  45. package/server/config.js +36 -0
  46. package/server/db.js +154 -0
  47. package/server/db.test.js +169 -0
  48. package/server/dolt-pool.js +257 -0
  49. package/server/dolt-queries.js +646 -0
  50. package/server/index.js +97 -0
  51. package/server/list-adapters.js +395 -0
  52. package/server/list-adapters.test.js +208 -0
  53. package/server/logging.js +23 -0
  54. package/server/registry-watcher.js +200 -0
  55. package/server/subscriptions.js +299 -0
  56. package/server/subscriptions.test.js +128 -0
  57. package/server/validators.js +124 -0
  58. package/server/watcher.js +139 -0
  59. package/server/watcher.test.js +120 -0
  60. package/server/ws.comments.test.js +262 -0
  61. package/server/ws.delete.test.js +119 -0
  62. package/server/ws.js +1309 -0
  63. package/server/ws.labels.test.js +95 -0
  64. package/server/ws.list-refresh.coalesce.test.js +95 -0
  65. package/server/ws.list-subscriptions.test.js +403 -0
  66. package/server/ws.mutation-window.test.js +147 -0
  67. package/server/ws.mutations.test.js +389 -0
  68. package/server/ws.test.js +52 -0
@@ -0,0 +1,28 @@
1
+ name: Publish to npm
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ id-token: write
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - uses: actions/setup-node@v4
18
+ with:
19
+ node-version: 20
20
+ registry-url: https://registry.npmjs.org
21
+
22
+ - run: npm ci
23
+
24
+ - run: npm run build
25
+
26
+ - run: npm publish --provenance --access public
27
+ env:
28
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Protocol definitions for beads-ui WebSocket communication.
3
+ *
4
+ * Conventions
5
+ * - All messages are JSON objects.
6
+ * - Client → Server uses RequestEnvelope.
7
+ * - Server → Client uses ReplyEnvelope.
8
+ * - Every request is correlated by `id` in replies.
9
+ * - Server can also send unsolicited events (e.g., subscription `snapshot`).
10
+ */
11
+
12
+ /** @typedef {'list-issues'|'update-status'|'edit-text'|'update-priority'|'create-issue'|'list-ready'|'dep-add'|'dep-remove'|'epic-status'|'update-assignee'|'label-add'|'label-remove'|'subscribe-list'|'unsubscribe-list'|'snapshot'|'upsert'|'delete'|'get-comments'|'add-comment'|'delete-issue'|'list-workspaces'|'set-workspace'|'get-workspace'|'workspace-changed'} MessageType */
13
+
14
+ /**
15
+ * @typedef {Object} RequestEnvelope
16
+ * @property {string} id - Unique id to correlate request/response.
17
+ * @property {MessageType} type - Message type.
18
+ * @property {unknown} [payload] - Message payload.
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} ErrorObject
23
+ * @property {string} code - Stable error code.
24
+ * @property {string} message - Human-readable message.
25
+ * @property {unknown} [details] - Optional extra info for debugging.
26
+ */
27
+
28
+ /**
29
+ * @typedef {Object} ReplyEnvelope
30
+ * @property {string} id - Correlates to the originating request.
31
+ * @property {boolean} ok - True when request succeeded; false on error.
32
+ * @property {MessageType} type - Echoes request type (or event type).
33
+ * @property {unknown} [payload] - Response payload.
34
+ * @property {ErrorObject} [error] - Present when ok=false.
35
+ */
36
+
37
+ /** @type {MessageType[]} */
38
+ export const MESSAGE_TYPES = /** @type {const} */ ([
39
+ 'list-issues',
40
+ 'update-status',
41
+ 'edit-text',
42
+ 'update-priority',
43
+ 'create-issue',
44
+ 'list-ready',
45
+ 'dep-add',
46
+ 'dep-remove',
47
+ 'epic-status',
48
+ 'update-assignee',
49
+ 'label-add',
50
+ 'label-remove',
51
+ 'subscribe-list',
52
+ 'unsubscribe-list',
53
+ // vNext per-subscription full-issue push events
54
+ 'snapshot',
55
+ 'upsert',
56
+ 'delete',
57
+ // Comments
58
+ 'get-comments',
59
+ 'add-comment',
60
+ // Delete issue
61
+ 'delete-issue',
62
+ // Workspace management
63
+ 'list-workspaces',
64
+ 'set-workspace',
65
+ 'get-workspace',
66
+ 'workspace-changed'
67
+ ]);
68
+
69
+ /**
70
+ * Generate a lexically sortable request id.
71
+ *
72
+ * @returns {string}
73
+ */
74
+ export function nextId() {
75
+ const now = Date.now().toString(36);
76
+ const rand = Math.random().toString(36).slice(2, 8);
77
+ return `${now}-${rand}`;
78
+ }
79
+
80
+ /**
81
+ * Create a request envelope.
82
+ *
83
+ * @param {MessageType} type - Message type.
84
+ * @param {unknown} [payload] - Message payload.
85
+ * @param {string} [id] - Optional id; generated if omitted.
86
+ * @returns {RequestEnvelope}
87
+ */
88
+ export function makeRequest(type, payload, id = nextId()) {
89
+ return { id, type, payload };
90
+ }
91
+
92
+ /**
93
+ * Create a successful reply envelope for a given request.
94
+ *
95
+ * @param {RequestEnvelope} req - Original request.
96
+ * @param {unknown} [payload] - Reply payload.
97
+ * @returns {ReplyEnvelope}
98
+ */
99
+ export function makeOk(req, payload) {
100
+ return { id: req.id, ok: true, type: req.type, payload };
101
+ }
102
+
103
+ /**
104
+ * Create an error reply envelope for a given request.
105
+ *
106
+ * @param {RequestEnvelope} req - Original request.
107
+ * @param {string} code
108
+ * @param {string} message
109
+ * @param {unknown} [details]
110
+ * @returns {ReplyEnvelope}
111
+ */
112
+ export function makeError(req, code, message, details) {
113
+ return {
114
+ id: req.id,
115
+ ok: false,
116
+ type: req.type,
117
+ error: { code, message, details }
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Check if a value is a plain object.
123
+ *
124
+ * @param {unknown} value
125
+ * @returns {value is Record<string, unknown>}
126
+ */
127
+ function isRecord(value) {
128
+ return !!value && typeof value === 'object' && !Array.isArray(value);
129
+ }
130
+
131
+ /**
132
+ * Type guard for MessageType values.
133
+ *
134
+ * @param {unknown} value
135
+ * @returns {value is MessageType}
136
+ */
137
+ export function isMessageType(value) {
138
+ return (
139
+ typeof value === 'string' &&
140
+ MESSAGE_TYPES.includes(/** @type {MessageType} */ (value))
141
+ );
142
+ }
143
+
144
+ /**
145
+ * Type guard for RequestEnvelope.
146
+ *
147
+ * @param {unknown} value
148
+ * @returns {value is RequestEnvelope}
149
+ */
150
+ export function isRequest(value) {
151
+ if (!isRecord(value)) {
152
+ return false;
153
+ }
154
+ return (
155
+ typeof value.id === 'string' &&
156
+ typeof value.type === 'string' &&
157
+ (value.payload === undefined || 'payload' in value)
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Type guard for ReplyEnvelope.
163
+ *
164
+ * @param {unknown} value
165
+ * @returns {value is ReplyEnvelope}
166
+ */
167
+ export function isReply(value) {
168
+ if (!isRecord(value)) {
169
+ return false;
170
+ }
171
+ if (
172
+ typeof value.id !== 'string' ||
173
+ typeof value.ok !== 'boolean' ||
174
+ !isMessageType(value.type)
175
+ ) {
176
+ return false;
177
+ }
178
+ if (value.ok === false) {
179
+ const err = value.error;
180
+ if (
181
+ !isRecord(err) ||
182
+ typeof err.code !== 'string' ||
183
+ typeof err.message !== 'string'
184
+ ) {
185
+ return false;
186
+ }
187
+ }
188
+ return true;
189
+ }
190
+
191
+ /**
192
+ * Normalize and validate an incoming JSON value as a RequestEnvelope.
193
+ * Throws a user-friendly error if invalid.
194
+ *
195
+ * @param {unknown} json
196
+ * @returns {RequestEnvelope}
197
+ */
198
+ export function decodeRequest(json) {
199
+ if (!isRequest(json)) {
200
+ throw new Error('Invalid request envelope');
201
+ }
202
+ return json;
203
+ }
204
+
205
+ /**
206
+ * Normalize and validate an incoming JSON value as a ReplyEnvelope.
207
+ *
208
+ * @param {unknown} json
209
+ * @returns {ReplyEnvelope}
210
+ */
211
+ export function decodeReply(json) {
212
+ if (!isReply(json)) {
213
+ throw new Error('Invalid reply envelope');
214
+ }
215
+ return json;
216
+ }
package/bin/bdui ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const serverDir = resolve(__dirname, "..");
7
+
8
+ // Set static dir for serving the Vite build output
9
+ process.env.BEADS_UI_STATIC = resolve(serverDir, "dist");
10
+
11
+ // Default to port 3333 to avoid conflicts with common dev servers on 3000
12
+ if (!process.env.PORT) {
13
+ process.env.PORT = "3333";
14
+ }
15
+
16
+ // Preserve the caller's working directory — the server uses cwd() to
17
+ // resolve the beads database, so we must NOT chdir.
18
+
19
+ import("../server/index.js");
@@ -0,0 +1,12 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Beads UI</title>
7
+ </head>
8
+ <body style="background: #FDFBF7; color: #1A1A1A;">
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,11 @@
1
+ import path from "path";
2
+ import { fileURLToPath } from "url";
3
+
4
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
5
+
6
+ export default {
7
+ plugins: {
8
+ tailwindcss: { config: path.resolve(__dirname, "tailwind.config.ts") },
9
+ autoprefixer: {},
10
+ },
11
+ };
@@ -0,0 +1,35 @@
1
+ import { WsProvider } from "./lib/ws-context";
2
+ import { Layout } from "./components/Layout";
3
+ import { Board } from "./views/Board";
4
+ import { List } from "./views/List";
5
+ import { Detail } from "./views/Detail";
6
+ import { SearchDialog } from "./components/SearchDialog";
7
+
8
+ export function App() {
9
+ return (
10
+ <WsProvider>
11
+ <SearchDialog />
12
+ <Layout>
13
+ {(route) => {
14
+ if (route.startsWith("#/detail/")) {
15
+ const id = route.replace("#/detail/", "");
16
+ return <Detail key={id} issueId={id} />;
17
+ }
18
+ if (route.startsWith("#/list")) return <List />;
19
+ if (
20
+ route.startsWith("#/board") ||
21
+ route === "" ||
22
+ route === "#/" ||
23
+ route === "#"
24
+ )
25
+ return <Board />;
26
+ return (
27
+ <div className="p-6 text-stone-400">
28
+ View not implemented yet
29
+ </div>
30
+ );
31
+ }}
32
+ </Layout>
33
+ </WsProvider>
34
+ );
35
+ }
@@ -0,0 +1,73 @@
1
+ import { PriorityBadge } from "./PriorityBadge";
2
+ import { TypeBadge } from "./TypeBadge";
3
+ import { getInitials, getAvatarColor } from "../lib/avatar";
4
+ import type { Issue } from "../lib/types";
5
+
6
+ const TYPE_BORDER_COLORS: Record<string, string> = {
7
+ epic: "#7C3AED",
8
+ feature: "#6366F1",
9
+ bug: "#EF4444",
10
+ task: "#16A34A",
11
+ chore: "#78716C",
12
+ };
13
+
14
+ export function IssueCard({
15
+ issue,
16
+ onClick,
17
+ dimmed = false,
18
+ }: {
19
+ issue: Issue;
20
+ onClick: () => void;
21
+ dimmed?: boolean;
22
+ }) {
23
+ const borderColor = TYPE_BORDER_COLORS[issue.issue_type] ?? "#78716C";
24
+
25
+ return (
26
+ <button
27
+ onClick={onClick}
28
+ className={`issue-card group w-full text-left relative cursor-pointer ${dimmed ? "issue-card--dimmed" : ""}`}
29
+ style={{ borderLeft: `3px solid ${borderColor}` }}
30
+ >
31
+ <div className="px-3.5 py-3">
32
+ {/* Issue ID */}
33
+ <div className="mb-1">
34
+ <span
35
+ className="font-mono text-xs"
36
+ style={{ color: "var(--text-tertiary)" }}
37
+ >
38
+ {issue.id}
39
+ </span>
40
+ </div>
41
+
42
+ {/* Title */}
43
+ <p
44
+ className="text-sm font-medium line-clamp-2 mb-2.5"
45
+ style={{ color: "var(--text-primary)" }}
46
+ >
47
+ {issue.title}
48
+ </p>
49
+
50
+ {/* Type badge + Priority badge + Assignee avatar */}
51
+ <div className="flex items-center gap-1.5">
52
+ <TypeBadge type={issue.issue_type} />
53
+ <PriorityBadge priority={issue.priority} />
54
+ <div className="flex-1" />
55
+ {issue.assignee && (
56
+ <div
57
+ className="flex items-center justify-center rounded-full text-[10px] font-bold shrink-0"
58
+ style={{
59
+ width: "24px",
60
+ height: "24px",
61
+ backgroundColor: getAvatarColor(issue.assignee),
62
+ color: "var(--text-inverse)",
63
+ }}
64
+ title={issue.assignee}
65
+ >
66
+ {getInitials(issue.assignee)}
67
+ </div>
68
+ )}
69
+ </div>
70
+ </div>
71
+ </button>
72
+ );
73
+ }
@@ -0,0 +1,175 @@
1
+ import { useState, useEffect, type ReactNode } from "react";
2
+ import { useWs } from "../lib/ws-context";
3
+
4
+ function useHashRoute(): string {
5
+ const [hash, setHash] = useState(window.location.hash || "#/board");
6
+ useEffect(() => {
7
+ const handler = () => setHash(window.location.hash || "#/board");
8
+ window.addEventListener("hashchange", handler);
9
+ return () => window.removeEventListener("hashchange", handler);
10
+ }, []);
11
+ return hash;
12
+ }
13
+
14
+ function NavLink({ href, label, icon, active }: { href: string; label: string; icon: ReactNode; active: boolean }) {
15
+ return (
16
+ <a
17
+ href={href}
18
+ className="flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors"
19
+ style={{
20
+ background: active ? "var(--bg-hover)" : "transparent",
21
+ color: active ? "var(--text-primary)" : "var(--text-secondary)",
22
+ fontWeight: active ? 500 : 400,
23
+ }}
24
+ onMouseEnter={(e) => {
25
+ if (!active) e.currentTarget.style.background = "var(--bg-hover)";
26
+ }}
27
+ onMouseLeave={(e) => {
28
+ if (!active) e.currentTarget.style.background = "transparent";
29
+ }}
30
+ >
31
+ {icon}
32
+ {label}
33
+ </a>
34
+ );
35
+ }
36
+
37
+ const BoardIcon = () => (
38
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
39
+ <rect x="1" y="1" width="5" height="6" rx="1" />
40
+ <rect x="10" y="1" width="5" height="9" rx="1" />
41
+ <rect x="1" y="9" width="5" height="6" rx="1" />
42
+ <rect x="10" y="12" width="5" height="3" rx="1" />
43
+ </svg>
44
+ );
45
+
46
+ const ListIcon = () => (
47
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
48
+ <line x1="5" y1="4" x2="14" y2="4" />
49
+ <line x1="5" y1="8" x2="14" y2="8" />
50
+ <line x1="5" y1="12" x2="14" y2="12" />
51
+ <circle cx="2" cy="4" r="0.75" fill="currentColor" stroke="none" />
52
+ <circle cx="2" cy="8" r="0.75" fill="currentColor" stroke="none" />
53
+ <circle cx="2" cy="12" r="0.75" fill="currentColor" stroke="none" />
54
+ </svg>
55
+ );
56
+
57
+ export function Layout({
58
+ children,
59
+ }: {
60
+ children: (route: string) => ReactNode;
61
+ }) {
62
+ const route = useHashRoute();
63
+ const ws = useWs();
64
+ const [projectName, setProjectName] = useState("");
65
+
66
+ useEffect(() => {
67
+ ws.getWorkspace().then((res: any) => {
68
+ if (res?.db_path) {
69
+ // db_path is like /path/to/project/.beads — parent is the project
70
+ const parts = res.db_path.split("/").filter(Boolean);
71
+ const beadsIdx = parts.lastIndexOf(".beads");
72
+ if (beadsIdx > 0) {
73
+ setProjectName(parts[beadsIdx - 1]);
74
+ return;
75
+ }
76
+ }
77
+ if (res?.root_dir) {
78
+ setProjectName(res.root_dir.split("/").pop() || "");
79
+ }
80
+ }).catch(() => {});
81
+ }, [ws]);
82
+
83
+ return (
84
+ <div className="flex h-screen" style={{ background: "var(--bg-base)" }}>
85
+ <nav
86
+ className="flex flex-col"
87
+ style={{
88
+ width: "200px",
89
+ borderRight: "1px solid var(--border-subtle)",
90
+ background: "var(--bg-base)",
91
+ }}
92
+ >
93
+ {/* Logo */}
94
+ <div className="flex items-center gap-2.5 px-4 py-5">
95
+ <div
96
+ className="flex items-center justify-center rounded-md"
97
+ style={{
98
+ width: "28px",
99
+ height: "28px",
100
+ background: "var(--accent)",
101
+ }}
102
+ >
103
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="white">
104
+ <circle cx="7" cy="4" r="2.5" />
105
+ <circle cx="4" cy="10" r="2.5" />
106
+ <circle cx="10" cy="10" r="2.5" />
107
+ </svg>
108
+ </div>
109
+ <span
110
+ className="font-bold"
111
+ style={{
112
+ fontSize: "16px",
113
+ color: "var(--text-primary)",
114
+ }}
115
+ >
116
+ Beads
117
+ </span>
118
+ </div>
119
+
120
+ {/* Project name */}
121
+ {projectName && (
122
+ <div className="px-4 pb-3 mb-1" style={{ borderBottom: "1px solid var(--border-subtle)" }}>
123
+ <span className="text-xs font-mono" style={{ color: "var(--text-tertiary)" }}>
124
+ {projectName}
125
+ </span>
126
+ </div>
127
+ )}
128
+
129
+ {/* Nav links */}
130
+ <div className="px-2 space-y-0.5">
131
+ <NavLink
132
+ href="#/board"
133
+ label="Board"
134
+ icon={<BoardIcon />}
135
+ active={route.startsWith("#/board") || route === "" || route === "#/" || route === "#"}
136
+ />
137
+ <NavLink
138
+ href="#/list"
139
+ label="List"
140
+ icon={<ListIcon />}
141
+ active={route.startsWith("#/list")}
142
+ />
143
+ </div>
144
+
145
+ {/* Spacer */}
146
+ <div className="flex-1" />
147
+
148
+ {/* User card */}
149
+ <div
150
+ className="mx-3 mb-3 px-3 py-2.5 rounded-md flex items-center gap-2.5"
151
+ style={{
152
+ border: "1px solid var(--border-subtle)",
153
+ }}
154
+ >
155
+ <div
156
+ className="flex items-center justify-center rounded-full text-xs font-bold shrink-0"
157
+ style={{
158
+ width: "28px",
159
+ height: "28px",
160
+ background: "var(--accent)",
161
+ color: "white",
162
+ }}
163
+ >
164
+ R
165
+ </div>
166
+ <div>
167
+ <div className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>Rustam</div>
168
+ <div style={{ fontSize: "11px", color: "var(--text-tertiary)" }}>Developer</div>
169
+ </div>
170
+ </div>
171
+ </nav>
172
+ <main className="flex-1 overflow-auto">{children(route)}</main>
173
+ </div>
174
+ );
175
+ }
@@ -0,0 +1,77 @@
1
+ import { useEffect, useState } from "react";
2
+ import { marked } from "marked";
3
+ import DOMPurify from "dompurify";
4
+ import { codeToHtml } from "shiki";
5
+
6
+ export function Markdown({ content }: { content: string }) {
7
+ const [html, setHtml] = useState("");
8
+
9
+ useEffect(() => {
10
+ if (!content) {
11
+ setHtml("");
12
+ return;
13
+ }
14
+
15
+ let cancelled = false;
16
+
17
+ async function render() {
18
+ const tokens = marked.lexer(content);
19
+
20
+ // Collect code blocks for syntax highlighting
21
+ const codeBlocks: Array<{ code: string; lang: string }> = [];
22
+ marked.walkTokens(tokens, (token) => {
23
+ if (token.type === "code") {
24
+ codeBlocks.push({
25
+ code: (token as { text: string }).text,
26
+ lang: (token as { lang?: string }).lang || "text",
27
+ });
28
+ }
29
+ });
30
+
31
+ // Highlight all code blocks in parallel
32
+ const highlighted = await Promise.all(
33
+ codeBlocks.map(async (block) => {
34
+ try {
35
+ return await codeToHtml(block.code, {
36
+ lang: block.lang,
37
+ theme: "github-light",
38
+ });
39
+ } catch {
40
+ return `<pre><code>${DOMPurify.sanitize(block.code)}</code></pre>`;
41
+ }
42
+ }),
43
+ );
44
+
45
+ // Render markdown and replace code blocks with highlighted versions
46
+ let rendered = marked.parser(tokens) as string;
47
+ let i = 0;
48
+ rendered = rendered.replace(
49
+ /<pre><code[^>]*>[\s\S]*?<\/code><\/pre>/g,
50
+ () => highlighted[i++] || "",
51
+ );
52
+
53
+ const sanitized = DOMPurify.sanitize(rendered, {
54
+ ADD_TAGS: ["span"],
55
+ ADD_ATTR: ["style", "class"],
56
+ });
57
+
58
+ if (!cancelled) setHtml(sanitized);
59
+ }
60
+
61
+ render();
62
+ return () => {
63
+ cancelled = true;
64
+ };
65
+ }, [content]);
66
+
67
+ return (
68
+ <div
69
+ className="prose prose-stone prose-sm max-w-none
70
+ prose-pre:bg-stone-50 prose-pre:border prose-pre:border-stone-200 prose-pre:rounded
71
+ prose-code:text-sm prose-code:font-mono
72
+ prose-headings:font-semibold prose-headings:text-stone-900
73
+ prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline"
74
+ dangerouslySetInnerHTML={{ __html: html }}
75
+ />
76
+ );
77
+ }
@@ -0,0 +1,26 @@
1
+ const PRIORITY_CONFIG: Record<number, { bg: string; text: string }> = {
2
+ 0: { bg: "rgba(239,68,68,0.12)", text: "#DC2626" },
3
+ 1: { bg: "rgba(249,115,22,0.12)", text: "#EA580C" },
4
+ 2: { bg: "rgba(59,130,246,0.12)", text: "#2563EB" },
5
+ 3: { bg: "rgba(34,197,94,0.12)", text: "#16A34A" },
6
+ 4: { bg: "rgba(156,163,175,0.1)", text: "#9CA3AF" },
7
+ };
8
+
9
+ export function PriorityBadge({ priority }: { priority: number }) {
10
+ const config = PRIORITY_CONFIG[priority] || PRIORITY_CONFIG[4];
11
+ return (
12
+ <span
13
+ className="inline-flex items-center justify-center font-mono font-bold"
14
+ style={{
15
+ fontSize: "11px",
16
+ lineHeight: 1,
17
+ padding: "3px 8px",
18
+ borderRadius: "var(--radius-sm)",
19
+ backgroundColor: config.bg,
20
+ color: config.text,
21
+ }}
22
+ >
23
+ P{priority}
24
+ </span>
25
+ );
26
+ }