@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,461 @@
|
|
|
1
|
+
import { useState, useMemo, useEffect, useRef } from "react";
|
|
2
|
+
import { useSubscription } from "../hooks/use-subscription";
|
|
3
|
+
import { StatusBadge } from "../components/StatusBadge";
|
|
4
|
+
import { PriorityBadge } from "../components/PriorityBadge";
|
|
5
|
+
import { TypeBadge } from "../components/TypeBadge";
|
|
6
|
+
import { getInitials, getAvatarColor } from "../lib/avatar";
|
|
7
|
+
import type { Issue } from "../lib/types";
|
|
8
|
+
|
|
9
|
+
const PAGE_SIZE = 20;
|
|
10
|
+
|
|
11
|
+
const SearchIcon = () => (
|
|
12
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
|
13
|
+
<circle cx="7" cy="7" r="5" />
|
|
14
|
+
<line x1="11" y1="11" x2="14" y2="14" />
|
|
15
|
+
</svg>
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
function SkeletonRow() {
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
className="flex items-center gap-3 px-4 py-3"
|
|
22
|
+
style={{ borderBottom: "1px solid var(--border-subtle)" }}
|
|
23
|
+
>
|
|
24
|
+
<div className="w-36 h-3 rounded skeleton-shimmer" />
|
|
25
|
+
<div className="w-20 h-5 rounded-full skeleton-shimmer" />
|
|
26
|
+
<div className="w-8 h-5 rounded skeleton-shimmer" />
|
|
27
|
+
<div className="flex-1 h-3 rounded skeleton-shimmer" />
|
|
28
|
+
<div className="w-14 h-5 rounded skeleton-shimmer" />
|
|
29
|
+
<div className="w-24 h-5 rounded skeleton-shimmer" />
|
|
30
|
+
<div className="w-16 h-3 rounded skeleton-shimmer" />
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function Spinner() {
|
|
36
|
+
return (
|
|
37
|
+
<svg
|
|
38
|
+
width="20"
|
|
39
|
+
height="20"
|
|
40
|
+
viewBox="0 0 20 20"
|
|
41
|
+
fill="none"
|
|
42
|
+
style={{ animation: "spin 0.8s linear infinite" }}
|
|
43
|
+
>
|
|
44
|
+
<circle cx="10" cy="10" r="8" stroke="var(--border-default)" strokeWidth="2" />
|
|
45
|
+
<path
|
|
46
|
+
d="M10 2a8 8 0 0 1 8 8"
|
|
47
|
+
stroke="var(--accent)"
|
|
48
|
+
strokeWidth="2"
|
|
49
|
+
strokeLinecap="round"
|
|
50
|
+
/>
|
|
51
|
+
</svg>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function Toolbar({
|
|
56
|
+
search,
|
|
57
|
+
setSearch,
|
|
58
|
+
statusFilter,
|
|
59
|
+
setStatusFilter,
|
|
60
|
+
typeFilter,
|
|
61
|
+
setTypeFilter,
|
|
62
|
+
}: {
|
|
63
|
+
search: string;
|
|
64
|
+
setSearch: (v: string) => void;
|
|
65
|
+
statusFilter: string;
|
|
66
|
+
setStatusFilter: (v: string) => void;
|
|
67
|
+
typeFilter: string;
|
|
68
|
+
setTypeFilter: (v: string) => void;
|
|
69
|
+
}) {
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
className="flex items-center gap-3 mb-4 px-4 py-3 rounded-lg"
|
|
73
|
+
style={{
|
|
74
|
+
background: "var(--bg-elevated)",
|
|
75
|
+
border: "1px solid var(--border-subtle)",
|
|
76
|
+
boxShadow: "var(--shadow-sm)",
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
<div className="relative flex-1 max-w-xs">
|
|
80
|
+
<span className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: "var(--text-tertiary)" }}>
|
|
81
|
+
<SearchIcon />
|
|
82
|
+
</span>
|
|
83
|
+
<input
|
|
84
|
+
type="text"
|
|
85
|
+
value={search}
|
|
86
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
87
|
+
placeholder="Search issues..."
|
|
88
|
+
className="w-full pl-9 pr-3 py-1.5 text-sm rounded-md outline-none"
|
|
89
|
+
style={{
|
|
90
|
+
border: "1px solid var(--border-default)",
|
|
91
|
+
background: "var(--bg-base)",
|
|
92
|
+
color: "var(--text-primary)",
|
|
93
|
+
}}
|
|
94
|
+
onFocus={(e) => { e.currentTarget.style.borderColor = "var(--accent)"; }}
|
|
95
|
+
onBlur={(e) => { e.currentTarget.style.borderColor = "var(--border-default)"; }}
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
<select
|
|
99
|
+
value={statusFilter}
|
|
100
|
+
onChange={(e) => setStatusFilter(e.target.value)}
|
|
101
|
+
className="px-3 py-1.5 text-sm rounded-md"
|
|
102
|
+
style={{
|
|
103
|
+
border: "1px solid var(--border-default)",
|
|
104
|
+
background: "var(--bg-base)",
|
|
105
|
+
color: "var(--text-primary)",
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
<option value="all">All statuses</option>
|
|
109
|
+
<option value="open">Open</option>
|
|
110
|
+
<option value="in_progress">In Progress</option>
|
|
111
|
+
<option value="blocked">Blocked</option>
|
|
112
|
+
<option value="closed">Closed</option>
|
|
113
|
+
</select>
|
|
114
|
+
<select
|
|
115
|
+
value={typeFilter}
|
|
116
|
+
onChange={(e) => setTypeFilter(e.target.value)}
|
|
117
|
+
className="px-3 py-1.5 text-sm rounded-md"
|
|
118
|
+
style={{
|
|
119
|
+
border: "1px solid var(--border-default)",
|
|
120
|
+
background: "var(--bg-base)",
|
|
121
|
+
color: "var(--text-primary)",
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
<option value="all">All types</option>
|
|
125
|
+
<option value="epic">Epic</option>
|
|
126
|
+
<option value="feature">Feature</option>
|
|
127
|
+
<option value="task">Task</option>
|
|
128
|
+
<option value="bug">Bug</option>
|
|
129
|
+
<option value="chore">Chore</option>
|
|
130
|
+
</select>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function Pagination({
|
|
136
|
+
page,
|
|
137
|
+
totalPages,
|
|
138
|
+
total,
|
|
139
|
+
pageSize,
|
|
140
|
+
onPageChange,
|
|
141
|
+
}: {
|
|
142
|
+
page: number;
|
|
143
|
+
totalPages: number;
|
|
144
|
+
total: number;
|
|
145
|
+
pageSize: number;
|
|
146
|
+
onPageChange: (p: number) => void;
|
|
147
|
+
}) {
|
|
148
|
+
const from = page * pageSize + 1;
|
|
149
|
+
const to = Math.min((page + 1) * pageSize, total);
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div className="flex items-center justify-between px-1 py-2 text-xs" style={{ color: "var(--text-tertiary)" }}>
|
|
153
|
+
<span>
|
|
154
|
+
{total > 0 ? `${from}\u2013${to} of ${total}` : "0 issues"}
|
|
155
|
+
</span>
|
|
156
|
+
<div className="flex items-center gap-1">
|
|
157
|
+
<button
|
|
158
|
+
disabled={page === 0}
|
|
159
|
+
onClick={() => onPageChange(0)}
|
|
160
|
+
className="px-2 py-1 rounded disabled:opacity-30"
|
|
161
|
+
style={{ border: "1px solid var(--border-default)", background: "var(--bg-elevated)" }}
|
|
162
|
+
>
|
|
163
|
+
««
|
|
164
|
+
</button>
|
|
165
|
+
<button
|
|
166
|
+
disabled={page === 0}
|
|
167
|
+
onClick={() => onPageChange(page - 1)}
|
|
168
|
+
className="px-2 py-1 rounded disabled:opacity-30"
|
|
169
|
+
style={{ border: "1px solid var(--border-default)", background: "var(--bg-elevated)" }}
|
|
170
|
+
>
|
|
171
|
+
«
|
|
172
|
+
</button>
|
|
173
|
+
<span className="px-2">
|
|
174
|
+
{page + 1} / {totalPages || 1}
|
|
175
|
+
</span>
|
|
176
|
+
<button
|
|
177
|
+
disabled={page >= totalPages - 1}
|
|
178
|
+
onClick={() => onPageChange(page + 1)}
|
|
179
|
+
className="px-2 py-1 rounded disabled:opacity-30"
|
|
180
|
+
style={{ border: "1px solid var(--border-default)", background: "var(--bg-elevated)" }}
|
|
181
|
+
>
|
|
182
|
+
»
|
|
183
|
+
</button>
|
|
184
|
+
<button
|
|
185
|
+
disabled={page >= totalPages - 1}
|
|
186
|
+
onClick={() => onPageChange(totalPages - 1)}
|
|
187
|
+
className="px-2 py-1 rounded disabled:opacity-30"
|
|
188
|
+
style={{ border: "1px solid var(--border-default)", background: "var(--bg-elevated)" }}
|
|
189
|
+
>
|
|
190
|
+
»»
|
|
191
|
+
</button>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function IssueRow({
|
|
198
|
+
issue,
|
|
199
|
+
selected,
|
|
200
|
+
onClick,
|
|
201
|
+
}: {
|
|
202
|
+
issue: Issue;
|
|
203
|
+
selected: boolean;
|
|
204
|
+
onClick: () => void;
|
|
205
|
+
}) {
|
|
206
|
+
const ts = new Date(issue.updated_at).toLocaleDateString();
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<button
|
|
210
|
+
onClick={onClick}
|
|
211
|
+
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors"
|
|
212
|
+
style={{
|
|
213
|
+
borderBottom: "1px solid var(--border-subtle)",
|
|
214
|
+
background: selected ? "var(--bg-hover)" : "transparent",
|
|
215
|
+
}}
|
|
216
|
+
onMouseEnter={(e) => {
|
|
217
|
+
if (!selected) e.currentTarget.style.background = "var(--bg-hover)";
|
|
218
|
+
}}
|
|
219
|
+
onMouseLeave={(e) => {
|
|
220
|
+
if (!selected) e.currentTarget.style.background = "transparent";
|
|
221
|
+
}}
|
|
222
|
+
>
|
|
223
|
+
<span className="font-mono text-xs w-36 shrink-0 text-left" style={{ color: "var(--text-tertiary)" }}>
|
|
224
|
+
{issue.id}
|
|
225
|
+
</span>
|
|
226
|
+
<span className="w-28 shrink-0">
|
|
227
|
+
<StatusBadge status={issue.status} />
|
|
228
|
+
</span>
|
|
229
|
+
<span className="w-12 shrink-0">
|
|
230
|
+
<PriorityBadge priority={issue.priority} />
|
|
231
|
+
</span>
|
|
232
|
+
<span className="flex-1 text-left truncate" style={{ color: "var(--text-primary)" }}>
|
|
233
|
+
{issue.title}
|
|
234
|
+
{issue.issue_type === "epic" && issue.total_children != null && (
|
|
235
|
+
<span
|
|
236
|
+
className="ml-2 text-xs px-1.5 py-0.5 rounded"
|
|
237
|
+
style={{ background: "rgba(0,0,0,0.04)", color: "var(--text-tertiary)" }}
|
|
238
|
+
>
|
|
239
|
+
{issue.total_children} sub
|
|
240
|
+
</span>
|
|
241
|
+
)}
|
|
242
|
+
</span>
|
|
243
|
+
<span className="w-16 shrink-0">
|
|
244
|
+
<TypeBadge type={issue.issue_type} />
|
|
245
|
+
</span>
|
|
246
|
+
<span className="w-28 shrink-0 flex items-center gap-1.5 justify-end">
|
|
247
|
+
{issue.assignee ? (
|
|
248
|
+
<>
|
|
249
|
+
<div
|
|
250
|
+
className="flex items-center justify-center rounded-full text-[9px] font-bold shrink-0"
|
|
251
|
+
style={{
|
|
252
|
+
width: "20px",
|
|
253
|
+
height: "20px",
|
|
254
|
+
backgroundColor: getAvatarColor(issue.assignee),
|
|
255
|
+
color: "var(--text-inverse)",
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
{getInitials(issue.assignee)}
|
|
259
|
+
</div>
|
|
260
|
+
<span className="text-xs truncate" style={{ color: "var(--text-secondary)" }}>
|
|
261
|
+
{issue.assignee}
|
|
262
|
+
</span>
|
|
263
|
+
</>
|
|
264
|
+
) : (
|
|
265
|
+
<span className="text-xs" style={{ color: "var(--text-tertiary)" }}>—</span>
|
|
266
|
+
)}
|
|
267
|
+
</span>
|
|
268
|
+
<span className="text-xs w-20 text-right shrink-0" style={{ color: "var(--text-tertiary)" }}>
|
|
269
|
+
{ts}
|
|
270
|
+
</span>
|
|
271
|
+
</button>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function List() {
|
|
276
|
+
const [search, setSearch] = useState("");
|
|
277
|
+
const [debouncedSearch, setDebouncedSearch] = useState("");
|
|
278
|
+
const [statusFilter, setStatusFilter] = useState("all");
|
|
279
|
+
const [typeFilter, setTypeFilter] = useState("all");
|
|
280
|
+
const [page, setPage] = useState(0);
|
|
281
|
+
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
282
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
283
|
+
|
|
284
|
+
// Debounce search input
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
const timer = setTimeout(() => setDebouncedSearch(search), 300);
|
|
287
|
+
return () => clearTimeout(timer);
|
|
288
|
+
}, [search]);
|
|
289
|
+
|
|
290
|
+
// Build server-side subscription params
|
|
291
|
+
const subParams = useMemo(() => {
|
|
292
|
+
const params: Record<string, string | number | boolean> = {};
|
|
293
|
+
if (debouncedSearch.trim()) params.q = debouncedSearch.trim();
|
|
294
|
+
if (statusFilter !== "all") params.status = statusFilter;
|
|
295
|
+
if (typeFilter !== "all") params.type = typeFilter;
|
|
296
|
+
return params;
|
|
297
|
+
}, [debouncedSearch, statusFilter, typeFilter]);
|
|
298
|
+
|
|
299
|
+
// Server-side search+filter via search-issues subscription
|
|
300
|
+
const { issues: allFiltered, loading, refreshing, total } = useSubscription(
|
|
301
|
+
"search-issues",
|
|
302
|
+
subParams,
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const totalPages = Math.ceil(total / PAGE_SIZE);
|
|
306
|
+
|
|
307
|
+
// Client-side pagination of server-filtered results
|
|
308
|
+
const paginatedIssues = useMemo(() => {
|
|
309
|
+
const start = page * PAGE_SIZE;
|
|
310
|
+
return allFiltered.slice(start, start + PAGE_SIZE);
|
|
311
|
+
}, [allFiltered, page]);
|
|
312
|
+
|
|
313
|
+
// Reset page when filters change
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
setPage(0);
|
|
316
|
+
setSelectedIndex(-1);
|
|
317
|
+
}, [statusFilter, typeFilter, search]);
|
|
318
|
+
|
|
319
|
+
// Reset selection on page change
|
|
320
|
+
useEffect(() => {
|
|
321
|
+
setSelectedIndex(-1);
|
|
322
|
+
}, [page]);
|
|
323
|
+
|
|
324
|
+
// Keyboard navigation
|
|
325
|
+
useEffect(() => {
|
|
326
|
+
const handler = (e: KeyboardEvent) => {
|
|
327
|
+
if (
|
|
328
|
+
e.target instanceof HTMLInputElement ||
|
|
329
|
+
e.target instanceof HTMLTextAreaElement ||
|
|
330
|
+
e.target instanceof HTMLSelectElement
|
|
331
|
+
)
|
|
332
|
+
return;
|
|
333
|
+
if (e.key === "j")
|
|
334
|
+
setSelectedIndex((i) => Math.min(i + 1, paginatedIssues.length - 1));
|
|
335
|
+
if (e.key === "k") setSelectedIndex((i) => Math.max(i - 1, -1));
|
|
336
|
+
if (e.key === "Enter" && paginatedIssues[selectedIndex]) {
|
|
337
|
+
window.location.hash = `#/detail/${paginatedIssues[selectedIndex].id}`;
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
window.addEventListener("keydown", handler);
|
|
341
|
+
return () => window.removeEventListener("keydown", handler);
|
|
342
|
+
}, [paginatedIssues, selectedIndex]);
|
|
343
|
+
|
|
344
|
+
return (
|
|
345
|
+
<div className="p-6 h-full flex flex-col" style={{ background: "var(--bg-base)" }}>
|
|
346
|
+
<div className="mb-4">
|
|
347
|
+
<h1
|
|
348
|
+
className="text-xl font-bold"
|
|
349
|
+
style={{ color: "var(--text-primary)" }}
|
|
350
|
+
>
|
|
351
|
+
Issues
|
|
352
|
+
</h1>
|
|
353
|
+
<p className="text-sm mt-0.5" style={{ color: "var(--text-tertiary)" }}>
|
|
354
|
+
{loading ? "\u00A0" : `${total} issues`}
|
|
355
|
+
</p>
|
|
356
|
+
</div>
|
|
357
|
+
|
|
358
|
+
<Toolbar
|
|
359
|
+
search={search}
|
|
360
|
+
setSearch={setSearch}
|
|
361
|
+
statusFilter={statusFilter}
|
|
362
|
+
setStatusFilter={setStatusFilter}
|
|
363
|
+
typeFilter={typeFilter}
|
|
364
|
+
setTypeFilter={setTypeFilter}
|
|
365
|
+
/>
|
|
366
|
+
|
|
367
|
+
{/* Table header */}
|
|
368
|
+
<div
|
|
369
|
+
className="rounded-t-lg overflow-hidden"
|
|
370
|
+
style={{
|
|
371
|
+
background: "var(--bg-elevated)",
|
|
372
|
+
border: "1px solid var(--border-subtle)",
|
|
373
|
+
borderBottom: "none",
|
|
374
|
+
boxShadow: "var(--shadow-card)",
|
|
375
|
+
}}
|
|
376
|
+
>
|
|
377
|
+
<div
|
|
378
|
+
className="flex items-center gap-3 px-4 py-2 text-xs font-semibold uppercase tracking-wider"
|
|
379
|
+
style={{
|
|
380
|
+
color: "var(--text-tertiary)",
|
|
381
|
+
borderBottom: "1px solid var(--border-subtle)",
|
|
382
|
+
fontSize: "11px",
|
|
383
|
+
}}
|
|
384
|
+
>
|
|
385
|
+
<span className="w-36 shrink-0">ID</span>
|
|
386
|
+
<span className="w-28 shrink-0">Status</span>
|
|
387
|
+
<span className="w-12 shrink-0">Priority</span>
|
|
388
|
+
<span className="flex-1">Title</span>
|
|
389
|
+
<span className="w-16 shrink-0">Type</span>
|
|
390
|
+
<span className="w-28 shrink-0 text-right">Assignee</span>
|
|
391
|
+
<span className="w-20 shrink-0 text-right">Date</span>
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
|
|
395
|
+
{/* Table body */}
|
|
396
|
+
<div
|
|
397
|
+
ref={listRef}
|
|
398
|
+
className="flex-1 overflow-y-auto rounded-b-lg relative"
|
|
399
|
+
style={{
|
|
400
|
+
background: "var(--bg-elevated)",
|
|
401
|
+
border: "1px solid var(--border-subtle)",
|
|
402
|
+
borderTop: "none",
|
|
403
|
+
boxShadow: "var(--shadow-card)",
|
|
404
|
+
}}
|
|
405
|
+
>
|
|
406
|
+
{/* Initial load: skeleton shimmer */}
|
|
407
|
+
{loading && (
|
|
408
|
+
<>
|
|
409
|
+
{Array.from({ length: 8 }).map((_, i) => (
|
|
410
|
+
<SkeletonRow key={i} />
|
|
411
|
+
))}
|
|
412
|
+
</>
|
|
413
|
+
)}
|
|
414
|
+
|
|
415
|
+
{/* Refresh overlay: dim existing list + centered spinner */}
|
|
416
|
+
{refreshing && (
|
|
417
|
+
<div
|
|
418
|
+
className="absolute inset-0 z-10 flex items-center justify-center"
|
|
419
|
+
style={{
|
|
420
|
+
background: "rgba(253,251,247,0.6)",
|
|
421
|
+
backdropFilter: "blur(1px)",
|
|
422
|
+
}}
|
|
423
|
+
>
|
|
424
|
+
<Spinner />
|
|
425
|
+
</div>
|
|
426
|
+
)}
|
|
427
|
+
|
|
428
|
+
{/* Data rows */}
|
|
429
|
+
{!loading && (
|
|
430
|
+
paginatedIssues.length === 0 ? (
|
|
431
|
+
<p className="text-sm p-8 text-center" style={{ color: "var(--text-tertiary)" }}>
|
|
432
|
+
No issues match your filters
|
|
433
|
+
</p>
|
|
434
|
+
) : (
|
|
435
|
+
paginatedIssues.map((issue, index) => (
|
|
436
|
+
<IssueRow
|
|
437
|
+
key={issue.id}
|
|
438
|
+
issue={issue}
|
|
439
|
+
selected={index === selectedIndex}
|
|
440
|
+
onClick={() => {
|
|
441
|
+
setSelectedIndex(index);
|
|
442
|
+
window.location.hash = `#/detail/${issue.id}`;
|
|
443
|
+
}}
|
|
444
|
+
/>
|
|
445
|
+
))
|
|
446
|
+
)
|
|
447
|
+
)}
|
|
448
|
+
</div>
|
|
449
|
+
|
|
450
|
+
{!loading && totalPages > 1 && (
|
|
451
|
+
<Pagination
|
|
452
|
+
page={page}
|
|
453
|
+
totalPages={totalPages}
|
|
454
|
+
total={total}
|
|
455
|
+
pageSize={PAGE_SIZE}
|
|
456
|
+
onPageChange={setPage}
|
|
457
|
+
/>
|
|
458
|
+
)}
|
|
459
|
+
</div>
|
|
460
|
+
);
|
|
461
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Config } from "tailwindcss";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import typography from "@tailwindcss/typography";
|
|
4
|
+
|
|
5
|
+
const clientDir = __dirname;
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
content: [
|
|
9
|
+
path.join(clientDir, "src/**/*.{ts,tsx}"),
|
|
10
|
+
path.join(clientDir, "index.html"),
|
|
11
|
+
],
|
|
12
|
+
theme: {
|
|
13
|
+
extend: {
|
|
14
|
+
fontFamily: {
|
|
15
|
+
sans: ["'DM Sans'", "system-ui", "-apple-system", "sans-serif"],
|
|
16
|
+
mono: ["'JetBrains Mono'", "ui-monospace", "monospace"],
|
|
17
|
+
},
|
|
18
|
+
colors: {
|
|
19
|
+
status: {
|
|
20
|
+
open: "var(--status-open)",
|
|
21
|
+
"in-progress": "var(--status-in-progress)",
|
|
22
|
+
blocked: "var(--status-blocked)",
|
|
23
|
+
closed: "var(--status-closed)",
|
|
24
|
+
},
|
|
25
|
+
priority: {
|
|
26
|
+
0: "var(--priority-0)",
|
|
27
|
+
1: "var(--priority-1)",
|
|
28
|
+
2: "var(--priority-2)",
|
|
29
|
+
3: "var(--priority-3)",
|
|
30
|
+
4: "var(--priority-4)",
|
|
31
|
+
},
|
|
32
|
+
surface: {
|
|
33
|
+
base: "var(--bg-base)",
|
|
34
|
+
card: "var(--bg-surface)",
|
|
35
|
+
elevated: "var(--bg-elevated)",
|
|
36
|
+
hover: "var(--bg-hover)",
|
|
37
|
+
},
|
|
38
|
+
ink: {
|
|
39
|
+
primary: "var(--text-primary)",
|
|
40
|
+
secondary: "var(--text-secondary)",
|
|
41
|
+
tertiary: "var(--text-tertiary)",
|
|
42
|
+
},
|
|
43
|
+
accent: {
|
|
44
|
+
DEFAULT: "var(--accent)",
|
|
45
|
+
soft: "var(--accent-soft)",
|
|
46
|
+
},
|
|
47
|
+
type: {
|
|
48
|
+
task: "var(--type-task)",
|
|
49
|
+
bug: "var(--type-bug)",
|
|
50
|
+
feature: "var(--type-feature)",
|
|
51
|
+
epic: "var(--type-epic)",
|
|
52
|
+
chore: "var(--type-chore)",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
boxShadow: {
|
|
56
|
+
sm: "var(--shadow-sm)",
|
|
57
|
+
card: "var(--shadow-card)",
|
|
58
|
+
md: "var(--shadow-md)",
|
|
59
|
+
},
|
|
60
|
+
borderRadius: {
|
|
61
|
+
sm: "var(--radius-sm)",
|
|
62
|
+
md: "var(--radius-md)",
|
|
63
|
+
lg: "var(--radius-lg)",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
plugins: [typography],
|
|
68
|
+
} satisfies Config;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"jsx": "react-jsx",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"outDir": "dist",
|
|
11
|
+
"rootDir": "src",
|
|
12
|
+
"baseUrl": ".",
|
|
13
|
+
"paths": { "@/*": ["src/*"] }
|
|
14
|
+
},
|
|
15
|
+
"include": ["src"]
|
|
16
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import react from "@vitejs/plugin-react";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
plugins: [react()],
|
|
7
|
+
root: path.resolve(__dirname),
|
|
8
|
+
build: {
|
|
9
|
+
outDir: path.resolve(__dirname, "../dist"),
|
|
10
|
+
emptyOutDir: true,
|
|
11
|
+
},
|
|
12
|
+
server: {
|
|
13
|
+
proxy: {
|
|
14
|
+
"/ws": {
|
|
15
|
+
target: "ws://localhost:3333",
|
|
16
|
+
ws: true,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rsktash/beads-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "https://github.com/rsktash/beads-ui.git"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"bin": {
|
|
10
|
+
"bdui": "bin/bdui"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"dev": "concurrently \"vite --config client/vite.config.ts\" \"node server/index.js\"",
|
|
14
|
+
"build": "vite build --config client/vite.config.ts",
|
|
15
|
+
"start": "node server/index.js"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"debug": "^4.3.7",
|
|
19
|
+
"dompurify": "^3.2.0",
|
|
20
|
+
"express": "^4.21.0",
|
|
21
|
+
"marked": "^15.0.0",
|
|
22
|
+
"mysql2": "^3.20.0",
|
|
23
|
+
"react": "^19.0.0",
|
|
24
|
+
"react-dom": "^19.0.0",
|
|
25
|
+
"react-window": "^1.8.10",
|
|
26
|
+
"shiki": "^1.24.0",
|
|
27
|
+
"ws": "^8.18.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@tailwindcss/typography": "^0.5.15",
|
|
31
|
+
"@types/dompurify": "^3.0.5",
|
|
32
|
+
"@types/react": "^19.0.0",
|
|
33
|
+
"@types/react-dom": "^19.0.0",
|
|
34
|
+
"@types/react-window": "^1.8.8",
|
|
35
|
+
"@vitejs/plugin-react": "^4.3.0",
|
|
36
|
+
"autoprefixer": "^10.4.20",
|
|
37
|
+
"concurrently": "^9.1.0",
|
|
38
|
+
"postcss": "^8.4.49",
|
|
39
|
+
"tailwindcss": "^3.4.0",
|
|
40
|
+
"typescript": "^5.7.0",
|
|
41
|
+
"vite": "^6.0.0"
|
|
42
|
+
}
|
|
43
|
+
}
|