beads-kanban-ui 0.1.0 → 0.1.1
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
package/src/hooks/use-epics.ts
DELETED
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Hook for managing epic-specific state and filtering.
|
|
5
|
-
*
|
|
6
|
-
* Derived from useBeads, provides:
|
|
7
|
-
* - Top-level beads only (no child tasks)
|
|
8
|
-
* - Separated epics and standalone tasks
|
|
9
|
-
* - Epic progress computation
|
|
10
|
-
* - Expansion state management
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { useMemo, useState, useCallback } from "react";
|
|
14
|
-
import type { Bead, Epic, EpicProgress } from "@/types";
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Result type for the useEpics hook
|
|
18
|
-
*/
|
|
19
|
-
export interface UseEpicsResult {
|
|
20
|
-
/** Top-level beads only (no children) */
|
|
21
|
-
topLevelBeads: Bead[];
|
|
22
|
-
/** Epics with computed progress */
|
|
23
|
-
epics: Epic[];
|
|
24
|
-
/** Standalone tasks (not epics, not children) */
|
|
25
|
-
standaloneTasks: Bead[];
|
|
26
|
-
/** Map of epic ID to expansion state */
|
|
27
|
-
expandedEpics: Set<string>;
|
|
28
|
-
/** Toggle epic expansion */
|
|
29
|
-
toggleEpic: (epicId: string) => void;
|
|
30
|
-
/** Expand all epics */
|
|
31
|
-
expandAll: () => void;
|
|
32
|
-
/** Collapse all epics */
|
|
33
|
-
collapseAll: () => void;
|
|
34
|
-
/** Check if epic is expanded */
|
|
35
|
-
isExpanded: (epicId: string) => boolean;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Compute progress metrics for an epic based on its children
|
|
40
|
-
*/
|
|
41
|
-
function computeEpicProgress(children: Bead[]): EpicProgress {
|
|
42
|
-
const total = children.length;
|
|
43
|
-
const completed = children.filter(c => c.status === 'closed').length;
|
|
44
|
-
const inProgress = children.filter(c => c.status === 'in_progress').length;
|
|
45
|
-
// Blocked = has unresolved dependencies
|
|
46
|
-
const blocked = children.filter(c => (c.deps?.length ?? 0) > 0).length;
|
|
47
|
-
|
|
48
|
-
return { total, completed, inProgress, blocked };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Type guard to check if a bead is an epic
|
|
53
|
-
*/
|
|
54
|
-
function isEpic(bead: Bead): bead is Epic {
|
|
55
|
-
return bead.issue_type === 'epic';
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Hook to manage epic state and filtering
|
|
60
|
-
*
|
|
61
|
-
* @param beads - All beads from the project
|
|
62
|
-
* @returns Object with top-level beads, epics, tasks, and expansion controls
|
|
63
|
-
*
|
|
64
|
-
* @example
|
|
65
|
-
* ```tsx
|
|
66
|
-
* function KanbanBoard() {
|
|
67
|
-
* const { beads } = useBeads(projectPath);
|
|
68
|
-
* const { topLevelBeads, epics, standaloneTasks, isExpanded, toggleEpic } = useEpics(beads);
|
|
69
|
-
*
|
|
70
|
-
* return (
|
|
71
|
-
* <div>
|
|
72
|
-
* {topLevelBeads.map(bead =>
|
|
73
|
-
* isEpic(bead)
|
|
74
|
-
* ? <EpicCard epic={bead} isExpanded={isExpanded(bead.id)} />
|
|
75
|
-
* : <BeadCard bead={bead} />
|
|
76
|
-
* )}
|
|
77
|
-
* </div>
|
|
78
|
-
* );
|
|
79
|
-
* }
|
|
80
|
-
* ```
|
|
81
|
-
*/
|
|
82
|
-
export function useEpics(beads: Bead[]): UseEpicsResult {
|
|
83
|
-
// Track expanded epic IDs
|
|
84
|
-
const [expandedEpics, setExpandedEpics] = useState<Set<string>>(new Set());
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Filter to only top-level beads (no parent_id)
|
|
88
|
-
* This prevents child tasks from appearing in columns
|
|
89
|
-
*/
|
|
90
|
-
const topLevelBeads = useMemo(() => {
|
|
91
|
-
return beads.filter(b => !b.parent_id);
|
|
92
|
-
}, [beads]);
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Separate epics from standalone tasks and compute progress
|
|
96
|
-
*/
|
|
97
|
-
const { epics, standaloneTasks } = useMemo(() => {
|
|
98
|
-
const epicsList: Epic[] = [];
|
|
99
|
-
const tasksList: Bead[] = [];
|
|
100
|
-
|
|
101
|
-
for (const bead of topLevelBeads) {
|
|
102
|
-
if (isEpic(bead)) {
|
|
103
|
-
// Resolve children from all beads (including child tasks)
|
|
104
|
-
const children = (bead.children || [])
|
|
105
|
-
.map(childId => beads.find(b => b.id === childId))
|
|
106
|
-
.filter((b): b is Bead => b !== undefined);
|
|
107
|
-
|
|
108
|
-
// Compute progress
|
|
109
|
-
const progress = computeEpicProgress(children);
|
|
110
|
-
|
|
111
|
-
epicsList.push({
|
|
112
|
-
...bead,
|
|
113
|
-
progress
|
|
114
|
-
});
|
|
115
|
-
} else {
|
|
116
|
-
tasksList.push(bead);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return {
|
|
121
|
-
epics: epicsList,
|
|
122
|
-
standaloneTasks: tasksList
|
|
123
|
-
};
|
|
124
|
-
}, [topLevelBeads, beads]);
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Toggle epic expansion state
|
|
128
|
-
* Uses functional update to avoid stale closure issues
|
|
129
|
-
*/
|
|
130
|
-
const toggleEpic = useCallback((epicId: string) => {
|
|
131
|
-
setExpandedEpics(current => {
|
|
132
|
-
const next = new Set(current);
|
|
133
|
-
if (next.has(epicId)) {
|
|
134
|
-
next.delete(epicId);
|
|
135
|
-
} else {
|
|
136
|
-
next.add(epicId);
|
|
137
|
-
}
|
|
138
|
-
return next;
|
|
139
|
-
});
|
|
140
|
-
}, []);
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Expand all epics
|
|
144
|
-
*/
|
|
145
|
-
const expandAll = useCallback(() => {
|
|
146
|
-
setExpandedEpics(new Set(epics.map(e => e.id)));
|
|
147
|
-
}, [epics]);
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Collapse all epics
|
|
151
|
-
*/
|
|
152
|
-
const collapseAll = useCallback(() => {
|
|
153
|
-
setExpandedEpics(new Set());
|
|
154
|
-
}, []);
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Check if an epic is expanded
|
|
158
|
-
*/
|
|
159
|
-
const isExpanded = useCallback((epicId: string) => {
|
|
160
|
-
return expandedEpics.has(epicId);
|
|
161
|
-
}, [expandedEpics]);
|
|
162
|
-
|
|
163
|
-
return {
|
|
164
|
-
topLevelBeads,
|
|
165
|
-
epics,
|
|
166
|
-
standaloneTasks,
|
|
167
|
-
expandedEpics,
|
|
168
|
-
toggleEpic,
|
|
169
|
-
expandAll,
|
|
170
|
-
collapseAll,
|
|
171
|
-
isExpanded
|
|
172
|
-
};
|
|
173
|
-
}
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Hook for watching file changes via SSE (Server-Sent Events).
|
|
5
|
-
*
|
|
6
|
-
* Uses the backend API to watch for file changes and emit events
|
|
7
|
-
* to the frontend. Includes debouncing to prevent rapid-fire callbacks.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { useEffect, useState, useRef, useCallback } from "react";
|
|
11
|
-
import * as api from "@/lib/api";
|
|
12
|
-
|
|
13
|
-
/** Return type for the useFileWatcher hook. */
|
|
14
|
-
interface UseFileWatcherResult {
|
|
15
|
-
/** Whether the file is currently being watched. */
|
|
16
|
-
isWatching: boolean;
|
|
17
|
-
/** Any error that occurred while setting up or running the watcher. */
|
|
18
|
-
error: Error | null;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Hook to watch a project's beads file for changes and trigger a callback when it changes.
|
|
23
|
-
*
|
|
24
|
-
* @param projectPath - The absolute path to the project root directory (not the issues.jsonl file).
|
|
25
|
-
* The backend API will append .beads/issues.jsonl to this path.
|
|
26
|
-
* @param onFileChange - Callback function to run when the file changes.
|
|
27
|
-
* @param debounceMs - Debounce interval in milliseconds (default: 100).
|
|
28
|
-
* @returns Object containing isWatching status and any error.
|
|
29
|
-
*
|
|
30
|
-
* @example
|
|
31
|
-
* ```tsx
|
|
32
|
-
* const { isWatching, error } = useFileWatcher(
|
|
33
|
-
* "/path/to/project",
|
|
34
|
-
* () => {
|
|
35
|
-
* refetchIssues();
|
|
36
|
-
* },
|
|
37
|
-
* 100
|
|
38
|
-
* );
|
|
39
|
-
* ```
|
|
40
|
-
*/
|
|
41
|
-
export function useFileWatcher(
|
|
42
|
-
projectPath: string,
|
|
43
|
-
onFileChange: () => void,
|
|
44
|
-
debounceMs: number = 100
|
|
45
|
-
): UseFileWatcherResult {
|
|
46
|
-
const [isWatching, setIsWatching] = useState(false);
|
|
47
|
-
const [error, setError] = useState<Error | null>(null);
|
|
48
|
-
|
|
49
|
-
// Use refs to store the callback and debounce timer to avoid effect re-runs
|
|
50
|
-
const callbackRef = useRef(onFileChange);
|
|
51
|
-
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
52
|
-
|
|
53
|
-
// Keep callback ref up to date
|
|
54
|
-
useEffect(() => {
|
|
55
|
-
callbackRef.current = onFileChange;
|
|
56
|
-
}, [onFileChange]);
|
|
57
|
-
|
|
58
|
-
// Debounced callback handler
|
|
59
|
-
const handleFileChange = useCallback(() => {
|
|
60
|
-
// Clear any existing debounce timer
|
|
61
|
-
if (debounceTimerRef.current) {
|
|
62
|
-
clearTimeout(debounceTimerRef.current);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Set new debounce timer
|
|
66
|
-
debounceTimerRef.current = setTimeout(() => {
|
|
67
|
-
callbackRef.current();
|
|
68
|
-
debounceTimerRef.current = null;
|
|
69
|
-
}, debounceMs);
|
|
70
|
-
}, [debounceMs]);
|
|
71
|
-
|
|
72
|
-
useEffect(() => {
|
|
73
|
-
// Don't set up watcher if no project path provided
|
|
74
|
-
if (!projectPath) {
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
let cleanup: (() => void) | null = null;
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
// Set up the SSE watcher via API
|
|
82
|
-
cleanup = api.watch.beads(projectPath, () => {
|
|
83
|
-
handleFileChange();
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
setIsWatching(true);
|
|
87
|
-
setError(null);
|
|
88
|
-
} catch (err) {
|
|
89
|
-
setError(err instanceof Error ? err : new Error(String(err)));
|
|
90
|
-
setIsWatching(false);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Cleanup function
|
|
94
|
-
return () => {
|
|
95
|
-
// Clear any pending debounce timer
|
|
96
|
-
if (debounceTimerRef.current) {
|
|
97
|
-
clearTimeout(debounceTimerRef.current);
|
|
98
|
-
debounceTimerRef.current = null;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Close SSE connection
|
|
102
|
-
if (cleanup) {
|
|
103
|
-
cleanup();
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
setIsWatching(false);
|
|
107
|
-
};
|
|
108
|
-
}, [projectPath, handleFileChange]);
|
|
109
|
-
|
|
110
|
-
return { isWatching, error };
|
|
111
|
-
}
|
|
@@ -1,282 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useCallback, useEffect, useState, RefObject } from "react";
|
|
4
|
-
import type { Bead, BeadStatus } from "@/types";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Column order for navigation
|
|
8
|
-
*/
|
|
9
|
-
const COLUMN_ORDER: BeadStatus[] = ["open", "in_progress", "inreview", "closed"];
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Column shortcuts for 'g' prefix navigation
|
|
13
|
-
*/
|
|
14
|
-
const COLUMN_SHORTCUTS: Record<string, BeadStatus> = {
|
|
15
|
-
o: "open",
|
|
16
|
-
p: "in_progress",
|
|
17
|
-
r: "inreview",
|
|
18
|
-
c: "closed",
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export interface KeyboardNavigationOptions {
|
|
22
|
-
beads: Bead[];
|
|
23
|
-
beadsByStatus: Record<BeadStatus, Bead[]>;
|
|
24
|
-
selectedId: string | null;
|
|
25
|
-
onSelect: (bead: Bead) => void;
|
|
26
|
-
onOpen: (bead: Bead) => void;
|
|
27
|
-
onClose: () => void;
|
|
28
|
-
searchInputRef: RefObject<HTMLInputElement | null>;
|
|
29
|
-
isDetailOpen: boolean;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface KeyboardNavigationResult {
|
|
33
|
-
selectedId: string | null;
|
|
34
|
-
selectedColumnStatus: BeadStatus | null;
|
|
35
|
-
setSelectedId: (id: string | null) => void;
|
|
36
|
-
setSelectedColumnStatus: (status: BeadStatus | null) => void;
|
|
37
|
-
scrollToSelected: () => void;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Hook for keyboard navigation in the Kanban board
|
|
42
|
-
*
|
|
43
|
-
* Shortcuts:
|
|
44
|
-
* - j or ArrowDown: Move selection down
|
|
45
|
-
* - k or ArrowUp: Move selection up
|
|
46
|
-
* - Enter: Open selected bead detail
|
|
47
|
-
* - Escape: Close detail sheet / clear selection
|
|
48
|
-
* - /: Focus search input
|
|
49
|
-
* - g then o: Go to Open column
|
|
50
|
-
* - g then p: Go to In Progress column
|
|
51
|
-
* - g then r: Go to In Review column
|
|
52
|
-
* - g then d: Go to Done column
|
|
53
|
-
*/
|
|
54
|
-
export function useKeyboardNavigation({
|
|
55
|
-
beads,
|
|
56
|
-
beadsByStatus,
|
|
57
|
-
selectedId,
|
|
58
|
-
onSelect,
|
|
59
|
-
onOpen,
|
|
60
|
-
onClose,
|
|
61
|
-
searchInputRef,
|
|
62
|
-
isDetailOpen,
|
|
63
|
-
}: KeyboardNavigationOptions): KeyboardNavigationResult {
|
|
64
|
-
const [internalSelectedId, setInternalSelectedId] = useState<string | null>(selectedId);
|
|
65
|
-
const [selectedColumnStatus, setSelectedColumnStatus] = useState<BeadStatus | null>(null);
|
|
66
|
-
const [awaitingColumnKey, setAwaitingColumnKey] = useState(false);
|
|
67
|
-
|
|
68
|
-
// Sync internal state with external selectedId
|
|
69
|
-
useEffect(() => {
|
|
70
|
-
setInternalSelectedId(selectedId);
|
|
71
|
-
}, [selectedId]);
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Get the current column's beads for the selected bead
|
|
75
|
-
*/
|
|
76
|
-
const getCurrentColumnBeads = useCallback((): Bead[] => {
|
|
77
|
-
if (selectedColumnStatus) {
|
|
78
|
-
return beadsByStatus[selectedColumnStatus] || [];
|
|
79
|
-
}
|
|
80
|
-
if (!internalSelectedId) {
|
|
81
|
-
// Default to first non-empty column
|
|
82
|
-
for (const status of COLUMN_ORDER) {
|
|
83
|
-
if (beadsByStatus[status]?.length > 0) {
|
|
84
|
-
return beadsByStatus[status];
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
return [];
|
|
88
|
-
}
|
|
89
|
-
// Find which column contains the selected bead
|
|
90
|
-
for (const status of COLUMN_ORDER) {
|
|
91
|
-
const columnBeads = beadsByStatus[status] || [];
|
|
92
|
-
if (columnBeads.some((b) => b.id === internalSelectedId)) {
|
|
93
|
-
return columnBeads;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return beads;
|
|
97
|
-
}, [internalSelectedId, selectedColumnStatus, beadsByStatus, beads]);
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Get current index of selected bead in its column
|
|
101
|
-
*/
|
|
102
|
-
const getCurrentIndex = useCallback((): number => {
|
|
103
|
-
const columnBeads = getCurrentColumnBeads();
|
|
104
|
-
if (!internalSelectedId) return -1;
|
|
105
|
-
return columnBeads.findIndex((b) => b.id === internalSelectedId);
|
|
106
|
-
}, [internalSelectedId, getCurrentColumnBeads]);
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Move selection in a direction (up or down)
|
|
110
|
-
*/
|
|
111
|
-
const moveSelection = useCallback(
|
|
112
|
-
(direction: "up" | "down") => {
|
|
113
|
-
const columnBeads = getCurrentColumnBeads();
|
|
114
|
-
if (columnBeads.length === 0) return;
|
|
115
|
-
|
|
116
|
-
const currentIndex = getCurrentIndex();
|
|
117
|
-
let newIndex: number;
|
|
118
|
-
|
|
119
|
-
if (currentIndex === -1) {
|
|
120
|
-
// No selection, select first or last based on direction
|
|
121
|
-
newIndex = direction === "down" ? 0 : columnBeads.length - 1;
|
|
122
|
-
} else {
|
|
123
|
-
newIndex =
|
|
124
|
-
direction === "down"
|
|
125
|
-
? Math.min(currentIndex + 1, columnBeads.length - 1)
|
|
126
|
-
: Math.max(currentIndex - 1, 0);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const newBead = columnBeads[newIndex];
|
|
130
|
-
if (newBead) {
|
|
131
|
-
setInternalSelectedId(newBead.id);
|
|
132
|
-
onSelect(newBead);
|
|
133
|
-
// Update column status based on selected bead
|
|
134
|
-
for (const status of COLUMN_ORDER) {
|
|
135
|
-
if (beadsByStatus[status]?.some((b) => b.id === newBead.id)) {
|
|
136
|
-
setSelectedColumnStatus(status);
|
|
137
|
-
break;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
},
|
|
142
|
-
[getCurrentColumnBeads, getCurrentIndex, onSelect, beadsByStatus]
|
|
143
|
-
);
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Jump to a specific column
|
|
147
|
-
*/
|
|
148
|
-
const jumpToColumn = useCallback(
|
|
149
|
-
(status: BeadStatus) => {
|
|
150
|
-
const columnBeads = beadsByStatus[status] || [];
|
|
151
|
-
setSelectedColumnStatus(status);
|
|
152
|
-
if (columnBeads.length > 0) {
|
|
153
|
-
const firstBead = columnBeads[0];
|
|
154
|
-
setInternalSelectedId(firstBead.id);
|
|
155
|
-
onSelect(firstBead);
|
|
156
|
-
} else {
|
|
157
|
-
setInternalSelectedId(null);
|
|
158
|
-
}
|
|
159
|
-
},
|
|
160
|
-
[beadsByStatus, onSelect]
|
|
161
|
-
);
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Scroll the selected bead into view
|
|
165
|
-
*/
|
|
166
|
-
const scrollToSelected = useCallback(() => {
|
|
167
|
-
if (!internalSelectedId) return;
|
|
168
|
-
const element = document.querySelector(`[data-bead-id="${internalSelectedId}"]`);
|
|
169
|
-
if (element) {
|
|
170
|
-
element.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
171
|
-
}
|
|
172
|
-
}, [internalSelectedId]);
|
|
173
|
-
|
|
174
|
-
// Scroll into view when selection changes
|
|
175
|
-
useEffect(() => {
|
|
176
|
-
scrollToSelected();
|
|
177
|
-
}, [internalSelectedId, scrollToSelected]);
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Handle keyboard events
|
|
181
|
-
*/
|
|
182
|
-
useEffect(() => {
|
|
183
|
-
const handleKeyDown = (event: KeyboardEvent) => {
|
|
184
|
-
// Don't handle if focused on an input (except for Escape)
|
|
185
|
-
const target = event.target as HTMLElement;
|
|
186
|
-
const isInputFocused =
|
|
187
|
-
target.tagName === "INPUT" ||
|
|
188
|
-
target.tagName === "TEXTAREA" ||
|
|
189
|
-
target.isContentEditable;
|
|
190
|
-
|
|
191
|
-
// Always handle Escape
|
|
192
|
-
if (event.key === "Escape") {
|
|
193
|
-
event.preventDefault();
|
|
194
|
-
if (isDetailOpen) {
|
|
195
|
-
onClose();
|
|
196
|
-
} else if (isInputFocused) {
|
|
197
|
-
target.blur();
|
|
198
|
-
} else if (internalSelectedId) {
|
|
199
|
-
setInternalSelectedId(null);
|
|
200
|
-
setSelectedColumnStatus(null);
|
|
201
|
-
}
|
|
202
|
-
setAwaitingColumnKey(false);
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Skip other shortcuts if in input
|
|
207
|
-
if (isInputFocused) return;
|
|
208
|
-
|
|
209
|
-
// Handle 'g' prefix for column navigation
|
|
210
|
-
if (awaitingColumnKey) {
|
|
211
|
-
setAwaitingColumnKey(false);
|
|
212
|
-
const targetStatus = COLUMN_SHORTCUTS[event.key.toLowerCase()];
|
|
213
|
-
if (targetStatus) {
|
|
214
|
-
event.preventDefault();
|
|
215
|
-
jumpToColumn(targetStatus);
|
|
216
|
-
}
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
switch (event.key) {
|
|
221
|
-
case "j":
|
|
222
|
-
case "ArrowDown":
|
|
223
|
-
event.preventDefault();
|
|
224
|
-
if (!isDetailOpen) {
|
|
225
|
-
moveSelection("down");
|
|
226
|
-
}
|
|
227
|
-
break;
|
|
228
|
-
|
|
229
|
-
case "k":
|
|
230
|
-
case "ArrowUp":
|
|
231
|
-
event.preventDefault();
|
|
232
|
-
if (!isDetailOpen) {
|
|
233
|
-
moveSelection("up");
|
|
234
|
-
}
|
|
235
|
-
break;
|
|
236
|
-
|
|
237
|
-
case "Enter":
|
|
238
|
-
event.preventDefault();
|
|
239
|
-
if (internalSelectedId && !isDetailOpen) {
|
|
240
|
-
const selectedBead = beads.find((b) => b.id === internalSelectedId);
|
|
241
|
-
if (selectedBead) {
|
|
242
|
-
onOpen(selectedBead);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
break;
|
|
246
|
-
|
|
247
|
-
case "/":
|
|
248
|
-
event.preventDefault();
|
|
249
|
-
searchInputRef.current?.focus();
|
|
250
|
-
break;
|
|
251
|
-
|
|
252
|
-
case "g":
|
|
253
|
-
event.preventDefault();
|
|
254
|
-
setAwaitingColumnKey(true);
|
|
255
|
-
// Reset after timeout if no follow-up key
|
|
256
|
-
setTimeout(() => setAwaitingColumnKey(false), 1000);
|
|
257
|
-
break;
|
|
258
|
-
}
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
window.addEventListener("keydown", handleKeyDown);
|
|
262
|
-
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
263
|
-
}, [
|
|
264
|
-
beads,
|
|
265
|
-
internalSelectedId,
|
|
266
|
-
isDetailOpen,
|
|
267
|
-
awaitingColumnKey,
|
|
268
|
-
moveSelection,
|
|
269
|
-
jumpToColumn,
|
|
270
|
-
onOpen,
|
|
271
|
-
onClose,
|
|
272
|
-
searchInputRef,
|
|
273
|
-
]);
|
|
274
|
-
|
|
275
|
-
return {
|
|
276
|
-
selectedId: internalSelectedId,
|
|
277
|
-
selectedColumnStatus,
|
|
278
|
-
setSelectedId: setInternalSelectedId,
|
|
279
|
-
setSelectedColumnStatus,
|
|
280
|
-
scrollToSelected,
|
|
281
|
-
};
|
|
282
|
-
}
|
package/src/hooks/use-project.ts
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState, useEffect, useCallback } from "react";
|
|
4
|
-
import { getProjectWithTags, type Project, type Tag } from "@/lib/db";
|
|
5
|
-
|
|
6
|
-
export interface ProjectWithTags extends Project {
|
|
7
|
-
tags: Tag[];
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
interface UseProjectResult {
|
|
11
|
-
project: ProjectWithTags | null;
|
|
12
|
-
isLoading: boolean;
|
|
13
|
-
error: Error | null;
|
|
14
|
-
refetch: () => Promise<void>;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Hook to fetch a single project by ID from SQLite
|
|
19
|
-
*
|
|
20
|
-
* @param projectId - The ID of the project to fetch
|
|
21
|
-
* @returns Object containing project data, loading state, error, and refetch function
|
|
22
|
-
*/
|
|
23
|
-
export function useProject(projectId: string | null): UseProjectResult {
|
|
24
|
-
const [project, setProject] = useState<ProjectWithTags | null>(null);
|
|
25
|
-
const [isLoading, setIsLoading] = useState(true);
|
|
26
|
-
const [error, setError] = useState<Error | null>(null);
|
|
27
|
-
|
|
28
|
-
const fetchProject = useCallback(async () => {
|
|
29
|
-
if (!projectId) {
|
|
30
|
-
setProject(null);
|
|
31
|
-
setIsLoading(false);
|
|
32
|
-
setError(null);
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
try {
|
|
37
|
-
setIsLoading(true);
|
|
38
|
-
setError(null);
|
|
39
|
-
const data = await getProjectWithTags(projectId);
|
|
40
|
-
setProject(data);
|
|
41
|
-
} catch (err) {
|
|
42
|
-
setError(
|
|
43
|
-
err instanceof Error ? err : new Error("Failed to fetch project")
|
|
44
|
-
);
|
|
45
|
-
setProject(null);
|
|
46
|
-
} finally {
|
|
47
|
-
setIsLoading(false);
|
|
48
|
-
}
|
|
49
|
-
}, [projectId]);
|
|
50
|
-
|
|
51
|
-
useEffect(() => {
|
|
52
|
-
fetchProject();
|
|
53
|
-
}, [fetchProject]);
|
|
54
|
-
|
|
55
|
-
return {
|
|
56
|
-
project,
|
|
57
|
-
isLoading,
|
|
58
|
-
error,
|
|
59
|
-
refetch: fetchProject,
|
|
60
|
-
};
|
|
61
|
-
}
|