beads-kanban-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.
Files changed (154) hide show
  1. package/.designs/beads-kanban-ui-bj0.md +73 -0
  2. package/.designs/beads-kanban-ui-qxq.md +144 -0
  3. package/.designs/epic-support.md +282 -0
  4. package/.env.local.example +2 -0
  5. package/.eslintrc.json +3 -0
  6. package/.gitattributes +3 -0
  7. package/.github/workflows/release.yml +123 -0
  8. package/.history/README_20260121193710.md +227 -0
  9. package/.history/README_20260121193918.md +227 -0
  10. package/.history/README_20260121193921.md +227 -0
  11. package/.history/README_20260121193933.md +227 -0
  12. package/.history/README_20260121193934.md +227 -0
  13. package/.history/README_20260121193944.md +227 -0
  14. package/.history/README_20260121193953.md +227 -0
  15. package/.history/src/app/page_20260121133429.tsx +134 -0
  16. package/.history/src/app/page_20260121133928.tsx +134 -0
  17. package/.history/src/app/page_20260121144850.tsx +138 -0
  18. package/.history/src/app/page_20260121144854.tsx +138 -0
  19. package/.history/src/app/page_20260121144858.tsx +138 -0
  20. package/.history/src/app/page_20260121144902.tsx +138 -0
  21. package/.history/src/app/page_20260121144906.tsx +138 -0
  22. package/.history/src/app/page_20260121144911.tsx +138 -0
  23. package/.history/src/app/page_20260121144928.tsx +138 -0
  24. package/.playwright-mcp/.playwright-mcp/morphing-dialog-wheel-scroll-fix.png +0 -0
  25. package/.playwright-mcp/beams-test.png +0 -0
  26. package/.playwright-mcp/card-verification.png +0 -0
  27. package/.playwright-mcp/design-doc-dialog-fix-verification.png +0 -0
  28. package/.playwright-mcp/dialog-width-test.png +0 -0
  29. package/.playwright-mcp/homepage.png +0 -0
  30. package/.playwright-mcp/morphing-dialog-expanded.png +0 -0
  31. package/.playwright-mcp/morphing-dialog-fixes-final.png +0 -0
  32. package/.playwright-mcp/morphing-dialog-open.png +0 -0
  33. package/.playwright-mcp/page-2026-01-21T14-08-31-529Z.png +0 -0
  34. package/.playwright-mcp/page-2026-01-21T14-09-23-431Z.png +0 -0
  35. package/.playwright-mcp/page-2026-01-21T14-10-28-773Z.png +0 -0
  36. package/.playwright-mcp/page-2026-01-21T14-10-47-432Z.png +0 -0
  37. package/.playwright-mcp/page-2026-01-21T14-11-12-350Z.png +0 -0
  38. package/.playwright-mcp/screenshot-after-click.png +0 -0
  39. package/.playwright-mcp/screenshot-after-dialog-click.png +0 -0
  40. package/.playwright-mcp/sheet-restored-after-dialog-close.png +0 -0
  41. package/.playwright-mcp/test-1-sheet-open-with-overlay.png +0 -0
  42. package/.playwright-mcp/test-2-morphing-dialog-with-overlay.png +0 -0
  43. package/.playwright-mcp/test-3-sheet-open-dark-overlay.png +0 -0
  44. package/.playwright-mcp/test-4-morphing-dialog-with-dark-overlay.png +0 -0
  45. package/.playwright-mcp/test-5-morphing-dialog-scrolled.png +0 -0
  46. package/.playwright-mcp/test-6-sheet-restored-after-dialog-close.png +0 -0
  47. package/.playwright-mcp/wheel-scroll-fixed.png +0 -0
  48. package/README.md +243 -0
  49. package/Screenshots/bead-detail.png +0 -0
  50. package/Screenshots/dashboard.png +0 -0
  51. package/Screenshots/kanban-board.png +0 -0
  52. package/components.json +27 -0
  53. package/logo/logo.svg +1 -0
  54. package/next.config.js +9 -0
  55. package/npm/README.md +37 -0
  56. package/npm/bin/cli.js +107 -0
  57. package/npm/package.json +20 -0
  58. package/npm/scripts/postinstall.js +132 -0
  59. package/package.json +62 -0
  60. package/postcss.config.js +6 -0
  61. package/public/logo.svg +1 -0
  62. package/restart.sh +5 -0
  63. package/server/Cargo.lock +1685 -0
  64. package/server/Cargo.toml +24 -0
  65. package/server/src/db.rs +570 -0
  66. package/server/src/main.rs +141 -0
  67. package/server/src/routes/beads.rs +413 -0
  68. package/server/src/routes/cli.rs +150 -0
  69. package/server/src/routes/fs.rs +360 -0
  70. package/server/src/routes/git.rs +169 -0
  71. package/server/src/routes/mod.rs +107 -0
  72. package/server/src/routes/projects.rs +177 -0
  73. package/server/src/routes/watch.rs +211 -0
  74. package/src/app/globals.css +101 -0
  75. package/src/app/layout.tsx +36 -0
  76. package/src/app/page.tsx +348 -0
  77. package/src/app/project/kanban-board.tsx +356 -0
  78. package/src/app/project/page.tsx +18 -0
  79. package/src/app/settings/page.tsx +224 -0
  80. package/src/components/Beams.css +5 -0
  81. package/src/components/Beams.jsx +307 -0
  82. package/src/components/Galaxy.css +5 -0
  83. package/src/components/Galaxy.jsx +333 -0
  84. package/src/components/activity-timeline.tsx +172 -0
  85. package/src/components/add-project-dialog.tsx +219 -0
  86. package/src/components/bead-card.tsx +196 -0
  87. package/src/components/bead-detail.tsx +306 -0
  88. package/src/components/color-picker.tsx +101 -0
  89. package/src/components/comment-input.tsx +155 -0
  90. package/src/components/comment-list.tsx +147 -0
  91. package/src/components/dependency-badge.tsx +106 -0
  92. package/src/components/design-doc-dialog.tsx +58 -0
  93. package/src/components/design-doc-preview.tsx +97 -0
  94. package/src/components/design-doc-viewer.tsx +199 -0
  95. package/src/components/editable-project-name.tsx +178 -0
  96. package/src/components/epic-card.tsx +263 -0
  97. package/src/components/folder-browser.tsx +273 -0
  98. package/src/components/footer.tsx +27 -0
  99. package/src/components/kanban/default.tsx +184 -0
  100. package/src/components/kanban-column.tsx +167 -0
  101. package/src/components/project-card.tsx +191 -0
  102. package/src/components/quick-filter-bar.tsx +279 -0
  103. package/src/components/scan-directory-dialog.tsx +368 -0
  104. package/src/components/status-donut.tsx +197 -0
  105. package/src/components/subtask-list.tsx +128 -0
  106. package/src/components/tag-picker.tsx +252 -0
  107. package/src/components/ui/.gitkeep +0 -0
  108. package/src/components/ui/alert-dialog.tsx +141 -0
  109. package/src/components/ui/avatar.tsx +67 -0
  110. package/src/components/ui/badge.tsx +230 -0
  111. package/src/components/ui/button.tsx +433 -0
  112. package/src/components/ui/card/index.tsx +24 -0
  113. package/src/components/ui/card/roiui-card.module.css +197 -0
  114. package/src/components/ui/card/roiui-card.tsx +154 -0
  115. package/src/components/ui/card/shadcn-card.tsx +76 -0
  116. package/src/components/ui/chart.tsx +369 -0
  117. package/src/components/ui/dialog.tsx +122 -0
  118. package/src/components/ui/dropdown-menu.tsx +201 -0
  119. package/src/components/ui/input.tsx +22 -0
  120. package/src/components/ui/kanban.tsx +522 -0
  121. package/src/components/ui/morphing-dialog.tsx +457 -0
  122. package/src/components/ui/popover.tsx +33 -0
  123. package/src/components/ui/progress.tsx +28 -0
  124. package/src/components/ui/scroll-area.tsx +48 -0
  125. package/src/components/ui/select.tsx +159 -0
  126. package/src/components/ui/separator.tsx +31 -0
  127. package/src/components/ui/sheet.tsx +142 -0
  128. package/src/components/ui/skeleton.tsx +15 -0
  129. package/src/components/ui/toast.tsx +129 -0
  130. package/src/components/ui/toaster.tsx +35 -0
  131. package/src/components/ui/tooltip.tsx +30 -0
  132. package/src/hooks/.gitkeep +0 -0
  133. package/src/hooks/use-bead-filters.ts +261 -0
  134. package/src/hooks/use-beads.ts +162 -0
  135. package/src/hooks/use-branch-statuses.ts +161 -0
  136. package/src/hooks/use-epics.ts +173 -0
  137. package/src/hooks/use-file-watcher.ts +111 -0
  138. package/src/hooks/use-keyboard-navigation.ts +282 -0
  139. package/src/hooks/use-project.ts +61 -0
  140. package/src/hooks/use-projects.ts +93 -0
  141. package/src/hooks/use-toast.ts +194 -0
  142. package/src/hooks/useClickOutside.tsx +26 -0
  143. package/src/lib/.gitkeep +0 -0
  144. package/src/lib/api.ts +186 -0
  145. package/src/lib/beads-parser.ts +252 -0
  146. package/src/lib/cli.ts +193 -0
  147. package/src/lib/db.ts +145 -0
  148. package/src/lib/design-doc.ts +74 -0
  149. package/src/lib/epic-parser.ts +242 -0
  150. package/src/lib/git.ts +102 -0
  151. package/src/lib/utils.ts +12 -0
  152. package/src/types/index.ts +107 -0
  153. package/tailwind.config.ts +85 -0
  154. package/tsconfig.json +26 -0
@@ -0,0 +1,173 @@
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
+ }
@@ -0,0 +1,111 @@
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
+ }
@@ -0,0 +1,282 @@
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
+ }
@@ -0,0 +1,61 @@
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
+ }