@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,200 @@
|
|
|
1
|
+
import { useMemo, useState, useRef, useEffect } from "react";
|
|
2
|
+
import { useSubscription } from "../hooks/use-subscription";
|
|
3
|
+
import { IssueCard } from "../components/IssueCard";
|
|
4
|
+
import type { SubscriptionType } from "../lib/types";
|
|
5
|
+
|
|
6
|
+
const COLUMN_PAGE_SIZE = 20;
|
|
7
|
+
|
|
8
|
+
const STATUS_COLORS: Record<string, string> = {
|
|
9
|
+
open: "var(--status-open)",
|
|
10
|
+
in_progress: "var(--status-in-progress)",
|
|
11
|
+
blocked: "var(--status-blocked)",
|
|
12
|
+
closed: "var(--status-closed)",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function Column({
|
|
16
|
+
title,
|
|
17
|
+
subscriptionType,
|
|
18
|
+
statusKey,
|
|
19
|
+
onCardClick,
|
|
20
|
+
isClosed = false,
|
|
21
|
+
}: {
|
|
22
|
+
title: string;
|
|
23
|
+
subscriptionType: SubscriptionType;
|
|
24
|
+
statusKey: string;
|
|
25
|
+
onCardClick: (id: string) => void;
|
|
26
|
+
isClosed?: boolean;
|
|
27
|
+
}) {
|
|
28
|
+
const [limit, setLimit] = useState(COLUMN_PAGE_SIZE);
|
|
29
|
+
const params = useMemo(() => ({ limit, offset: 0 }), [limit]);
|
|
30
|
+
const { issues, loading, total } = useSubscription(subscriptionType, params);
|
|
31
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
32
|
+
const [hasOverflow, setHasOverflow] = useState(false);
|
|
33
|
+
|
|
34
|
+
const hasMore = total > issues.length;
|
|
35
|
+
const remaining = total - issues.length;
|
|
36
|
+
const dotColor = STATUS_COLORS[statusKey] ?? "var(--text-tertiary)";
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const el = scrollRef.current;
|
|
40
|
+
if (!el) return;
|
|
41
|
+
const check = () => setHasOverflow(el.scrollHeight > el.clientHeight);
|
|
42
|
+
check();
|
|
43
|
+
const observer = new ResizeObserver(check);
|
|
44
|
+
observer.observe(el);
|
|
45
|
+
return () => observer.disconnect();
|
|
46
|
+
}, [issues.length]);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="flex-1 min-w-[280px] max-w-[360px] flex flex-col self-start">
|
|
50
|
+
{/* Column header */}
|
|
51
|
+
<div
|
|
52
|
+
className="flex items-center gap-2.5 px-1 pb-3 mb-3"
|
|
53
|
+
style={{ borderBottom: "1px solid var(--border-subtle)" }}
|
|
54
|
+
>
|
|
55
|
+
<div
|
|
56
|
+
className="rounded-full shrink-0"
|
|
57
|
+
style={{
|
|
58
|
+
width: "10px",
|
|
59
|
+
height: "10px",
|
|
60
|
+
backgroundColor: dotColor,
|
|
61
|
+
boxShadow: `0 0 0 3px ${dotColor}33`,
|
|
62
|
+
}}
|
|
63
|
+
/>
|
|
64
|
+
<h2
|
|
65
|
+
className="text-sm font-semibold"
|
|
66
|
+
style={{ color: "var(--text-primary)" }}
|
|
67
|
+
>
|
|
68
|
+
{title}
|
|
69
|
+
</h2>
|
|
70
|
+
<span
|
|
71
|
+
className="text-xs font-medium px-2 py-0.5 rounded-full ml-auto"
|
|
72
|
+
style={{
|
|
73
|
+
backgroundColor: "rgba(0,0,0,0.05)",
|
|
74
|
+
color: "var(--text-tertiary)",
|
|
75
|
+
fontSize: "11px",
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
{loading ? "\u2026" : isClosed && total > issues.length ? `${issues.length} / ${total}` : total}
|
|
79
|
+
</span>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
{/* Card list */}
|
|
83
|
+
<div
|
|
84
|
+
ref={scrollRef}
|
|
85
|
+
className="space-y-2 overflow-y-auto flex-1 pr-1 relative"
|
|
86
|
+
style={{
|
|
87
|
+
maxHeight: "calc(100vh - 160px)",
|
|
88
|
+
maskImage: hasOverflow
|
|
89
|
+
? "linear-gradient(to bottom, transparent 0px, black 8px, black calc(100% - 8px), transparent 100%)"
|
|
90
|
+
: undefined,
|
|
91
|
+
WebkitMaskImage: hasOverflow
|
|
92
|
+
? "linear-gradient(to bottom, transparent 0px, black 8px, black calc(100% - 8px), transparent 100%)"
|
|
93
|
+
: undefined,
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
{loading && issues.length === 0 && (
|
|
97
|
+
<div className="py-8 text-center">
|
|
98
|
+
<p className="text-xs" style={{ color: "var(--text-tertiary)" }}>
|
|
99
|
+
Loading…
|
|
100
|
+
</p>
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
|
|
104
|
+
{!loading && issues.length === 0 && (
|
|
105
|
+
<div
|
|
106
|
+
className="py-8 text-center"
|
|
107
|
+
style={{
|
|
108
|
+
border: "2px dashed var(--border-default)",
|
|
109
|
+
borderRadius: "var(--radius-md)",
|
|
110
|
+
minHeight: "100px",
|
|
111
|
+
display: "flex",
|
|
112
|
+
alignItems: "center",
|
|
113
|
+
justifyContent: "center",
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
<p className="text-xs" style={{ color: "var(--text-tertiary)" }}>
|
|
117
|
+
No items
|
|
118
|
+
</p>
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{issues.map((issue) => (
|
|
123
|
+
<IssueCard
|
|
124
|
+
key={issue.id}
|
|
125
|
+
issue={issue}
|
|
126
|
+
onClick={() => onCardClick(issue.id)}
|
|
127
|
+
dimmed={isClosed}
|
|
128
|
+
/>
|
|
129
|
+
))}
|
|
130
|
+
|
|
131
|
+
{hasMore && (
|
|
132
|
+
<button
|
|
133
|
+
onClick={() => setLimit((l) => l + COLUMN_PAGE_SIZE)}
|
|
134
|
+
className="w-full text-xs py-2"
|
|
135
|
+
style={{
|
|
136
|
+
color: "var(--text-secondary)",
|
|
137
|
+
transition: "color 120ms ease",
|
|
138
|
+
}}
|
|
139
|
+
onMouseEnter={(e) => { e.currentTarget.style.color = "var(--accent)"; }}
|
|
140
|
+
onMouseLeave={(e) => { e.currentTarget.style.color = "var(--text-secondary)"; }}
|
|
141
|
+
>
|
|
142
|
+
Show {remaining} more…
|
|
143
|
+
</button>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function Board() {
|
|
151
|
+
const { total: totalIssues } = useSubscription("all-issues", { limit: 1, offset: 0 });
|
|
152
|
+
const { total: totalEpics } = useSubscription("epics", { limit: 1, offset: 0 });
|
|
153
|
+
|
|
154
|
+
const navigateToDetail = (id: string) => {
|
|
155
|
+
window.location.hash = `#/detail/${id}`;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<div className="p-6" style={{ background: "var(--bg-base)" }}>
|
|
160
|
+
<div className="mb-6">
|
|
161
|
+
<h1
|
|
162
|
+
className="text-xl font-bold"
|
|
163
|
+
style={{ color: "var(--text-primary)" }}
|
|
164
|
+
>
|
|
165
|
+
Board
|
|
166
|
+
</h1>
|
|
167
|
+
<p className="text-sm mt-0.5" style={{ color: "var(--text-tertiary)" }}>
|
|
168
|
+
{totalIssues} issues across {totalEpics} epics
|
|
169
|
+
</p>
|
|
170
|
+
</div>
|
|
171
|
+
<div className="flex gap-4 overflow-x-auto items-start">
|
|
172
|
+
<Column
|
|
173
|
+
title="Open"
|
|
174
|
+
subscriptionType="ready-issues"
|
|
175
|
+
statusKey="open"
|
|
176
|
+
onCardClick={navigateToDetail}
|
|
177
|
+
/>
|
|
178
|
+
<Column
|
|
179
|
+
title="In Progress"
|
|
180
|
+
subscriptionType="in-progress-issues"
|
|
181
|
+
statusKey="in_progress"
|
|
182
|
+
onCardClick={navigateToDetail}
|
|
183
|
+
/>
|
|
184
|
+
<Column
|
|
185
|
+
title="Blocked"
|
|
186
|
+
subscriptionType="blocked-issues"
|
|
187
|
+
statusKey="blocked"
|
|
188
|
+
onCardClick={navigateToDetail}
|
|
189
|
+
/>
|
|
190
|
+
<Column
|
|
191
|
+
title="Closed"
|
|
192
|
+
subscriptionType="closed-issues"
|
|
193
|
+
statusKey="closed"
|
|
194
|
+
onCardClick={navigateToDetail}
|
|
195
|
+
isClosed
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useSubscription } from "../hooks/use-subscription";
|
|
3
|
+
import { useMutation } from "../hooks/use-mutation";
|
|
4
|
+
import { StatusBadge } from "../components/StatusBadge";
|
|
5
|
+
import { PriorityBadge } from "../components/PriorityBadge";
|
|
6
|
+
import { TypeBadge } from "../components/TypeBadge";
|
|
7
|
+
import { SectionEditor } from "../components/SectionEditor";
|
|
8
|
+
import { getInitials, getAvatarColor } from "../lib/avatar";
|
|
9
|
+
import type { Issue } from "../lib/types";
|
|
10
|
+
|
|
11
|
+
function MetadataCard({ label, children }: { label: string; children: React.ReactNode }) {
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
className="px-3 py-2.5 rounded-md"
|
|
15
|
+
style={{ border: "1px solid var(--border-subtle)" }}
|
|
16
|
+
>
|
|
17
|
+
<h3
|
|
18
|
+
className="font-semibold uppercase tracking-wider mb-1.5"
|
|
19
|
+
style={{ fontSize: "11px", color: "var(--text-tertiary)" }}
|
|
20
|
+
>
|
|
21
|
+
{label}
|
|
22
|
+
</h3>
|
|
23
|
+
{children}
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function MetadataSidebar({
|
|
29
|
+
issue,
|
|
30
|
+
onUpdate,
|
|
31
|
+
}: {
|
|
32
|
+
issue: Issue;
|
|
33
|
+
onUpdate: ReturnType<typeof useMutation>;
|
|
34
|
+
}) {
|
|
35
|
+
const parentId = issue.parent_id || (issue as any).parent;
|
|
36
|
+
const parentTitle = issue.parent_title || (issue as any).parent_title;
|
|
37
|
+
const parentStatus = issue.parent_status || (issue as any).parent_status;
|
|
38
|
+
const ts = new Date(issue.updated_at).toLocaleDateString();
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<aside
|
|
42
|
+
className="shrink-0 p-4 space-y-3 overflow-y-auto"
|
|
43
|
+
style={{
|
|
44
|
+
width: "280px",
|
|
45
|
+
borderLeft: "1px solid var(--border-subtle)",
|
|
46
|
+
background: "var(--bg-base)",
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
<MetadataCard label="Status">
|
|
50
|
+
<select
|
|
51
|
+
value={issue.status}
|
|
52
|
+
onChange={(e) =>
|
|
53
|
+
onUpdate.updateStatus(
|
|
54
|
+
issue.id,
|
|
55
|
+
e.target.value as "open" | "in_progress" | "closed",
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
className="w-full px-2 py-1 text-sm rounded-md outline-none"
|
|
59
|
+
style={{
|
|
60
|
+
border: "1px solid var(--border-default)",
|
|
61
|
+
background: "var(--bg-elevated)",
|
|
62
|
+
color: "var(--text-primary)",
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<option value="open">Open</option>
|
|
66
|
+
<option value="in_progress">In Progress</option>
|
|
67
|
+
<option value="closed">Closed</option>
|
|
68
|
+
</select>
|
|
69
|
+
</MetadataCard>
|
|
70
|
+
|
|
71
|
+
<MetadataCard label="Priority">
|
|
72
|
+
<select
|
|
73
|
+
value={issue.priority}
|
|
74
|
+
onChange={(e) =>
|
|
75
|
+
onUpdate.updatePriority(issue.id, Number(e.target.value))
|
|
76
|
+
}
|
|
77
|
+
className="w-full px-2 py-1 text-sm rounded-md outline-none"
|
|
78
|
+
style={{
|
|
79
|
+
border: "1px solid var(--border-default)",
|
|
80
|
+
background: "var(--bg-elevated)",
|
|
81
|
+
color: "var(--text-primary)",
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
{[0, 1, 2, 3, 4].map((p) => (
|
|
85
|
+
<option key={p} value={p}>P{p}</option>
|
|
86
|
+
))}
|
|
87
|
+
</select>
|
|
88
|
+
</MetadataCard>
|
|
89
|
+
|
|
90
|
+
<MetadataCard label="Type">
|
|
91
|
+
<TypeBadge type={issue.issue_type} />
|
|
92
|
+
</MetadataCard>
|
|
93
|
+
|
|
94
|
+
<MetadataCard label="Assignee">
|
|
95
|
+
{issue.assignee ? (
|
|
96
|
+
<div className="flex items-center gap-2">
|
|
97
|
+
<div
|
|
98
|
+
className="flex items-center justify-center rounded-full text-[10px] font-bold shrink-0"
|
|
99
|
+
style={{
|
|
100
|
+
width: "24px",
|
|
101
|
+
height: "24px",
|
|
102
|
+
backgroundColor: getAvatarColor(issue.assignee),
|
|
103
|
+
color: "var(--text-inverse)",
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
{getInitials(issue.assignee)}
|
|
107
|
+
</div>
|
|
108
|
+
<span className="text-sm" style={{ color: "var(--text-primary)" }}>{issue.assignee}</span>
|
|
109
|
+
</div>
|
|
110
|
+
) : (
|
|
111
|
+
<span className="text-sm" style={{ color: "var(--text-tertiary)" }}>Unassigned</span>
|
|
112
|
+
)}
|
|
113
|
+
</MetadataCard>
|
|
114
|
+
|
|
115
|
+
<MetadataCard label="Updated">
|
|
116
|
+
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>{ts}</span>
|
|
117
|
+
</MetadataCard>
|
|
118
|
+
|
|
119
|
+
{issue.labels && issue.labels.length > 0 && (
|
|
120
|
+
<MetadataCard label="Labels">
|
|
121
|
+
<div className="flex flex-wrap gap-1">
|
|
122
|
+
{issue.labels.map((l) => (
|
|
123
|
+
<span
|
|
124
|
+
key={l}
|
|
125
|
+
className="px-2 py-0.5 text-xs rounded"
|
|
126
|
+
style={{ background: "var(--bg-hover)", color: "var(--text-secondary)" }}
|
|
127
|
+
>
|
|
128
|
+
{l}
|
|
129
|
+
</span>
|
|
130
|
+
))}
|
|
131
|
+
</div>
|
|
132
|
+
</MetadataCard>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
{/* Parent */}
|
|
136
|
+
{parentId && (
|
|
137
|
+
<MetadataCard label="Parent">
|
|
138
|
+
<a
|
|
139
|
+
href={`#/detail/${parentId}`}
|
|
140
|
+
className="block rounded px-1 py-1 -mx-1 transition-colors"
|
|
141
|
+
style={{ color: "var(--text-primary)" }}
|
|
142
|
+
onMouseEnter={(e) => { e.currentTarget.style.background = "var(--bg-hover)"; }}
|
|
143
|
+
onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}
|
|
144
|
+
>
|
|
145
|
+
<div className="flex items-center gap-1.5 mb-0.5">
|
|
146
|
+
{parentStatus && <StatusBadge status={parentStatus} />}
|
|
147
|
+
<span className="font-mono text-xs" style={{ color: "var(--text-tertiary)" }}>{parentId}</span>
|
|
148
|
+
</div>
|
|
149
|
+
{parentTitle && (
|
|
150
|
+
<p className="text-xs leading-snug pl-0.5 line-clamp-2" style={{ color: "var(--text-secondary)" }}>
|
|
151
|
+
{parentTitle}
|
|
152
|
+
</p>
|
|
153
|
+
)}
|
|
154
|
+
</a>
|
|
155
|
+
</MetadataCard>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
{/* Blocked By */}
|
|
159
|
+
{(() => {
|
|
160
|
+
const blocksDeps = (issue.dependencies || []).filter(
|
|
161
|
+
(dep: any) => dep.type === "blocks" && dep.issue_id === issue.id
|
|
162
|
+
);
|
|
163
|
+
if (blocksDeps.length === 0) return null;
|
|
164
|
+
return (
|
|
165
|
+
<MetadataCard label="Blocked By">
|
|
166
|
+
<div className="space-y-1">
|
|
167
|
+
{blocksDeps.map((dep: any) => (
|
|
168
|
+
<a
|
|
169
|
+
key={dep.depends_on_id}
|
|
170
|
+
href={`#/detail/${dep.depends_on_id}`}
|
|
171
|
+
className="block text-xs font-mono px-1 py-0.5 transition-colors"
|
|
172
|
+
style={{ color: "var(--accent)" }}
|
|
173
|
+
onMouseEnter={(e) => { e.currentTarget.style.textDecoration = "underline"; }}
|
|
174
|
+
onMouseLeave={(e) => { e.currentTarget.style.textDecoration = "none"; }}
|
|
175
|
+
>
|
|
176
|
+
{dep.depends_on_id}
|
|
177
|
+
</a>
|
|
178
|
+
))}
|
|
179
|
+
</div>
|
|
180
|
+
</MetadataCard>
|
|
181
|
+
);
|
|
182
|
+
})()}
|
|
183
|
+
|
|
184
|
+
{/* Children / Dependents (sidebar) */}
|
|
185
|
+
{issue.dependents && issue.dependents.length > 0 && (
|
|
186
|
+
<MetadataCard label="Children / Dependents">
|
|
187
|
+
<div className="space-y-1">
|
|
188
|
+
{issue.dependents.map((dep) => (
|
|
189
|
+
<a
|
|
190
|
+
key={dep.id}
|
|
191
|
+
href={`#/detail/${dep.id}`}
|
|
192
|
+
className="block text-xs rounded px-1 py-1.5 -mx-1 transition-colors"
|
|
193
|
+
onMouseEnter={(e) => { e.currentTarget.style.background = "var(--bg-hover)"; }}
|
|
194
|
+
onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}
|
|
195
|
+
>
|
|
196
|
+
<div className="flex items-center gap-1.5 mb-0.5">
|
|
197
|
+
<StatusBadge status={dep.status} />
|
|
198
|
+
<span className="font-mono" style={{ color: "var(--text-tertiary)" }}>{dep.id}</span>
|
|
199
|
+
</div>
|
|
200
|
+
{dep.title && (
|
|
201
|
+
<p className="text-xs leading-snug pl-0.5 line-clamp-2" style={{ color: "var(--text-secondary)" }}>
|
|
202
|
+
{dep.title}
|
|
203
|
+
</p>
|
|
204
|
+
)}
|
|
205
|
+
</a>
|
|
206
|
+
))}
|
|
207
|
+
</div>
|
|
208
|
+
</MetadataCard>
|
|
209
|
+
)}
|
|
210
|
+
</aside>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function CommentsSection({ issueId }: { issueId: string }) {
|
|
215
|
+
const mutations = useMutation();
|
|
216
|
+
const [newComment, setNewComment] = useState("");
|
|
217
|
+
|
|
218
|
+
const handleAddComment = async () => {
|
|
219
|
+
if (!newComment.trim()) return;
|
|
220
|
+
await mutations.addComment(issueId, newComment.trim());
|
|
221
|
+
setNewComment("");
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<div
|
|
226
|
+
className="rounded-lg p-4"
|
|
227
|
+
style={{
|
|
228
|
+
background: "var(--bg-elevated)",
|
|
229
|
+
border: "1px solid var(--border-subtle)",
|
|
230
|
+
boxShadow: "var(--shadow-card)",
|
|
231
|
+
}}
|
|
232
|
+
>
|
|
233
|
+
<h3
|
|
234
|
+
className="font-semibold uppercase tracking-wider mb-3"
|
|
235
|
+
style={{ fontSize: "11px", color: "var(--text-tertiary)" }}
|
|
236
|
+
>
|
|
237
|
+
Comments
|
|
238
|
+
</h3>
|
|
239
|
+
<div className="flex gap-2">
|
|
240
|
+
<textarea
|
|
241
|
+
value={newComment}
|
|
242
|
+
onChange={(e) => setNewComment(e.target.value)}
|
|
243
|
+
placeholder="Add a comment... (Ctrl+Enter to submit)"
|
|
244
|
+
rows={3}
|
|
245
|
+
className="flex-1 p-2 text-sm rounded-md resize-y outline-none"
|
|
246
|
+
style={{
|
|
247
|
+
border: "1px solid var(--border-default)",
|
|
248
|
+
background: "var(--bg-base)",
|
|
249
|
+
color: "var(--text-primary)",
|
|
250
|
+
}}
|
|
251
|
+
onFocus={(e) => { e.currentTarget.style.borderColor = "var(--accent)"; }}
|
|
252
|
+
onBlur={(e) => { e.currentTarget.style.borderColor = "var(--border-default)"; }}
|
|
253
|
+
onKeyDown={(e) => {
|
|
254
|
+
if (e.key === "Enter" && (e.ctrlKey || e.metaKey))
|
|
255
|
+
handleAddComment();
|
|
256
|
+
}}
|
|
257
|
+
/>
|
|
258
|
+
</div>
|
|
259
|
+
<button
|
|
260
|
+
onClick={handleAddComment}
|
|
261
|
+
className="mt-2 px-3 py-1.5 text-sm rounded-md font-medium transition-colors"
|
|
262
|
+
style={{
|
|
263
|
+
background: "var(--bg-hover)",
|
|
264
|
+
color: "var(--text-primary)",
|
|
265
|
+
border: "1px solid var(--border-default)",
|
|
266
|
+
}}
|
|
267
|
+
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--accent)"; }}
|
|
268
|
+
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--border-default)"; }}
|
|
269
|
+
>
|
|
270
|
+
Add Comment
|
|
271
|
+
</button>
|
|
272
|
+
</div>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function Detail({ issueId }: { issueId: string }) {
|
|
277
|
+
const { issues, loading } = useSubscription("issue-detail", { id: issueId });
|
|
278
|
+
const mutations = useMutation();
|
|
279
|
+
const issue = issues[0];
|
|
280
|
+
|
|
281
|
+
if (loading) return <div className="p-6" style={{ color: "var(--text-tertiary)" }}>Loading...</div>;
|
|
282
|
+
if (!issue)
|
|
283
|
+
return <div className="p-6" style={{ color: "var(--text-tertiary)" }}>Issue not found</div>;
|
|
284
|
+
|
|
285
|
+
return (
|
|
286
|
+
<div className="flex h-full" style={{ background: "var(--bg-base)" }}>
|
|
287
|
+
<div className="flex-1 overflow-y-auto p-6 space-y-5">
|
|
288
|
+
{/* Breadcrumbs */}
|
|
289
|
+
<div className="flex items-center gap-2 text-sm" style={{ color: "var(--text-tertiary)" }}>
|
|
290
|
+
<a
|
|
291
|
+
href="#/list"
|
|
292
|
+
className="flex items-center gap-1 transition-colors"
|
|
293
|
+
style={{ color: "var(--text-tertiary)" }}
|
|
294
|
+
onMouseEnter={(e) => { e.currentTarget.style.color = "var(--accent)"; }}
|
|
295
|
+
onMouseLeave={(e) => { e.currentTarget.style.color = "var(--text-tertiary)"; }}
|
|
296
|
+
>
|
|
297
|
+
← List
|
|
298
|
+
</a>
|
|
299
|
+
<span>/</span>
|
|
300
|
+
<span className="font-mono">{issue.id}</span>
|
|
301
|
+
</div>
|
|
302
|
+
|
|
303
|
+
{/* Title */}
|
|
304
|
+
<h1 className="text-2xl font-bold" style={{ color: "var(--text-primary)" }}>
|
|
305
|
+
{issue.title}
|
|
306
|
+
</h1>
|
|
307
|
+
|
|
308
|
+
{/* Description */}
|
|
309
|
+
<SectionEditor
|
|
310
|
+
label="Description"
|
|
311
|
+
value={issue.description || ""}
|
|
312
|
+
onSave={(v) =>
|
|
313
|
+
mutations.editText({
|
|
314
|
+
id: issue.id,
|
|
315
|
+
field: "description",
|
|
316
|
+
value: v,
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
/>
|
|
320
|
+
|
|
321
|
+
{/* Acceptance Criteria */}
|
|
322
|
+
<SectionEditor
|
|
323
|
+
label="Acceptance Criteria"
|
|
324
|
+
value={issue.acceptance || ""}
|
|
325
|
+
placeholder="Add acceptance criteria..."
|
|
326
|
+
onSave={(v) =>
|
|
327
|
+
mutations.editText({
|
|
328
|
+
id: issue.id,
|
|
329
|
+
field: "acceptance",
|
|
330
|
+
value: v,
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
/>
|
|
334
|
+
|
|
335
|
+
{/* Notes */}
|
|
336
|
+
<SectionEditor
|
|
337
|
+
label="Notes"
|
|
338
|
+
value={issue.notes || ""}
|
|
339
|
+
placeholder="Add notes..."
|
|
340
|
+
onSave={(v) =>
|
|
341
|
+
mutations.editText({ id: issue.id, field: "notes", value: v })
|
|
342
|
+
}
|
|
343
|
+
/>
|
|
344
|
+
|
|
345
|
+
{/* Design */}
|
|
346
|
+
<SectionEditor
|
|
347
|
+
label="Design"
|
|
348
|
+
value={issue.design || ""}
|
|
349
|
+
placeholder="Add design notes..."
|
|
350
|
+
onSave={(v) =>
|
|
351
|
+
mutations.editText({ id: issue.id, field: "design", value: v })
|
|
352
|
+
}
|
|
353
|
+
/>
|
|
354
|
+
|
|
355
|
+
{/* Children (epics only) */}
|
|
356
|
+
{issue.issue_type === "epic" && issue.dependents && issue.dependents.length > 0 && (
|
|
357
|
+
<div
|
|
358
|
+
className="rounded-lg p-4"
|
|
359
|
+
style={{
|
|
360
|
+
background: "var(--bg-elevated)",
|
|
361
|
+
border: "1px solid var(--border-subtle)",
|
|
362
|
+
boxShadow: "var(--shadow-card)",
|
|
363
|
+
}}
|
|
364
|
+
>
|
|
365
|
+
<h3
|
|
366
|
+
className="font-semibold uppercase tracking-wider mb-3"
|
|
367
|
+
style={{ fontSize: "11px", color: "var(--text-tertiary)" }}
|
|
368
|
+
>
|
|
369
|
+
Children ({issue.closed_children ?? 0} / {issue.total_children ?? issue.dependents.length})
|
|
370
|
+
</h3>
|
|
371
|
+
<div className="space-y-0.5">
|
|
372
|
+
{issue.dependents.map((child) => (
|
|
373
|
+
<a
|
|
374
|
+
key={child.id}
|
|
375
|
+
href={`#/detail/${child.id}`}
|
|
376
|
+
className="flex items-center gap-2.5 px-2 py-2 rounded-md transition-colors"
|
|
377
|
+
style={{ opacity: child.status === "closed" ? 0.6 : 1 }}
|
|
378
|
+
onMouseEnter={(e) => { e.currentTarget.style.background = "var(--bg-hover)"; }}
|
|
379
|
+
onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}
|
|
380
|
+
>
|
|
381
|
+
<StatusBadge status={child.status} />
|
|
382
|
+
<span className="text-xs font-mono" style={{ color: "var(--text-tertiary)" }}>{child.id}</span>
|
|
383
|
+
<span className="text-sm truncate" style={{ color: "var(--text-primary)" }}>{child.title}</span>
|
|
384
|
+
</a>
|
|
385
|
+
))}
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
)}
|
|
389
|
+
|
|
390
|
+
{/* Comments */}
|
|
391
|
+
<CommentsSection issueId={issue.id} />
|
|
392
|
+
</div>
|
|
393
|
+
|
|
394
|
+
{/* Metadata sidebar */}
|
|
395
|
+
<MetadataSidebar issue={issue} onUpdate={mutations} />
|
|
396
|
+
</div>
|
|
397
|
+
);
|
|
398
|
+
}
|