@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.
- package/.github/workflows/publish.yml +28 -0
- package/app/protocol.js +216 -0
- package/bin/bdui +19 -0
- package/client/index.html +12 -0
- package/client/postcss.config.js +11 -0
- package/client/src/App.tsx +35 -0
- package/client/src/components/IssueCard.tsx +73 -0
- package/client/src/components/Layout.tsx +175 -0
- package/client/src/components/Markdown.tsx +77 -0
- package/client/src/components/PriorityBadge.tsx +26 -0
- package/client/src/components/SearchDialog.tsx +137 -0
- package/client/src/components/SectionEditor.tsx +212 -0
- package/client/src/components/StatusBadge.tsx +64 -0
- package/client/src/components/TypeBadge.tsx +26 -0
- package/client/src/hooks/use-mutation.ts +55 -0
- package/client/src/hooks/use-search.ts +19 -0
- package/client/src/hooks/use-subscription.ts +187 -0
- package/client/src/index.css +133 -0
- package/client/src/lib/avatar.ts +17 -0
- package/client/src/lib/types.ts +115 -0
- package/client/src/lib/ws-client.ts +214 -0
- package/client/src/lib/ws-context.tsx +28 -0
- package/client/src/main.tsx +10 -0
- package/client/src/views/Board.tsx +200 -0
- package/client/src/views/Detail.tsx +398 -0
- package/client/src/views/List.tsx +461 -0
- package/client/tailwind.config.ts +68 -0
- package/client/tsconfig.json +16 -0
- package/client/vite.config.ts +20 -0
- package/package.json +43 -0
- package/server/app.js +120 -0
- package/server/app.test.js +30 -0
- package/server/bd.js +227 -0
- package/server/bd.test.js +194 -0
- package/server/cli/cli.test.js +207 -0
- package/server/cli/commands.integration.test.js +148 -0
- package/server/cli/commands.js +285 -0
- package/server/cli/commands.unit.test.js +408 -0
- package/server/cli/daemon.js +340 -0
- package/server/cli/daemon.test.js +31 -0
- package/server/cli/index.js +135 -0
- package/server/cli/open.js +178 -0
- package/server/cli/open.test.js +26 -0
- package/server/cli/usage.js +27 -0
- package/server/config.js +36 -0
- package/server/db.js +154 -0
- package/server/db.test.js +169 -0
- package/server/dolt-pool.js +257 -0
- package/server/dolt-queries.js +646 -0
- package/server/index.js +97 -0
- package/server/list-adapters.js +395 -0
- package/server/list-adapters.test.js +208 -0
- package/server/logging.js +23 -0
- package/server/registry-watcher.js +200 -0
- package/server/subscriptions.js +299 -0
- package/server/subscriptions.test.js +128 -0
- package/server/validators.js +124 -0
- package/server/watcher.js +139 -0
- package/server/watcher.test.js +120 -0
- package/server/ws.comments.test.js +262 -0
- package/server/ws.delete.test.js +119 -0
- package/server/ws.js +1309 -0
- package/server/ws.labels.test.js +95 -0
- package/server/ws.list-refresh.coalesce.test.js +95 -0
- package/server/ws.list-subscriptions.test.js +403 -0
- package/server/ws.mutation-window.test.js +147 -0
- package/server/ws.mutations.test.js +389 -0
- 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 }}
|
package/app/protocol.js
ADDED
|
@@ -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
|
+
}
|