beads-kanban-ui 0.1.0 → 0.1.2
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/README.md +16 -222
- package/package.json +18 -55
- package/.designs/beads-kanban-ui-bj0.md +0 -73
- package/.designs/beads-kanban-ui-qxq.md +0 -144
- package/.designs/epic-support.md +0 -282
- package/.env.local.example +0 -2
- package/.eslintrc.json +0 -3
- package/.gitattributes +0 -3
- package/.github/workflows/release.yml +0 -123
- package/.history/README_20260121193710.md +0 -227
- package/.history/README_20260121193918.md +0 -227
- package/.history/README_20260121193921.md +0 -227
- package/.history/README_20260121193933.md +0 -227
- package/.history/README_20260121193934.md +0 -227
- package/.history/README_20260121193944.md +0 -227
- package/.history/README_20260121193953.md +0 -227
- package/.history/src/app/page_20260121133429.tsx +0 -134
- package/.history/src/app/page_20260121133928.tsx +0 -134
- package/.history/src/app/page_20260121144850.tsx +0 -138
- package/.history/src/app/page_20260121144854.tsx +0 -138
- package/.history/src/app/page_20260121144858.tsx +0 -138
- package/.history/src/app/page_20260121144902.tsx +0 -138
- package/.history/src/app/page_20260121144906.tsx +0 -138
- package/.history/src/app/page_20260121144911.tsx +0 -138
- package/.history/src/app/page_20260121144928.tsx +0 -138
- package/.playwright-mcp/.playwright-mcp/morphing-dialog-wheel-scroll-fix.png +0 -0
- package/.playwright-mcp/beams-test.png +0 -0
- package/.playwright-mcp/card-verification.png +0 -0
- package/.playwright-mcp/design-doc-dialog-fix-verification.png +0 -0
- package/.playwright-mcp/dialog-width-test.png +0 -0
- package/.playwright-mcp/homepage.png +0 -0
- package/.playwright-mcp/morphing-dialog-expanded.png +0 -0
- package/.playwright-mcp/morphing-dialog-fixes-final.png +0 -0
- package/.playwright-mcp/morphing-dialog-open.png +0 -0
- package/.playwright-mcp/page-2026-01-21T14-08-31-529Z.png +0 -0
- package/.playwright-mcp/page-2026-01-21T14-09-23-431Z.png +0 -0
- package/.playwright-mcp/page-2026-01-21T14-10-28-773Z.png +0 -0
- package/.playwright-mcp/page-2026-01-21T14-10-47-432Z.png +0 -0
- package/.playwright-mcp/page-2026-01-21T14-11-12-350Z.png +0 -0
- package/.playwright-mcp/screenshot-after-click.png +0 -0
- package/.playwright-mcp/screenshot-after-dialog-click.png +0 -0
- package/.playwright-mcp/sheet-restored-after-dialog-close.png +0 -0
- package/.playwright-mcp/test-1-sheet-open-with-overlay.png +0 -0
- package/.playwright-mcp/test-2-morphing-dialog-with-overlay.png +0 -0
- package/.playwright-mcp/test-3-sheet-open-dark-overlay.png +0 -0
- package/.playwright-mcp/test-4-morphing-dialog-with-dark-overlay.png +0 -0
- package/.playwright-mcp/test-5-morphing-dialog-scrolled.png +0 -0
- package/.playwright-mcp/test-6-sheet-restored-after-dialog-close.png +0 -0
- package/.playwright-mcp/wheel-scroll-fixed.png +0 -0
- package/Screenshots/bead-detail.png +0 -0
- package/Screenshots/dashboard.png +0 -0
- package/Screenshots/kanban-board.png +0 -0
- package/components.json +0 -27
- package/logo/logo.svg +0 -1
- package/next.config.js +0 -9
- package/npm/README.md +0 -37
- package/npm/package.json +0 -20
- package/postcss.config.js +0 -6
- package/public/logo.svg +0 -1
- package/restart.sh +0 -5
- package/server/Cargo.lock +0 -1685
- package/server/Cargo.toml +0 -24
- package/server/src/db.rs +0 -570
- package/server/src/main.rs +0 -141
- package/server/src/routes/beads.rs +0 -413
- package/server/src/routes/cli.rs +0 -150
- package/server/src/routes/fs.rs +0 -360
- package/server/src/routes/git.rs +0 -169
- package/server/src/routes/mod.rs +0 -107
- package/server/src/routes/projects.rs +0 -177
- package/server/src/routes/watch.rs +0 -211
- package/src/app/globals.css +0 -101
- package/src/app/layout.tsx +0 -36
- package/src/app/page.tsx +0 -348
- package/src/app/project/kanban-board.tsx +0 -356
- package/src/app/project/page.tsx +0 -18
- package/src/app/settings/page.tsx +0 -224
- package/src/components/Beams.css +0 -5
- package/src/components/Beams.jsx +0 -307
- package/src/components/Galaxy.css +0 -5
- package/src/components/Galaxy.jsx +0 -333
- package/src/components/activity-timeline.tsx +0 -172
- package/src/components/add-project-dialog.tsx +0 -219
- package/src/components/bead-card.tsx +0 -196
- package/src/components/bead-detail.tsx +0 -306
- package/src/components/color-picker.tsx +0 -101
- package/src/components/comment-input.tsx +0 -155
- package/src/components/comment-list.tsx +0 -147
- package/src/components/dependency-badge.tsx +0 -106
- package/src/components/design-doc-dialog.tsx +0 -58
- package/src/components/design-doc-preview.tsx +0 -97
- package/src/components/design-doc-viewer.tsx +0 -199
- package/src/components/editable-project-name.tsx +0 -178
- package/src/components/epic-card.tsx +0 -263
- package/src/components/folder-browser.tsx +0 -273
- package/src/components/footer.tsx +0 -27
- package/src/components/kanban/default.tsx +0 -184
- package/src/components/kanban-column.tsx +0 -167
- package/src/components/project-card.tsx +0 -191
- package/src/components/quick-filter-bar.tsx +0 -279
- package/src/components/scan-directory-dialog.tsx +0 -368
- package/src/components/status-donut.tsx +0 -197
- package/src/components/subtask-list.tsx +0 -128
- package/src/components/tag-picker.tsx +0 -252
- package/src/components/ui/.gitkeep +0 -0
- package/src/components/ui/alert-dialog.tsx +0 -141
- package/src/components/ui/avatar.tsx +0 -67
- package/src/components/ui/badge.tsx +0 -230
- package/src/components/ui/button.tsx +0 -433
- package/src/components/ui/card/index.tsx +0 -24
- package/src/components/ui/card/roiui-card.module.css +0 -197
- package/src/components/ui/card/roiui-card.tsx +0 -154
- package/src/components/ui/card/shadcn-card.tsx +0 -76
- package/src/components/ui/chart.tsx +0 -369
- package/src/components/ui/dialog.tsx +0 -122
- package/src/components/ui/dropdown-menu.tsx +0 -201
- package/src/components/ui/input.tsx +0 -22
- package/src/components/ui/kanban.tsx +0 -522
- package/src/components/ui/morphing-dialog.tsx +0 -457
- package/src/components/ui/popover.tsx +0 -33
- package/src/components/ui/progress.tsx +0 -28
- package/src/components/ui/scroll-area.tsx +0 -48
- package/src/components/ui/select.tsx +0 -159
- package/src/components/ui/separator.tsx +0 -31
- package/src/components/ui/sheet.tsx +0 -142
- package/src/components/ui/skeleton.tsx +0 -15
- package/src/components/ui/toast.tsx +0 -129
- package/src/components/ui/toaster.tsx +0 -35
- package/src/components/ui/tooltip.tsx +0 -30
- package/src/hooks/.gitkeep +0 -0
- package/src/hooks/use-bead-filters.ts +0 -261
- package/src/hooks/use-beads.ts +0 -162
- package/src/hooks/use-branch-statuses.ts +0 -161
- package/src/hooks/use-epics.ts +0 -173
- package/src/hooks/use-file-watcher.ts +0 -111
- package/src/hooks/use-keyboard-navigation.ts +0 -282
- package/src/hooks/use-project.ts +0 -61
- package/src/hooks/use-projects.ts +0 -93
- package/src/hooks/use-toast.ts +0 -194
- package/src/hooks/useClickOutside.tsx +0 -26
- package/src/lib/.gitkeep +0 -0
- package/src/lib/api.ts +0 -186
- package/src/lib/beads-parser.ts +0 -252
- package/src/lib/cli.ts +0 -193
- package/src/lib/db.ts +0 -145
- package/src/lib/design-doc.ts +0 -74
- package/src/lib/epic-parser.ts +0 -242
- package/src/lib/git.ts +0 -102
- package/src/lib/utils.ts +0 -12
- package/src/types/index.ts +0 -107
- package/tailwind.config.ts +0 -85
- package/tsconfig.json +0 -26
- /package/{npm/bin → bin}/cli.js +0 -0
- /package/{npm/scripts → scripts}/postinstall.js +0 -0
|
@@ -1,368 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState, useCallback } from "react";
|
|
4
|
-
import {
|
|
5
|
-
Dialog,
|
|
6
|
-
DialogContent,
|
|
7
|
-
DialogDescription,
|
|
8
|
-
DialogFooter,
|
|
9
|
-
DialogHeader,
|
|
10
|
-
DialogTitle,
|
|
11
|
-
} from "@/components/ui/dialog";
|
|
12
|
-
import {
|
|
13
|
-
AlertDialog,
|
|
14
|
-
AlertDialogAction,
|
|
15
|
-
AlertDialogCancel,
|
|
16
|
-
AlertDialogContent,
|
|
17
|
-
AlertDialogDescription,
|
|
18
|
-
AlertDialogFooter,
|
|
19
|
-
AlertDialogHeader,
|
|
20
|
-
AlertDialogTitle,
|
|
21
|
-
} from "@/components/ui/alert-dialog";
|
|
22
|
-
import { Button } from "@/components/ui/button";
|
|
23
|
-
import { Badge } from "@/components/ui/badge";
|
|
24
|
-
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
25
|
-
import { Input } from "@/components/ui/input";
|
|
26
|
-
import { useToast } from "@/hooks/use-toast";
|
|
27
|
-
import * as api from "@/lib/api";
|
|
28
|
-
import { cn } from "@/lib/utils";
|
|
29
|
-
import { Folder, Check, Loader2 } from "lucide-react";
|
|
30
|
-
|
|
31
|
-
interface ScannedProject {
|
|
32
|
-
name: string;
|
|
33
|
-
path: string;
|
|
34
|
-
selected: boolean;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
interface ScanDirectoryDialogProps {
|
|
38
|
-
open: boolean;
|
|
39
|
-
onOpenChange: (open: boolean) => void;
|
|
40
|
-
onAddProjects: (projects: { name: string; path: string }[]) => Promise<void>;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
type Step = "select" | "results" | "confirm";
|
|
44
|
-
|
|
45
|
-
export function ScanDirectoryDialog({
|
|
46
|
-
open: isOpen,
|
|
47
|
-
onOpenChange,
|
|
48
|
-
onAddProjects,
|
|
49
|
-
}: ScanDirectoryDialogProps) {
|
|
50
|
-
const [step, setStep] = useState<Step>("select");
|
|
51
|
-
const [selectedDirectory, setSelectedDirectory] = useState<string>("");
|
|
52
|
-
const [isScanning, setIsScanning] = useState(false);
|
|
53
|
-
const [scannedProjects, setScannedProjects] = useState<ScannedProject[]>([]);
|
|
54
|
-
const [isAdding, setIsAdding] = useState(false);
|
|
55
|
-
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
|
56
|
-
const { toast } = useToast();
|
|
57
|
-
|
|
58
|
-
const resetState = useCallback(() => {
|
|
59
|
-
setStep("select");
|
|
60
|
-
setSelectedDirectory("");
|
|
61
|
-
setScannedProjects([]);
|
|
62
|
-
setIsScanning(false);
|
|
63
|
-
setIsAdding(false);
|
|
64
|
-
setShowConfirmDialog(false);
|
|
65
|
-
}, []);
|
|
66
|
-
|
|
67
|
-
const handleOpenChange = useCallback(
|
|
68
|
-
(open: boolean) => {
|
|
69
|
-
if (!open) {
|
|
70
|
-
resetState();
|
|
71
|
-
}
|
|
72
|
-
onOpenChange(open);
|
|
73
|
-
},
|
|
74
|
-
[onOpenChange, resetState]
|
|
75
|
-
);
|
|
76
|
-
|
|
77
|
-
const scanDirectory = useCallback(async () => {
|
|
78
|
-
if (!selectedDirectory) return;
|
|
79
|
-
|
|
80
|
-
setIsScanning(true);
|
|
81
|
-
try {
|
|
82
|
-
const { entries } = await api.fs.list(selectedDirectory);
|
|
83
|
-
const dirs = entries.filter((e) => e.isDirectory);
|
|
84
|
-
|
|
85
|
-
// Check each subdirectory for .beads folder in parallel
|
|
86
|
-
const projectResults = await Promise.all(
|
|
87
|
-
dirs.map(async (dir) => {
|
|
88
|
-
const result = await api.fs.exists(`${dir.path}/.beads`);
|
|
89
|
-
return {
|
|
90
|
-
name: dir.name,
|
|
91
|
-
path: dir.path,
|
|
92
|
-
hasBeads: result.exists,
|
|
93
|
-
};
|
|
94
|
-
})
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
// Filter to only directories with .beads
|
|
98
|
-
const projectsFound = projectResults
|
|
99
|
-
.filter((p) => p.hasBeads)
|
|
100
|
-
.map((p) => ({
|
|
101
|
-
name: p.name,
|
|
102
|
-
path: p.path,
|
|
103
|
-
selected: true, // All selected by default
|
|
104
|
-
}));
|
|
105
|
-
|
|
106
|
-
setScannedProjects(projectsFound);
|
|
107
|
-
setStep("results");
|
|
108
|
-
|
|
109
|
-
if (projectsFound.length === 0) {
|
|
110
|
-
toast({
|
|
111
|
-
title: "No projects found",
|
|
112
|
-
description: "No beads projects found in subdirectories.",
|
|
113
|
-
variant: "destructive",
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
} catch (err) {
|
|
117
|
-
console.error("Error scanning directory:", err);
|
|
118
|
-
toast({
|
|
119
|
-
title: "Scan failed",
|
|
120
|
-
description:
|
|
121
|
-
err instanceof Error ? err.message : "Failed to scan directory.",
|
|
122
|
-
variant: "destructive",
|
|
123
|
-
});
|
|
124
|
-
} finally {
|
|
125
|
-
setIsScanning(false);
|
|
126
|
-
}
|
|
127
|
-
}, [selectedDirectory, toast]);
|
|
128
|
-
|
|
129
|
-
const toggleProject = useCallback((path: string) => {
|
|
130
|
-
setScannedProjects((prev) =>
|
|
131
|
-
prev.map((p) => (p.path === path ? { ...p, selected: !p.selected } : p))
|
|
132
|
-
);
|
|
133
|
-
}, []);
|
|
134
|
-
|
|
135
|
-
const toggleAll = useCallback(() => {
|
|
136
|
-
const allSelected = scannedProjects.every((p) => p.selected);
|
|
137
|
-
setScannedProjects((prev) =>
|
|
138
|
-
prev.map((p) => ({ ...p, selected: !allSelected }))
|
|
139
|
-
);
|
|
140
|
-
}, [scannedProjects]);
|
|
141
|
-
|
|
142
|
-
const selectedCount = scannedProjects.filter((p) => p.selected).length;
|
|
143
|
-
|
|
144
|
-
const handleAddProjects = useCallback(async () => {
|
|
145
|
-
const projectsToAdd = scannedProjects
|
|
146
|
-
.filter((p) => p.selected)
|
|
147
|
-
.map(({ name, path }) => ({ name, path }));
|
|
148
|
-
|
|
149
|
-
if (projectsToAdd.length === 0) return;
|
|
150
|
-
|
|
151
|
-
setIsAdding(true);
|
|
152
|
-
setShowConfirmDialog(false);
|
|
153
|
-
|
|
154
|
-
try {
|
|
155
|
-
await onAddProjects(projectsToAdd);
|
|
156
|
-
toast({
|
|
157
|
-
title: "Projects added",
|
|
158
|
-
description: `Successfully added ${projectsToAdd.length} project${projectsToAdd.length > 1 ? "s" : ""}.`,
|
|
159
|
-
});
|
|
160
|
-
handleOpenChange(false);
|
|
161
|
-
} catch (err) {
|
|
162
|
-
console.error("Error adding projects:", err);
|
|
163
|
-
toast({
|
|
164
|
-
title: "Error",
|
|
165
|
-
description: "Failed to add some projects. Please try again.",
|
|
166
|
-
variant: "destructive",
|
|
167
|
-
});
|
|
168
|
-
} finally {
|
|
169
|
-
setIsAdding(false);
|
|
170
|
-
}
|
|
171
|
-
}, [scannedProjects, onAddProjects, toast, handleOpenChange]);
|
|
172
|
-
|
|
173
|
-
return (
|
|
174
|
-
<>
|
|
175
|
-
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
|
176
|
-
<DialogContent className="sm:max-w-lg">
|
|
177
|
-
<DialogHeader>
|
|
178
|
-
<DialogTitle>Scan for Projects</DialogTitle>
|
|
179
|
-
<DialogDescription>
|
|
180
|
-
{step === "select"
|
|
181
|
-
? "Select a parent directory to scan for beads projects."
|
|
182
|
-
: `Found ${scannedProjects.length} project${scannedProjects.length !== 1 ? "s" : ""} in subdirectories.`}
|
|
183
|
-
</DialogDescription>
|
|
184
|
-
</DialogHeader>
|
|
185
|
-
|
|
186
|
-
{step === "select" && (
|
|
187
|
-
<div className="flex flex-col gap-4 py-4">
|
|
188
|
-
<div className="space-y-2">
|
|
189
|
-
<label htmlFor="scan-path" className="text-sm font-medium text-zinc-300">
|
|
190
|
-
Parent Directory
|
|
191
|
-
</label>
|
|
192
|
-
<div className="relative">
|
|
193
|
-
<Folder className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-500" aria-hidden="true" />
|
|
194
|
-
<Input
|
|
195
|
-
id="scan-path"
|
|
196
|
-
value={selectedDirectory}
|
|
197
|
-
onChange={(e) => setSelectedDirectory(e.target.value)}
|
|
198
|
-
placeholder="/path/to/parent/directory"
|
|
199
|
-
className="pl-10"
|
|
200
|
-
autoFocus
|
|
201
|
-
/>
|
|
202
|
-
</div>
|
|
203
|
-
<p className="text-xs text-zinc-500">
|
|
204
|
-
Enter a directory path to scan its subdirectories for beads projects.
|
|
205
|
-
</p>
|
|
206
|
-
</div>
|
|
207
|
-
|
|
208
|
-
<DialogFooter>
|
|
209
|
-
<Button
|
|
210
|
-
onClick={scanDirectory}
|
|
211
|
-
disabled={!selectedDirectory.trim() || isScanning}
|
|
212
|
-
>
|
|
213
|
-
{isScanning ? (
|
|
214
|
-
<>
|
|
215
|
-
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
|
|
216
|
-
Scanning...
|
|
217
|
-
</>
|
|
218
|
-
) : (
|
|
219
|
-
"Scan for Projects"
|
|
220
|
-
)}
|
|
221
|
-
</Button>
|
|
222
|
-
</DialogFooter>
|
|
223
|
-
</div>
|
|
224
|
-
)}
|
|
225
|
-
|
|
226
|
-
{step === "results" && (
|
|
227
|
-
<div className="flex flex-col gap-4 py-4">
|
|
228
|
-
{scannedProjects.length === 0 ? (
|
|
229
|
-
<div className="rounded-md border border-zinc-700 bg-zinc-800/50 px-4 py-8 text-center">
|
|
230
|
-
<p className="text-zinc-400">
|
|
231
|
-
No beads projects found in subdirectories.
|
|
232
|
-
</p>
|
|
233
|
-
<p className="mt-1 text-sm text-zinc-500">
|
|
234
|
-
Make sure subdirectories have a .beads folder.
|
|
235
|
-
</p>
|
|
236
|
-
</div>
|
|
237
|
-
) : (
|
|
238
|
-
<>
|
|
239
|
-
<div className="flex items-center justify-between">
|
|
240
|
-
<Badge variant="info" size="sm">
|
|
241
|
-
{selectedCount} of {scannedProjects.length} selected
|
|
242
|
-
</Badge>
|
|
243
|
-
<Button
|
|
244
|
-
variant="ghost"
|
|
245
|
-
size="sm"
|
|
246
|
-
onClick={toggleAll}
|
|
247
|
-
className="text-xs"
|
|
248
|
-
>
|
|
249
|
-
{scannedProjects.every((p) => p.selected)
|
|
250
|
-
? "Deselect All"
|
|
251
|
-
: "Select All"}
|
|
252
|
-
</Button>
|
|
253
|
-
</div>
|
|
254
|
-
|
|
255
|
-
<ScrollArea className="h-[250px] rounded-md border border-zinc-700 bg-zinc-800/50">
|
|
256
|
-
<div className="p-2" role="listbox" aria-label="Found projects">
|
|
257
|
-
{scannedProjects.map((project) => (
|
|
258
|
-
<button
|
|
259
|
-
key={project.path}
|
|
260
|
-
type="button"
|
|
261
|
-
role="option"
|
|
262
|
-
aria-selected={project.selected}
|
|
263
|
-
onClick={() => toggleProject(project.path)}
|
|
264
|
-
className={cn(
|
|
265
|
-
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors",
|
|
266
|
-
project.selected
|
|
267
|
-
? "bg-zinc-700 text-zinc-100"
|
|
268
|
-
: "text-zinc-400 hover:bg-zinc-700/50"
|
|
269
|
-
)}
|
|
270
|
-
>
|
|
271
|
-
<div
|
|
272
|
-
className={cn(
|
|
273
|
-
"flex size-5 shrink-0 items-center justify-center rounded border",
|
|
274
|
-
project.selected
|
|
275
|
-
? "border-purple-500 bg-purple-500"
|
|
276
|
-
: "border-zinc-600 bg-transparent"
|
|
277
|
-
)}
|
|
278
|
-
>
|
|
279
|
-
{project.selected && (
|
|
280
|
-
<Check className="size-3 text-white" />
|
|
281
|
-
)}
|
|
282
|
-
</div>
|
|
283
|
-
<Folder
|
|
284
|
-
className={cn(
|
|
285
|
-
"size-4 shrink-0",
|
|
286
|
-
project.selected
|
|
287
|
-
? "text-purple-400"
|
|
288
|
-
: "text-zinc-500"
|
|
289
|
-
)}
|
|
290
|
-
/>
|
|
291
|
-
<div className="min-w-0 flex-1">
|
|
292
|
-
<p className="truncate font-medium">
|
|
293
|
-
{project.name}
|
|
294
|
-
</p>
|
|
295
|
-
<p className="truncate text-xs text-zinc-500">
|
|
296
|
-
{project.path}
|
|
297
|
-
</p>
|
|
298
|
-
</div>
|
|
299
|
-
</button>
|
|
300
|
-
))}
|
|
301
|
-
</div>
|
|
302
|
-
</ScrollArea>
|
|
303
|
-
</>
|
|
304
|
-
)}
|
|
305
|
-
|
|
306
|
-
<DialogFooter className="gap-2 sm:gap-2">
|
|
307
|
-
<Button variant="outline" onClick={() => setStep("select")}>
|
|
308
|
-
Back
|
|
309
|
-
</Button>
|
|
310
|
-
<Button
|
|
311
|
-
onClick={() => setShowConfirmDialog(true)}
|
|
312
|
-
disabled={selectedCount === 0}
|
|
313
|
-
>
|
|
314
|
-
Add {selectedCount} Project{selectedCount !== 1 ? "s" : ""}
|
|
315
|
-
</Button>
|
|
316
|
-
</DialogFooter>
|
|
317
|
-
</div>
|
|
318
|
-
)}
|
|
319
|
-
</DialogContent>
|
|
320
|
-
</Dialog>
|
|
321
|
-
|
|
322
|
-
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
|
323
|
-
<AlertDialogContent>
|
|
324
|
-
<AlertDialogHeader>
|
|
325
|
-
<AlertDialogTitle>
|
|
326
|
-
Add {selectedCount} Project{selectedCount !== 1 ? "s" : ""}?
|
|
327
|
-
</AlertDialogTitle>
|
|
328
|
-
<AlertDialogDescription asChild>
|
|
329
|
-
<div>
|
|
330
|
-
<p className="mb-3">
|
|
331
|
-
The following projects will be added to your dashboard:
|
|
332
|
-
</p>
|
|
333
|
-
<ScrollArea className="max-h-[150px] rounded-md border border-zinc-700 bg-zinc-800/50">
|
|
334
|
-
<ul className="p-2 text-sm text-zinc-300">
|
|
335
|
-
{scannedProjects
|
|
336
|
-
.filter((p) => p.selected)
|
|
337
|
-
.map((project) => (
|
|
338
|
-
<li
|
|
339
|
-
key={project.path}
|
|
340
|
-
className="flex items-center gap-2 py-1"
|
|
341
|
-
>
|
|
342
|
-
<Folder className="size-3 shrink-0 text-purple-400" />
|
|
343
|
-
<span className="truncate">{project.name}</span>
|
|
344
|
-
</li>
|
|
345
|
-
))}
|
|
346
|
-
</ul>
|
|
347
|
-
</ScrollArea>
|
|
348
|
-
</div>
|
|
349
|
-
</AlertDialogDescription>
|
|
350
|
-
</AlertDialogHeader>
|
|
351
|
-
<AlertDialogFooter>
|
|
352
|
-
<AlertDialogCancel disabled={isAdding}>Cancel</AlertDialogCancel>
|
|
353
|
-
<AlertDialogAction onClick={handleAddProjects} disabled={isAdding}>
|
|
354
|
-
{isAdding ? (
|
|
355
|
-
<>
|
|
356
|
-
<Loader2 className="size-4 animate-spin" />
|
|
357
|
-
Adding...
|
|
358
|
-
</>
|
|
359
|
-
) : (
|
|
360
|
-
"Add All"
|
|
361
|
-
)}
|
|
362
|
-
</AlertDialogAction>
|
|
363
|
-
</AlertDialogFooter>
|
|
364
|
-
</AlertDialogContent>
|
|
365
|
-
</AlertDialog>
|
|
366
|
-
</>
|
|
367
|
-
);
|
|
368
|
-
}
|
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useMemo, useState } from "react";
|
|
4
|
-
|
|
5
|
-
interface BeadCounts {
|
|
6
|
-
open: number;
|
|
7
|
-
in_progress: number;
|
|
8
|
-
inreview: number;
|
|
9
|
-
closed: number;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
interface StatusDonutProps {
|
|
13
|
-
beadCounts: BeadCounts;
|
|
14
|
-
size?: number;
|
|
15
|
-
className?: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// Status colors matching the kanban board
|
|
19
|
-
const STATUS_COLORS = {
|
|
20
|
-
open: "#3b82f6", // blue-500
|
|
21
|
-
in_progress: "#f59e0b", // amber-500
|
|
22
|
-
inreview: "#a855f7", // purple-500
|
|
23
|
-
closed: "#22c55e", // green-500
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
// Custom tooltip showing all statuses
|
|
27
|
-
function StatusTooltip({ beadCounts, total }: { beadCounts: BeadCounts; total: number }) {
|
|
28
|
-
return (
|
|
29
|
-
<div className="rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 shadow-lg">
|
|
30
|
-
<div className="mb-1.5 text-xs font-medium text-zinc-300">
|
|
31
|
-
{total} task{total !== 1 ? "s" : ""}
|
|
32
|
-
</div>
|
|
33
|
-
<div className="space-y-1">
|
|
34
|
-
{beadCounts.open > 0 && (
|
|
35
|
-
<div className="flex items-center gap-2 text-xs">
|
|
36
|
-
<div className="h-2 w-2 rounded-sm" style={{ backgroundColor: STATUS_COLORS.open }} />
|
|
37
|
-
<span className="text-zinc-400">Open</span>
|
|
38
|
-
<span className="ml-auto font-mono text-zinc-100">{beadCounts.open}</span>
|
|
39
|
-
</div>
|
|
40
|
-
)}
|
|
41
|
-
{beadCounts.in_progress > 0 && (
|
|
42
|
-
<div className="flex items-center gap-2 text-xs">
|
|
43
|
-
<div className="h-2 w-2 rounded-sm" style={{ backgroundColor: STATUS_COLORS.in_progress }} />
|
|
44
|
-
<span className="text-zinc-400">In Progress</span>
|
|
45
|
-
<span className="ml-auto font-mono text-zinc-100">{beadCounts.in_progress}</span>
|
|
46
|
-
</div>
|
|
47
|
-
)}
|
|
48
|
-
{beadCounts.inreview > 0 && (
|
|
49
|
-
<div className="flex items-center gap-2 text-xs">
|
|
50
|
-
<div className="h-2 w-2 rounded-sm" style={{ backgroundColor: STATUS_COLORS.inreview }} />
|
|
51
|
-
<span className="text-zinc-400">In Review</span>
|
|
52
|
-
<span className="ml-auto font-mono text-zinc-100">{beadCounts.inreview}</span>
|
|
53
|
-
</div>
|
|
54
|
-
)}
|
|
55
|
-
{beadCounts.closed > 0 && (
|
|
56
|
-
<div className="flex items-center gap-2 text-xs">
|
|
57
|
-
<div className="h-2 w-2 rounded-sm" style={{ backgroundColor: STATUS_COLORS.closed }} />
|
|
58
|
-
<span className="text-zinc-400">Closed</span>
|
|
59
|
-
<span className="ml-auto font-mono text-zinc-100">{beadCounts.closed}</span>
|
|
60
|
-
</div>
|
|
61
|
-
)}
|
|
62
|
-
</div>
|
|
63
|
-
</div>
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function StatusDonut({ beadCounts, size = 40, className }: StatusDonutProps) {
|
|
68
|
-
const [isHovered, setIsHovered] = useState(false);
|
|
69
|
-
|
|
70
|
-
const chartData = useMemo(() => {
|
|
71
|
-
return [
|
|
72
|
-
{ status: "open", count: beadCounts.open, fill: STATUS_COLORS.open },
|
|
73
|
-
{ status: "in_progress", count: beadCounts.in_progress, fill: STATUS_COLORS.in_progress },
|
|
74
|
-
{ status: "inreview", count: beadCounts.inreview, fill: STATUS_COLORS.inreview },
|
|
75
|
-
{ status: "closed", count: beadCounts.closed, fill: STATUS_COLORS.closed },
|
|
76
|
-
].filter((item) => item.count > 0);
|
|
77
|
-
}, [beadCounts]);
|
|
78
|
-
|
|
79
|
-
const total = useMemo(() => {
|
|
80
|
-
return beadCounts.open + beadCounts.in_progress + beadCounts.inreview + beadCounts.closed;
|
|
81
|
-
}, [beadCounts]);
|
|
82
|
-
|
|
83
|
-
// If no tasks, show empty state
|
|
84
|
-
if (total === 0) {
|
|
85
|
-
return (
|
|
86
|
-
<div
|
|
87
|
-
className={className}
|
|
88
|
-
style={{ width: size, height: size }}
|
|
89
|
-
aria-label="No tasks"
|
|
90
|
-
>
|
|
91
|
-
<div
|
|
92
|
-
className="rounded-full border-2 border-dashed border-zinc-700 w-full h-full"
|
|
93
|
-
title="No tasks"
|
|
94
|
-
/>
|
|
95
|
-
</div>
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const innerRadius = size * 0.32;
|
|
100
|
-
const outerRadius = size * 0.48;
|
|
101
|
-
// Only add padding when there are multiple segments
|
|
102
|
-
const paddingAngle = chartData.length > 1 ? 3 : 0;
|
|
103
|
-
|
|
104
|
-
return (
|
|
105
|
-
<div
|
|
106
|
-
className={`relative ${className || ""}`}
|
|
107
|
-
style={{ width: size, height: size }}
|
|
108
|
-
onMouseEnter={() => setIsHovered(true)}
|
|
109
|
-
onMouseLeave={() => setIsHovered(false)}
|
|
110
|
-
>
|
|
111
|
-
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
|
112
|
-
<g transform={`translate(${size / 2}, ${size / 2})`}>
|
|
113
|
-
{/* Render pie segments */}
|
|
114
|
-
{chartData.map((entry, index) => {
|
|
115
|
-
// Calculate angles for each segment
|
|
116
|
-
const startAngle = chartData
|
|
117
|
-
.slice(0, index)
|
|
118
|
-
.reduce((acc, d) => acc + (d.count / total) * 360, 0);
|
|
119
|
-
const segmentAngle = (entry.count / total) * 360;
|
|
120
|
-
const endAngle = startAngle + segmentAngle;
|
|
121
|
-
|
|
122
|
-
// Check if this is a full circle (single segment covering 100%)
|
|
123
|
-
const isFullCircle = chartData.length === 1;
|
|
124
|
-
|
|
125
|
-
if (isFullCircle) {
|
|
126
|
-
// For full circle, use two semicircular arcs
|
|
127
|
-
// This avoids the issue where start and end points are the same
|
|
128
|
-
return (
|
|
129
|
-
<g key={entry.status}>
|
|
130
|
-
{/* First semicircle (top half) */}
|
|
131
|
-
<path
|
|
132
|
-
d={`M 0 ${-outerRadius} A ${outerRadius} ${outerRadius} 0 0 1 0 ${outerRadius} L 0 ${innerRadius} A ${innerRadius} ${innerRadius} 0 0 0 0 ${-innerRadius} Z`}
|
|
133
|
-
fill={entry.fill}
|
|
134
|
-
/>
|
|
135
|
-
{/* Second semicircle (bottom half) */}
|
|
136
|
-
<path
|
|
137
|
-
d={`M 0 ${outerRadius} A ${outerRadius} ${outerRadius} 0 0 1 0 ${-outerRadius} L 0 ${-innerRadius} A ${innerRadius} ${innerRadius} 0 0 0 0 ${innerRadius} Z`}
|
|
138
|
-
fill={entry.fill}
|
|
139
|
-
/>
|
|
140
|
-
</g>
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Add small padding between segments (only if multiple segments)
|
|
145
|
-
const adjustedStart = startAngle + paddingAngle / 2;
|
|
146
|
-
const adjustedEnd = endAngle - paddingAngle / 2;
|
|
147
|
-
|
|
148
|
-
// Convert to radians (SVG uses radians, start from top)
|
|
149
|
-
const startRad = ((adjustedStart - 90) * Math.PI) / 180;
|
|
150
|
-
const endRad = ((adjustedEnd - 90) * Math.PI) / 180;
|
|
151
|
-
|
|
152
|
-
// Calculate arc path
|
|
153
|
-
const x1 = Math.cos(startRad) * outerRadius;
|
|
154
|
-
const y1 = Math.sin(startRad) * outerRadius;
|
|
155
|
-
const x2 = Math.cos(endRad) * outerRadius;
|
|
156
|
-
const y2 = Math.sin(endRad) * outerRadius;
|
|
157
|
-
const x3 = Math.cos(endRad) * innerRadius;
|
|
158
|
-
const y3 = Math.sin(endRad) * innerRadius;
|
|
159
|
-
const x4 = Math.cos(startRad) * innerRadius;
|
|
160
|
-
const y4 = Math.sin(startRad) * innerRadius;
|
|
161
|
-
|
|
162
|
-
const largeArcFlag = adjustedEnd - adjustedStart > 180 ? 1 : 0;
|
|
163
|
-
|
|
164
|
-
const d = [
|
|
165
|
-
`M ${x1} ${y1}`,
|
|
166
|
-
`A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} 1 ${x2} ${y2}`,
|
|
167
|
-
`L ${x3} ${y3}`,
|
|
168
|
-
`A ${innerRadius} ${innerRadius} 0 ${largeArcFlag} 0 ${x4} ${y4}`,
|
|
169
|
-
"Z",
|
|
170
|
-
].join(" ");
|
|
171
|
-
|
|
172
|
-
return (
|
|
173
|
-
<path
|
|
174
|
-
key={entry.status}
|
|
175
|
-
d={d}
|
|
176
|
-
fill={entry.fill}
|
|
177
|
-
/>
|
|
178
|
-
);
|
|
179
|
-
})}
|
|
180
|
-
{/* Invisible circle covering entire donut area for hover detection */}
|
|
181
|
-
<circle
|
|
182
|
-
r={outerRadius}
|
|
183
|
-
fill="transparent"
|
|
184
|
-
style={{ cursor: "default" }}
|
|
185
|
-
/>
|
|
186
|
-
</g>
|
|
187
|
-
</svg>
|
|
188
|
-
|
|
189
|
-
{/* Tooltip - shown on hover anywhere in the donut area */}
|
|
190
|
-
{isHovered && (
|
|
191
|
-
<div className="absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2 whitespace-nowrap">
|
|
192
|
-
<StatusTooltip beadCounts={beadCounts} total={total} />
|
|
193
|
-
</div>
|
|
194
|
-
)}
|
|
195
|
-
</div>
|
|
196
|
-
);
|
|
197
|
-
}
|
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { cn } from "@/lib/utils";
|
|
4
|
-
import type { Bead, BeadStatus } from "@/types";
|
|
5
|
-
import { Check, Circle, Clock, FileCheck } from "lucide-react";
|
|
6
|
-
|
|
7
|
-
export interface SubtaskListProps {
|
|
8
|
-
/** Child tasks to display */
|
|
9
|
-
childTasks: Bead[];
|
|
10
|
-
/** Callback when clicking a child task */
|
|
11
|
-
onChildClick: (child: Bead) => void;
|
|
12
|
-
/** Maximum number of children to show when collapsed */
|
|
13
|
-
maxCollapsed?: number;
|
|
14
|
-
/** Whether the list is expanded */
|
|
15
|
-
isExpanded?: boolean;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Get status icon based on bead status
|
|
20
|
-
*/
|
|
21
|
-
function getStatusIcon(status: BeadStatus) {
|
|
22
|
-
switch (status) {
|
|
23
|
-
case 'closed':
|
|
24
|
-
return <Check className="h-3.5 w-3.5 text-green-400" aria-hidden="true" />;
|
|
25
|
-
case 'in_progress':
|
|
26
|
-
return <Clock className="h-3.5 w-3.5 text-blue-400" aria-hidden="true" />;
|
|
27
|
-
case 'inreview':
|
|
28
|
-
return <FileCheck className="h-3.5 w-3.5 text-purple-400" aria-hidden="true" />;
|
|
29
|
-
case 'open':
|
|
30
|
-
default:
|
|
31
|
-
return <Circle className="h-3.5 w-3.5 text-zinc-500" aria-hidden="true" />;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Get status text color
|
|
37
|
-
*/
|
|
38
|
-
function getStatusColor(status: BeadStatus): string {
|
|
39
|
-
switch (status) {
|
|
40
|
-
case 'closed':
|
|
41
|
-
return "text-green-400";
|
|
42
|
-
case 'in_progress':
|
|
43
|
-
return "text-blue-400";
|
|
44
|
-
case 'inreview':
|
|
45
|
-
return "text-purple-400";
|
|
46
|
-
case 'open':
|
|
47
|
-
default:
|
|
48
|
-
return "text-zinc-500";
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Truncate text to max length
|
|
54
|
-
*/
|
|
55
|
-
function truncate(text: string, maxLength: number): string {
|
|
56
|
-
if (text.length <= maxLength) return text;
|
|
57
|
-
return text.slice(0, maxLength).trim() + "…";
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Compact list of child tasks within epic card
|
|
62
|
-
*/
|
|
63
|
-
export function SubtaskList({
|
|
64
|
-
childTasks,
|
|
65
|
-
onChildClick,
|
|
66
|
-
maxCollapsed = 3,
|
|
67
|
-
isExpanded = false
|
|
68
|
-
}: SubtaskListProps) {
|
|
69
|
-
if (childTasks.length === 0) {
|
|
70
|
-
return (
|
|
71
|
-
<div className="text-xs text-muted-foreground italic">
|
|
72
|
-
No child tasks
|
|
73
|
-
</div>
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const displayChildren = isExpanded ? childTasks : childTasks.slice(0, maxCollapsed);
|
|
78
|
-
const hasMore = childTasks.length > maxCollapsed && !isExpanded;
|
|
79
|
-
|
|
80
|
-
return (
|
|
81
|
-
<div className="space-y-1">
|
|
82
|
-
{displayChildren.map((child) => (
|
|
83
|
-
<button
|
|
84
|
-
key={child.id}
|
|
85
|
-
onClick={(e) => {
|
|
86
|
-
e.stopPropagation();
|
|
87
|
-
onChildClick(child);
|
|
88
|
-
}}
|
|
89
|
-
aria-label={`Open task: ${child.title}`}
|
|
90
|
-
className={cn(
|
|
91
|
-
"w-full flex items-start gap-2 px-2 py-1.5 rounded-md",
|
|
92
|
-
"hover:bg-zinc-800 transition-colors text-left",
|
|
93
|
-
"group"
|
|
94
|
-
)}
|
|
95
|
-
>
|
|
96
|
-
<div className="flex-shrink-0 mt-0.5">
|
|
97
|
-
{getStatusIcon(child.status)}
|
|
98
|
-
</div>
|
|
99
|
-
<div className="flex-1 min-w-0">
|
|
100
|
-
<p className={cn(
|
|
101
|
-
"text-xs font-medium group-hover:underline",
|
|
102
|
-
child.status === 'closed' && "line-through text-zinc-500",
|
|
103
|
-
child.status !== 'closed' && "text-zinc-200"
|
|
104
|
-
)}>
|
|
105
|
-
{truncate(child.title, 50)}
|
|
106
|
-
</p>
|
|
107
|
-
{child.description && (
|
|
108
|
-
<p className="text-[10px] text-zinc-500 mt-0.5">
|
|
109
|
-
{truncate(child.description, 60)}
|
|
110
|
-
</p>
|
|
111
|
-
)}
|
|
112
|
-
</div>
|
|
113
|
-
<div className={cn(
|
|
114
|
-
"flex-shrink-0 text-[9px] font-medium uppercase tracking-wide",
|
|
115
|
-
getStatusColor(child.status)
|
|
116
|
-
)}>
|
|
117
|
-
{child.status.replace('_', ' ')}
|
|
118
|
-
</div>
|
|
119
|
-
</button>
|
|
120
|
-
))}
|
|
121
|
-
{hasMore && (
|
|
122
|
-
<p className="text-[10px] text-muted-foreground text-center py-1">
|
|
123
|
-
+{childTasks.length - maxCollapsed} more
|
|
124
|
-
</p>
|
|
125
|
-
)}
|
|
126
|
-
</div>
|
|
127
|
-
);
|
|
128
|
-
}
|