@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,137 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from "react";
|
|
2
|
+
import { useSubscription } from "../hooks/use-subscription";
|
|
3
|
+
import { useSearch } from "../hooks/use-search";
|
|
4
|
+
import { StatusBadge } from "./StatusBadge";
|
|
5
|
+
import { PriorityBadge } from "./PriorityBadge";
|
|
6
|
+
|
|
7
|
+
export function SearchDialog() {
|
|
8
|
+
const [open, setOpen] = useState(false);
|
|
9
|
+
const { issues } = useSubscription("all-issues");
|
|
10
|
+
const { query, setQuery, results } = useSearch(issues);
|
|
11
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
12
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
13
|
+
|
|
14
|
+
// Cmd+K to open, Escape to close
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const handler = (e: KeyboardEvent) => {
|
|
17
|
+
const tag = (e.target as HTMLElement)?.tagName;
|
|
18
|
+
if (e.key === "k" && (e.metaKey || e.ctrlKey) && tag !== "TEXTAREA") {
|
|
19
|
+
e.preventDefault();
|
|
20
|
+
setOpen(true);
|
|
21
|
+
}
|
|
22
|
+
if (e.key === "Escape" && open) {
|
|
23
|
+
e.preventDefault();
|
|
24
|
+
setOpen(false);
|
|
25
|
+
setQuery("");
|
|
26
|
+
setSelectedIndex(0);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
window.addEventListener("keydown", handler);
|
|
30
|
+
return () => window.removeEventListener("keydown", handler);
|
|
31
|
+
}, [open, setQuery]);
|
|
32
|
+
|
|
33
|
+
// Focus input when opened
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (open) setTimeout(() => inputRef.current?.focus(), 0);
|
|
36
|
+
}, [open]);
|
|
37
|
+
|
|
38
|
+
// Reset selection when results change
|
|
39
|
+
useEffect(() => setSelectedIndex(0), [results]);
|
|
40
|
+
|
|
41
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
42
|
+
const shown = results.slice(0, 20);
|
|
43
|
+
if (e.key === "ArrowDown") {
|
|
44
|
+
e.preventDefault();
|
|
45
|
+
setSelectedIndex((i) => Math.min(i + 1, shown.length - 1));
|
|
46
|
+
}
|
|
47
|
+
if (e.key === "ArrowUp") {
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
setSelectedIndex((i) => Math.max(i - 1, 0));
|
|
50
|
+
}
|
|
51
|
+
if (e.key === "Enter" && shown[selectedIndex]) {
|
|
52
|
+
window.location.hash = `#/detail/${shown[selectedIndex].id}`;
|
|
53
|
+
setOpen(false);
|
|
54
|
+
setQuery("");
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
if (!open) return null;
|
|
59
|
+
|
|
60
|
+
const shown = results.slice(0, 20);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"
|
|
65
|
+
onClick={() => {
|
|
66
|
+
setOpen(false);
|
|
67
|
+
setQuery("");
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
<div className="absolute inset-0" style={{ background: "var(--bg-overlay)" }} />
|
|
71
|
+
<div
|
|
72
|
+
className="relative w-full max-w-lg overflow-hidden"
|
|
73
|
+
style={{
|
|
74
|
+
background: "var(--bg-elevated)",
|
|
75
|
+
borderRadius: "var(--radius-lg)",
|
|
76
|
+
border: "1px solid var(--border-subtle)",
|
|
77
|
+
boxShadow: "0 25px 50px -12px rgba(0,0,0,0.15)",
|
|
78
|
+
}}
|
|
79
|
+
onClick={(e) => e.stopPropagation()}
|
|
80
|
+
>
|
|
81
|
+
<input
|
|
82
|
+
ref={inputRef}
|
|
83
|
+
type="text"
|
|
84
|
+
value={query}
|
|
85
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
86
|
+
onKeyDown={handleKeyDown}
|
|
87
|
+
placeholder="Search issues..."
|
|
88
|
+
className="w-full px-4 py-3 text-sm outline-none"
|
|
89
|
+
style={{
|
|
90
|
+
borderBottom: "1px solid var(--border-subtle)",
|
|
91
|
+
background: "transparent",
|
|
92
|
+
color: "var(--text-primary)",
|
|
93
|
+
}}
|
|
94
|
+
/>
|
|
95
|
+
<div className="max-h-80 overflow-y-auto">
|
|
96
|
+
{shown.length === 0 && query && (
|
|
97
|
+
<div className="px-4 py-6 text-sm text-center" style={{ color: "var(--text-tertiary)" }}>
|
|
98
|
+
No results
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
{shown.map((issue, i) => (
|
|
102
|
+
<button
|
|
103
|
+
key={issue.id}
|
|
104
|
+
onClick={() => {
|
|
105
|
+
window.location.hash = `#/detail/${issue.id}`;
|
|
106
|
+
setOpen(false);
|
|
107
|
+
setQuery("");
|
|
108
|
+
}}
|
|
109
|
+
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left transition-colors"
|
|
110
|
+
style={{
|
|
111
|
+
background: i === selectedIndex ? "var(--bg-hover)" : "transparent",
|
|
112
|
+
}}
|
|
113
|
+
onMouseEnter={(e) => { e.currentTarget.style.background = "var(--bg-hover)"; }}
|
|
114
|
+
onMouseLeave={(e) => { e.currentTarget.style.background = i === selectedIndex ? "var(--bg-hover)" : "transparent"; }}
|
|
115
|
+
>
|
|
116
|
+
<span className="font-mono text-xs w-28 shrink-0" style={{ color: "var(--text-tertiary)" }}>
|
|
117
|
+
{issue.id}
|
|
118
|
+
</span>
|
|
119
|
+
<StatusBadge status={issue.status} />
|
|
120
|
+
<span className="flex-1 truncate" style={{ color: "var(--text-primary)" }}>{issue.title}</span>
|
|
121
|
+
<PriorityBadge priority={issue.priority} />
|
|
122
|
+
</button>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
<div
|
|
126
|
+
className="px-4 py-2 text-xs"
|
|
127
|
+
style={{
|
|
128
|
+
color: "var(--text-tertiary)",
|
|
129
|
+
borderTop: "1px solid var(--border-subtle)",
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
↑↓ navigate · ↵ open · esc close
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect } from "react";
|
|
2
|
+
import { Markdown } from "./Markdown";
|
|
3
|
+
|
|
4
|
+
interface SectionEditorProps {
|
|
5
|
+
label: string;
|
|
6
|
+
value: string;
|
|
7
|
+
placeholder?: string;
|
|
8
|
+
onSave: (value: string) => Promise<unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function SectionEditor({
|
|
12
|
+
label,
|
|
13
|
+
value,
|
|
14
|
+
placeholder,
|
|
15
|
+
onSave,
|
|
16
|
+
}: SectionEditorProps) {
|
|
17
|
+
const [editing, setEditing] = useState(false);
|
|
18
|
+
const [draft, setDraft] = useState(value);
|
|
19
|
+
const [tab, setTab] = useState<"edit" | "preview">("edit");
|
|
20
|
+
const [saving, setSaving] = useState(false);
|
|
21
|
+
|
|
22
|
+
// Sync draft with external value when not editing
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!editing) setDraft(value);
|
|
25
|
+
}, [value, editing]);
|
|
26
|
+
|
|
27
|
+
const handleSave = useCallback(async () => {
|
|
28
|
+
setSaving(true);
|
|
29
|
+
try {
|
|
30
|
+
await onSave(draft);
|
|
31
|
+
setEditing(false);
|
|
32
|
+
setTab("edit");
|
|
33
|
+
} finally {
|
|
34
|
+
setSaving(false);
|
|
35
|
+
}
|
|
36
|
+
}, [draft, onSave]);
|
|
37
|
+
|
|
38
|
+
const handleCancel = useCallback(() => {
|
|
39
|
+
setDraft(value);
|
|
40
|
+
setEditing(false);
|
|
41
|
+
setTab("edit");
|
|
42
|
+
}, [value]);
|
|
43
|
+
|
|
44
|
+
const handleEditorKeyDown = useCallback(
|
|
45
|
+
(e: React.KeyboardEvent) => {
|
|
46
|
+
if (e.key === "s" && (e.metaKey || e.ctrlKey)) {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
handleSave();
|
|
49
|
+
}
|
|
50
|
+
if (e.key === "Escape") {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
handleCancel();
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
[handleSave, handleCancel],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
if (!editing) {
|
|
59
|
+
return (
|
|
60
|
+
<div
|
|
61
|
+
className="group rounded-lg p-4"
|
|
62
|
+
style={{
|
|
63
|
+
background: "var(--bg-elevated)",
|
|
64
|
+
border: "1px solid var(--border-subtle)",
|
|
65
|
+
boxShadow: "var(--shadow-card)",
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
<div className="flex items-center justify-between mb-2">
|
|
69
|
+
<h3
|
|
70
|
+
className="font-semibold uppercase tracking-wider"
|
|
71
|
+
style={{ fontSize: "11px", color: "var(--text-tertiary)" }}
|
|
72
|
+
>
|
|
73
|
+
{label}
|
|
74
|
+
</h3>
|
|
75
|
+
<button
|
|
76
|
+
onClick={() => setEditing(true)}
|
|
77
|
+
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
|
78
|
+
style={{ color: "var(--text-tertiary)" }}
|
|
79
|
+
onMouseEnter={(e) => { e.currentTarget.style.color = "var(--accent)"; }}
|
|
80
|
+
onMouseLeave={(e) => { e.currentTarget.style.color = "var(--text-tertiary)"; }}
|
|
81
|
+
title={`Edit ${label}`}
|
|
82
|
+
>
|
|
83
|
+
<svg
|
|
84
|
+
className="w-4 h-4"
|
|
85
|
+
fill="none"
|
|
86
|
+
stroke="currentColor"
|
|
87
|
+
viewBox="0 0 24 24"
|
|
88
|
+
>
|
|
89
|
+
<path
|
|
90
|
+
strokeLinecap="round"
|
|
91
|
+
strokeLinejoin="round"
|
|
92
|
+
strokeWidth={2}
|
|
93
|
+
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
|
94
|
+
/>
|
|
95
|
+
</svg>
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
{value ? (
|
|
99
|
+
<Markdown content={value} />
|
|
100
|
+
) : (
|
|
101
|
+
<p className="text-sm italic" style={{ color: "var(--text-tertiary)" }}>
|
|
102
|
+
{placeholder || `Add ${label.toLowerCase()}...`}
|
|
103
|
+
</p>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
className="rounded-lg p-4"
|
|
112
|
+
style={{
|
|
113
|
+
background: "var(--bg-elevated)",
|
|
114
|
+
border: "1px solid var(--accent)",
|
|
115
|
+
boxShadow: "var(--shadow-card)",
|
|
116
|
+
}}
|
|
117
|
+
onKeyDown={handleEditorKeyDown}
|
|
118
|
+
>
|
|
119
|
+
<div className="flex items-center justify-between mb-2">
|
|
120
|
+
<h3
|
|
121
|
+
className="font-semibold uppercase tracking-wider"
|
|
122
|
+
style={{ fontSize: "11px", color: "var(--text-tertiary)" }}
|
|
123
|
+
>
|
|
124
|
+
{label}
|
|
125
|
+
</h3>
|
|
126
|
+
<div
|
|
127
|
+
className="flex overflow-hidden text-xs rounded-md"
|
|
128
|
+
style={{ border: "1px solid var(--border-default)" }}
|
|
129
|
+
>
|
|
130
|
+
<button
|
|
131
|
+
onClick={() => setTab("edit")}
|
|
132
|
+
className="px-2.5 py-1 font-medium"
|
|
133
|
+
style={{
|
|
134
|
+
background: tab === "edit" ? "var(--bg-hover)" : "var(--bg-elevated)",
|
|
135
|
+
color: tab === "edit" ? "var(--text-primary)" : "var(--text-secondary)",
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
Edit
|
|
139
|
+
</button>
|
|
140
|
+
<button
|
|
141
|
+
onClick={() => setTab("preview")}
|
|
142
|
+
className="px-2.5 py-1 font-medium"
|
|
143
|
+
style={{
|
|
144
|
+
background: tab === "preview" ? "var(--bg-hover)" : "var(--bg-elevated)",
|
|
145
|
+
color: tab === "preview" ? "var(--text-primary)" : "var(--text-secondary)",
|
|
146
|
+
borderLeft: "1px solid var(--border-default)",
|
|
147
|
+
}}
|
|
148
|
+
>
|
|
149
|
+
Preview
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
{tab === "edit" ? (
|
|
154
|
+
<textarea
|
|
155
|
+
value={draft}
|
|
156
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
157
|
+
rows={10}
|
|
158
|
+
className="w-full p-3 text-sm font-mono rounded-md resize-y outline-none"
|
|
159
|
+
style={{
|
|
160
|
+
border: "1px solid var(--border-default)",
|
|
161
|
+
background: "var(--bg-base)",
|
|
162
|
+
color: "var(--text-primary)",
|
|
163
|
+
}}
|
|
164
|
+
onFocus={(e) => { e.currentTarget.style.borderColor = "var(--accent)"; }}
|
|
165
|
+
onBlur={(e) => { e.currentTarget.style.borderColor = "var(--border-default)"; }}
|
|
166
|
+
autoFocus
|
|
167
|
+
/>
|
|
168
|
+
) : (
|
|
169
|
+
<div
|
|
170
|
+
className="p-3 rounded-md min-h-[160px]"
|
|
171
|
+
style={{
|
|
172
|
+
border: "1px solid var(--border-default)",
|
|
173
|
+
background: "var(--bg-base)",
|
|
174
|
+
}}
|
|
175
|
+
>
|
|
176
|
+
<Markdown content={draft} />
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
<div className="flex items-center justify-end gap-2 mt-3">
|
|
180
|
+
<span className="text-xs mr-auto" style={{ color: "var(--text-tertiary)" }}>
|
|
181
|
+
Cmd+S to save, Esc to cancel
|
|
182
|
+
</span>
|
|
183
|
+
<button
|
|
184
|
+
onClick={handleCancel}
|
|
185
|
+
className="px-3 py-1.5 text-sm rounded-md transition-colors"
|
|
186
|
+
style={{
|
|
187
|
+
color: "var(--text-secondary)",
|
|
188
|
+
border: "1px solid var(--border-default)",
|
|
189
|
+
background: "var(--bg-elevated)",
|
|
190
|
+
}}
|
|
191
|
+
onMouseEnter={(e) => { e.currentTarget.style.background = "var(--bg-hover)"; }}
|
|
192
|
+
onMouseLeave={(e) => { e.currentTarget.style.background = "var(--bg-elevated)"; }}
|
|
193
|
+
>
|
|
194
|
+
Cancel
|
|
195
|
+
</button>
|
|
196
|
+
<button
|
|
197
|
+
onClick={handleSave}
|
|
198
|
+
disabled={saving}
|
|
199
|
+
className="px-3 py-1.5 text-sm rounded-md font-medium disabled:opacity-50 transition-colors"
|
|
200
|
+
style={{
|
|
201
|
+
background: "var(--accent)",
|
|
202
|
+
color: "white",
|
|
203
|
+
}}
|
|
204
|
+
onMouseEnter={(e) => { e.currentTarget.style.opacity = "0.9"; }}
|
|
205
|
+
onMouseLeave={(e) => { e.currentTarget.style.opacity = "1"; }}
|
|
206
|
+
>
|
|
207
|
+
{saving ? "Saving..." : "Save"}
|
|
208
|
+
</button>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const STATUS_CONFIG: Record<string, { dot: string; bg: string; text: string; border: string; label: string }> = {
|
|
2
|
+
open: {
|
|
3
|
+
dot: "var(--status-open)",
|
|
4
|
+
bg: "rgba(59,130,246,0.08)",
|
|
5
|
+
text: "#2563EB",
|
|
6
|
+
border: "rgba(59,130,246,0.2)",
|
|
7
|
+
label: "Open",
|
|
8
|
+
},
|
|
9
|
+
in_progress: {
|
|
10
|
+
dot: "var(--status-in-progress)",
|
|
11
|
+
bg: "rgba(245,158,11,0.08)",
|
|
12
|
+
text: "#D97706",
|
|
13
|
+
border: "rgba(245,158,11,0.2)",
|
|
14
|
+
label: "In Progress",
|
|
15
|
+
},
|
|
16
|
+
blocked: {
|
|
17
|
+
dot: "var(--status-blocked)",
|
|
18
|
+
bg: "rgba(239,68,68,0.08)",
|
|
19
|
+
text: "#DC2626",
|
|
20
|
+
border: "rgba(239,68,68,0.2)",
|
|
21
|
+
label: "Blocked",
|
|
22
|
+
},
|
|
23
|
+
closed: {
|
|
24
|
+
dot: "var(--status-closed)",
|
|
25
|
+
bg: "rgba(34,197,94,0.08)",
|
|
26
|
+
text: "#16A34A",
|
|
27
|
+
border: "rgba(34,197,94,0.2)",
|
|
28
|
+
label: "Closed",
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const DEFAULT_CONFIG = {
|
|
33
|
+
dot: "#9CA3AF",
|
|
34
|
+
bg: "rgba(156,163,175,0.08)",
|
|
35
|
+
text: "#6B7280",
|
|
36
|
+
border: "rgba(156,163,175,0.2)",
|
|
37
|
+
label: "",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function StatusBadge({ status }: { status: string }) {
|
|
41
|
+
const config = STATUS_CONFIG[status] || DEFAULT_CONFIG;
|
|
42
|
+
return (
|
|
43
|
+
<span
|
|
44
|
+
className="inline-flex items-center gap-1.5 px-2 py-0.5 font-semibold"
|
|
45
|
+
style={{
|
|
46
|
+
fontSize: "11px",
|
|
47
|
+
borderRadius: "20px",
|
|
48
|
+
backgroundColor: config.bg,
|
|
49
|
+
color: config.text,
|
|
50
|
+
border: `1px solid ${config.border}`,
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
<span
|
|
54
|
+
className="rounded-full shrink-0"
|
|
55
|
+
style={{
|
|
56
|
+
width: "6px",
|
|
57
|
+
height: "6px",
|
|
58
|
+
backgroundColor: config.dot,
|
|
59
|
+
}}
|
|
60
|
+
/>
|
|
61
|
+
{config.label || status}
|
|
62
|
+
</span>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const TYPE_CONFIG: Record<string, { bg: string; text: string }> = {
|
|
2
|
+
epic: { bg: "rgba(124,58,237,0.1)", text: "#7C3AED" },
|
|
3
|
+
feature: { bg: "rgba(99,102,241,0.1)", text: "#6366F1" },
|
|
4
|
+
bug: { bg: "rgba(239,68,68,0.1)", text: "#DC2626" },
|
|
5
|
+
task: { bg: "rgba(22,163,74,0.1)", text: "#16A34A" },
|
|
6
|
+
chore: { bg: "rgba(120,113,108,0.1)", text: "#78716C" },
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const DEFAULT = { bg: "rgba(120,113,108,0.1)", text: "#78716C" };
|
|
10
|
+
|
|
11
|
+
export function TypeBadge({ type }: { type: string }) {
|
|
12
|
+
const config = TYPE_CONFIG[type] || DEFAULT;
|
|
13
|
+
return (
|
|
14
|
+
<span
|
|
15
|
+
className="inline-flex items-center px-2 py-0.5 font-semibold capitalize"
|
|
16
|
+
style={{
|
|
17
|
+
fontSize: "11px",
|
|
18
|
+
borderRadius: "var(--radius-sm)",
|
|
19
|
+
backgroundColor: config.bg,
|
|
20
|
+
color: config.text,
|
|
21
|
+
}}
|
|
22
|
+
>
|
|
23
|
+
{type}
|
|
24
|
+
</span>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { useWs } from "../lib/ws-context";
|
|
3
|
+
import type {
|
|
4
|
+
EditTextPayload,
|
|
5
|
+
CreateIssuePayload,
|
|
6
|
+
} from "../lib/types";
|
|
7
|
+
|
|
8
|
+
export function useMutation() {
|
|
9
|
+
const ws = useWs();
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
updateStatus: useCallback(
|
|
13
|
+
(id: string, status: "open" | "in_progress" | "closed") =>
|
|
14
|
+
ws.updateStatus({ id, status }),
|
|
15
|
+
[ws],
|
|
16
|
+
),
|
|
17
|
+
editText: useCallback(
|
|
18
|
+
(payload: EditTextPayload) => ws.editText(payload),
|
|
19
|
+
[ws],
|
|
20
|
+
),
|
|
21
|
+
createIssue: useCallback(
|
|
22
|
+
(payload: CreateIssuePayload) => ws.createIssue(payload),
|
|
23
|
+
[ws],
|
|
24
|
+
),
|
|
25
|
+
updatePriority: useCallback(
|
|
26
|
+
(id: string, priority: number) => ws.updatePriority(id, priority),
|
|
27
|
+
[ws],
|
|
28
|
+
),
|
|
29
|
+
updateAssignee: useCallback(
|
|
30
|
+
(id: string, assignee: string) => ws.updateAssignee(id, assignee),
|
|
31
|
+
[ws],
|
|
32
|
+
),
|
|
33
|
+
addLabel: useCallback(
|
|
34
|
+
(id: string, label: string) => ws.addLabel(id, label),
|
|
35
|
+
[ws],
|
|
36
|
+
),
|
|
37
|
+
removeLabel: useCallback(
|
|
38
|
+
(id: string, label: string) => ws.removeLabel(id, label),
|
|
39
|
+
[ws],
|
|
40
|
+
),
|
|
41
|
+
deleteIssue: useCallback((id: string) => ws.deleteIssue(id), [ws]),
|
|
42
|
+
addComment: useCallback(
|
|
43
|
+
(id: string, text: string) => ws.addComment(id, text),
|
|
44
|
+
[ws],
|
|
45
|
+
),
|
|
46
|
+
addDep: useCallback(
|
|
47
|
+
(a: string, b: string) => ws.addDep(a, b),
|
|
48
|
+
[ws],
|
|
49
|
+
),
|
|
50
|
+
removeDep: useCallback(
|
|
51
|
+
(a: string, b: string) => ws.removeDep(a, b),
|
|
52
|
+
[ws],
|
|
53
|
+
),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useState, useMemo } from "react";
|
|
2
|
+
import type { Issue } from "../lib/types";
|
|
3
|
+
|
|
4
|
+
export function useSearch(issues: Issue[]) {
|
|
5
|
+
const [query, setQuery] = useState("");
|
|
6
|
+
|
|
7
|
+
const results = useMemo(() => {
|
|
8
|
+
if (!query.trim()) return issues;
|
|
9
|
+
const q = query.toLowerCase();
|
|
10
|
+
return issues.filter(
|
|
11
|
+
(i) =>
|
|
12
|
+
i.id.toLowerCase().includes(q) ||
|
|
13
|
+
i.title.toLowerCase().includes(q) ||
|
|
14
|
+
(i.description || "").toLowerCase().includes(q),
|
|
15
|
+
);
|
|
16
|
+
}, [issues, query]);
|
|
17
|
+
|
|
18
|
+
return { query, setQuery, results };
|
|
19
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { useEffect, useState, useRef } from "react";
|
|
2
|
+
import { useWs } from "../lib/ws-context";
|
|
3
|
+
import type { Issue, SubscriptionType, PushEvent } from "../lib/types";
|
|
4
|
+
import { WsClient } from "../lib/ws-client";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared subscription cache. Multiple useSubscription() calls with the same
|
|
8
|
+
* type+params share a single server-side subscription. The cache lives on
|
|
9
|
+
* the WsClient instance (via WeakMap) so it's scoped to the connection.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
interface SharedSub {
|
|
13
|
+
refCount: number;
|
|
14
|
+
subId: string;
|
|
15
|
+
items: Map<string, Issue>;
|
|
16
|
+
listeners: Set<() => void>;
|
|
17
|
+
loading: boolean;
|
|
18
|
+
total: number;
|
|
19
|
+
cleanupPush: (() => void) | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const clientCaches = new WeakMap<WsClient, Map<string, SharedSub>>();
|
|
23
|
+
|
|
24
|
+
function getCache(ws: WsClient): Map<string, SharedSub> {
|
|
25
|
+
let cache = clientCaches.get(ws);
|
|
26
|
+
if (!cache) {
|
|
27
|
+
cache = new Map();
|
|
28
|
+
clientCaches.set(ws, cache);
|
|
29
|
+
}
|
|
30
|
+
return cache;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function cacheKey(
|
|
34
|
+
type: SubscriptionType,
|
|
35
|
+
params?: Record<string, string | number | boolean>,
|
|
36
|
+
): string {
|
|
37
|
+
return params ? `${type}:${JSON.stringify(params)}` : type;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function acquireSubscription(
|
|
41
|
+
ws: WsClient,
|
|
42
|
+
type: SubscriptionType,
|
|
43
|
+
params?: Record<string, string | number | boolean>,
|
|
44
|
+
): SharedSub {
|
|
45
|
+
const cache = getCache(ws);
|
|
46
|
+
const key = cacheKey(type, params);
|
|
47
|
+
|
|
48
|
+
let shared = cache.get(key);
|
|
49
|
+
if (shared) {
|
|
50
|
+
shared.refCount++;
|
|
51
|
+
return shared;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const subId = `sub-${type}-${Date.now()}`;
|
|
55
|
+
shared = {
|
|
56
|
+
refCount: 1,
|
|
57
|
+
subId,
|
|
58
|
+
items: new Map(),
|
|
59
|
+
listeners: new Set(),
|
|
60
|
+
loading: true,
|
|
61
|
+
total: 0,
|
|
62
|
+
cleanupPush: null,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const notify = () => {
|
|
66
|
+
for (const listener of shared!.listeners) listener();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
shared.cleanupPush = ws.onPush((event: PushEvent) => {
|
|
70
|
+
if (event.id !== subId) return;
|
|
71
|
+
|
|
72
|
+
if (event.type === "snapshot") {
|
|
73
|
+
shared!.items = new Map();
|
|
74
|
+
for (const issue of event.issues) shared!.items.set(issue.id, issue);
|
|
75
|
+
shared!.loading = false;
|
|
76
|
+
if (typeof event.total === "number") {
|
|
77
|
+
shared!.total = event.total;
|
|
78
|
+
} else {
|
|
79
|
+
shared!.total = event.issues.length;
|
|
80
|
+
}
|
|
81
|
+
notify();
|
|
82
|
+
} else if (event.type === "upsert") {
|
|
83
|
+
shared!.items.set(event.issue.id, event.issue);
|
|
84
|
+
notify();
|
|
85
|
+
} else if (event.type === "delete") {
|
|
86
|
+
shared!.items.delete(event.issue_id);
|
|
87
|
+
notify();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
ws.subscribe({ id: subId, type, params });
|
|
92
|
+
cache.set(key, shared);
|
|
93
|
+
return shared;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function releaseSubscription(
|
|
97
|
+
ws: WsClient,
|
|
98
|
+
type: SubscriptionType,
|
|
99
|
+
params?: Record<string, string | number | boolean>,
|
|
100
|
+
): void {
|
|
101
|
+
const cache = getCache(ws);
|
|
102
|
+
const key = cacheKey(type, params);
|
|
103
|
+
const shared = cache.get(key);
|
|
104
|
+
if (!shared) return;
|
|
105
|
+
|
|
106
|
+
shared.refCount--;
|
|
107
|
+
if (shared.refCount <= 0) {
|
|
108
|
+
shared.cleanupPush?.();
|
|
109
|
+
ws.unsubscribe(shared.subId);
|
|
110
|
+
cache.delete(key);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function useSubscription(
|
|
115
|
+
type: SubscriptionType,
|
|
116
|
+
params?: Record<string, string | number | boolean>,
|
|
117
|
+
): { issues: Issue[]; loading: boolean; refreshing: boolean; total: number } {
|
|
118
|
+
const ws = useWs();
|
|
119
|
+
const paramsKey = params ? JSON.stringify(params) : "";
|
|
120
|
+
const sharedRef = useRef<SharedSub | null>(null);
|
|
121
|
+
|
|
122
|
+
// Track whether we've ever received data (initial load vs refresh)
|
|
123
|
+
const hasLoadedOnceRef = useRef(false);
|
|
124
|
+
const [staleIssues, setStaleIssues] = useState<Issue[]>([]);
|
|
125
|
+
const [staleTotal, setStaleTotal] = useState(0);
|
|
126
|
+
|
|
127
|
+
// Acquire/release shared subscription on mount/unmount
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
const shared = acquireSubscription(ws, type, params);
|
|
130
|
+
sharedRef.current = shared;
|
|
131
|
+
return () => {
|
|
132
|
+
releaseSubscription(ws, type, params);
|
|
133
|
+
sharedRef.current = null;
|
|
134
|
+
};
|
|
135
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
136
|
+
}, [ws, type, paramsKey]);
|
|
137
|
+
|
|
138
|
+
// Subscribe to changes
|
|
139
|
+
const [issues, setIssues] = useState<Issue[]>([]);
|
|
140
|
+
const [loading, setLoading] = useState(true);
|
|
141
|
+
const [total, setTotal] = useState(0);
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
const shared = sharedRef.current;
|
|
145
|
+
if (!shared) return;
|
|
146
|
+
|
|
147
|
+
const currentIssues = Array.from(shared.items.values());
|
|
148
|
+
setIssues(currentIssues);
|
|
149
|
+
setLoading(shared.loading);
|
|
150
|
+
setTotal(shared.total);
|
|
151
|
+
|
|
152
|
+
// When new data arrives, save it as stale for next transition
|
|
153
|
+
if (!shared.loading && currentIssues.length > 0) {
|
|
154
|
+
hasLoadedOnceRef.current = true;
|
|
155
|
+
setStaleIssues(currentIssues);
|
|
156
|
+
setStaleTotal(shared.total);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const listener = () => {
|
|
160
|
+
const items = Array.from(shared.items.values());
|
|
161
|
+
setIssues(items);
|
|
162
|
+
setLoading(shared.loading);
|
|
163
|
+
setTotal(shared.total);
|
|
164
|
+
|
|
165
|
+
if (!shared.loading && items.length > 0) {
|
|
166
|
+
hasLoadedOnceRef.current = true;
|
|
167
|
+
setStaleIssues(items);
|
|
168
|
+
setStaleTotal(shared.total);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
shared.listeners.add(listener);
|
|
172
|
+
return () => {
|
|
173
|
+
shared.listeners.delete(listener);
|
|
174
|
+
};
|
|
175
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
176
|
+
}, [ws, type, paramsKey]);
|
|
177
|
+
|
|
178
|
+
const isInitialLoad = loading && !hasLoadedOnceRef.current;
|
|
179
|
+
const isRefreshing = loading && hasLoadedOnceRef.current;
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
issues: isRefreshing ? staleIssues : issues,
|
|
183
|
+
loading: isInitialLoad,
|
|
184
|
+
refreshing: isRefreshing,
|
|
185
|
+
total: isRefreshing ? staleTotal : total,
|
|
186
|
+
};
|
|
187
|
+
}
|