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,356 @@
1
+ "use client";
2
+
3
+ import { useMemo, useRef, useState, useCallback } from "react";
4
+ import Link from "next/link";
5
+ import { useSearchParams, useRouter } from "next/navigation";
6
+ import { useEffect } from "react";
7
+ import { ArrowLeft } from "lucide-react";
8
+ import { QuickFilterBar } from "@/components/quick-filter-bar";
9
+ import { Button } from "@/components/ui/button";
10
+ import { KanbanColumn } from "@/components/kanban-column";
11
+ import { BeadDetail } from "@/components/bead-detail";
12
+ import { CommentList } from "@/components/comment-list";
13
+ import { ActivityTimeline } from "@/components/activity-timeline";
14
+ import { EditableProjectName } from "@/components/editable-project-name";
15
+ import { useBeads } from "@/hooks/use-beads";
16
+ import { useProject } from "@/hooks/use-project";
17
+ import { useBeadFilters } from "@/hooks/use-bead-filters";
18
+ import { useBranchStatuses } from "@/hooks/use-branch-statuses";
19
+ import { useKeyboardNavigation } from "@/hooks/use-keyboard-navigation";
20
+ import type { Bead, BeadStatus } from "@/types";
21
+
22
+ /**
23
+ * Column configuration for the Kanban board
24
+ * Note: Cancelled status is hidden per requirements
25
+ */
26
+ const COLUMNS: { status: BeadStatus; title: string }[] = [
27
+ { status: "open", title: "Open" },
28
+ { status: "in_progress", title: "In Progress" },
29
+ { status: "inreview", title: "In Review" },
30
+ { status: "closed", title: "Closed" },
31
+ ];
32
+
33
+ /**
34
+ * Issue type filter options
35
+ */
36
+ type IssueTypeFilter = "all" | "epics" | "tasks";
37
+
38
+ /**
39
+ * Main Kanban board component with 4 columns, search, filter, and keyboard navigation
40
+ */
41
+ export default function KanbanBoard() {
42
+ const searchParams = useSearchParams();
43
+ const router = useRouter();
44
+ const projectId = searchParams.get('id');
45
+
46
+ // Fetch project data from SQLite
47
+ const {
48
+ project,
49
+ isLoading: projectLoading,
50
+ error: projectError,
51
+ refetch: refetchProject,
52
+ } = useProject(projectId);
53
+
54
+ // Fetch beads from project path
55
+ const {
56
+ beads,
57
+ ticketNumbers,
58
+ isLoading: beadsLoading,
59
+ error: beadsError,
60
+ refresh: refreshBeads,
61
+ } = useBeads(project?.path ?? "");
62
+
63
+ // Use the bead filters hook with 300ms debounce
64
+ const {
65
+ filters,
66
+ setFilters,
67
+ filteredBeads,
68
+ clearFilters,
69
+ hasActiveFilters,
70
+ availableOwners,
71
+ } = useBeadFilters(beads, ticketNumbers, 300);
72
+
73
+ // Issue type filter state (epics vs tasks)
74
+ const [typeFilter, setTypeFilter] = useState<IssueTypeFilter>("all");
75
+
76
+ /**
77
+ * Toggle a status in the filter
78
+ */
79
+ const toggleStatus = useCallback((status: BeadStatus) => {
80
+ const newStatuses = filters.statuses.includes(status)
81
+ ? filters.statuses.filter(s => s !== status)
82
+ : [...filters.statuses, status];
83
+ setFilters({ statuses: newStatuses });
84
+ }, [filters.statuses, setFilters]);
85
+
86
+ /**
87
+ * Toggle an owner in the filter
88
+ */
89
+ const toggleOwner = useCallback((owner: string) => {
90
+ const newOwners = filters.owners.includes(owner)
91
+ ? filters.owners.filter(o => o !== owner)
92
+ : [...filters.owners, owner];
93
+ setFilters({ owners: newOwners });
94
+ }, [filters.owners, setFilters]);
95
+
96
+ // Fetch branch statuses for all beads
97
+ const beadIds = useMemo(() => beads.map((b) => b.id), [beads]);
98
+ const { statuses: branchStatuses } = useBranchStatuses(
99
+ project?.path ?? "",
100
+ beadIds
101
+ );
102
+
103
+ /**
104
+ * Filter to only top-level beads (no parent_id)
105
+ * Then apply issue type filter (epics vs tasks)
106
+ * Child tasks should not appear in columns - they appear inside epic cards
107
+ */
108
+ const topLevelBeads = useMemo(() => {
109
+ const topLevel = filteredBeads.filter(b => !b.parent_id);
110
+
111
+ // Apply issue type filter
112
+ if (typeFilter === "all") return topLevel;
113
+ if (typeFilter === "epics") return topLevel.filter(b => b.issue_type === "epic");
114
+ if (typeFilter === "tasks") return topLevel.filter(b => b.issue_type !== "epic");
115
+
116
+ return topLevel;
117
+ }, [filteredBeads, typeFilter]);
118
+
119
+ /**
120
+ * Group top-level beads by status for columns
121
+ */
122
+ const filteredBeadsByStatus = useMemo(() => {
123
+ const grouped: Record<BeadStatus, Bead[]> = {
124
+ open: [],
125
+ in_progress: [],
126
+ inreview: [],
127
+ closed: [],
128
+ };
129
+ topLevelBeads.forEach((bead) => {
130
+ grouped[bead.status].push(bead);
131
+ });
132
+ return grouped;
133
+ }, [topLevelBeads]);
134
+
135
+ // Detail sheet state
136
+ const [detailBeadId, setDetailBeadId] = useState<string | null>(null);
137
+ const [isDetailOpen, setIsDetailOpen] = useState(false);
138
+
139
+ // Get the actual bead from the current beads array to ensure fresh data
140
+ const detailBead = useMemo(() => {
141
+ if (!detailBeadId) return null;
142
+ return beads.find((b) => b.id === detailBeadId) || null;
143
+ }, [detailBeadId, beads]);
144
+
145
+ // Ref for search input (keyboard navigation)
146
+ const searchInputRef = useRef<HTMLInputElement>(null);
147
+
148
+ // Keyboard navigation (use top-level beads for navigation)
149
+ const { selectedId } = useKeyboardNavigation({
150
+ beads: topLevelBeads,
151
+ beadsByStatus: filteredBeadsByStatus,
152
+ selectedId: null,
153
+ onSelect: () => {
154
+ // Just highlight, don't open detail
155
+ },
156
+ onOpen: (bead) => {
157
+ setDetailBeadId(bead.id);
158
+ setIsDetailOpen(true);
159
+ },
160
+ onClose: () => {
161
+ setIsDetailOpen(false);
162
+ },
163
+ searchInputRef,
164
+ isDetailOpen,
165
+ });
166
+
167
+ // Redirect if no project ID
168
+ useEffect(() => {
169
+ if (!projectId) {
170
+ router.replace("/");
171
+ }
172
+ }, [projectId, router]);
173
+
174
+ /**
175
+ * Handle bead selection - opens detail panel
176
+ * Works for both epics and standalone tasks
177
+ */
178
+ const handleSelectBead = (bead: Bead) => {
179
+ setDetailBeadId(bead.id);
180
+ setIsDetailOpen(true);
181
+ };
182
+
183
+ /**
184
+ * Handle child task click from within an epic
185
+ */
186
+ const handleChildClick = (child: Bead) => {
187
+ setDetailBeadId(child.id);
188
+ setIsDetailOpen(true);
189
+ };
190
+
191
+ /**
192
+ * Handle navigation to a dependency from DependencyBadge
193
+ */
194
+ const handleNavigateToDependency = (beadId: string) => {
195
+ setDetailBeadId(beadId);
196
+ setIsDetailOpen(true);
197
+ };
198
+
199
+ // Redirect state while no project ID
200
+ if (!projectId) {
201
+ return (
202
+ <div className="dark flex min-h-dvh items-center justify-center bg-[#0a0a0a]">
203
+ <p className="text-zinc-500">Redirecting…</p>
204
+ </div>
205
+ );
206
+ }
207
+
208
+ // Show loading state
209
+ if (projectLoading) {
210
+ return (
211
+ <div className="dark flex items-center justify-center min-h-dvh bg-[#0a0a0a]">
212
+ <div role="status" className="text-zinc-500">Loading project…</div>
213
+ </div>
214
+ );
215
+ }
216
+
217
+ // Show project error state
218
+ if (projectError) {
219
+ return (
220
+ <div className="dark flex flex-col items-center justify-center min-h-dvh bg-[#0a0a0a] gap-4">
221
+ <div role="alert" className="text-red-400">Error: {projectError.message}</div>
222
+ <Button variant="outline" asChild>
223
+ <Link href="/">Back to projects</Link>
224
+ </Button>
225
+ </div>
226
+ );
227
+ }
228
+
229
+ // Project not found
230
+ if (!project) {
231
+ return (
232
+ <div className="dark flex flex-col items-center justify-center min-h-dvh bg-[#0a0a0a] gap-4">
233
+ <div className="text-zinc-500">Project not found</div>
234
+ <Button variant="outline" asChild>
235
+ <Link href="/">Back to projects</Link>
236
+ </Button>
237
+ </div>
238
+ );
239
+ }
240
+
241
+ return (
242
+ <div className="dark min-h-dvh bg-[#0a0a0a] flex flex-col">
243
+ {/* Header */}
244
+ <header className="sticky top-0 z-30 flex items-center justify-center border-b border-zinc-800 bg-[#0a0a0a]/80 backdrop-blur-sm px-4 py-3">
245
+ {/* Left: Back button - absolute positioned */}
246
+ <div className="absolute left-4">
247
+ <Button variant="ghost" size="icon" asChild>
248
+ <Link href="/">
249
+ <ArrowLeft className="h-4 w-4" />
250
+ <span className="sr-only">Back to projects</span>
251
+ </Link>
252
+ </Button>
253
+ </div>
254
+
255
+ {/* Center: Project name */}
256
+ <EditableProjectName
257
+ projectId={project.id}
258
+ initialName={project.name}
259
+ onNameUpdated={refetchProject}
260
+ />
261
+ </header>
262
+
263
+ {/* Quick Filter Bar */}
264
+ <div className="px-4 py-2 border-b border-zinc-800">
265
+ <QuickFilterBar
266
+ // Search
267
+ search={filters.search}
268
+ onSearchChange={(value) => setFilters({ search: value })}
269
+ searchInputRef={searchInputRef}
270
+ // Type filter
271
+ typeFilter={typeFilter}
272
+ onTypeFilterChange={setTypeFilter}
273
+ // Today
274
+ todayOnly={filters.todayOnly}
275
+ onTodayOnlyChange={(value) => setFilters({ todayOnly: value })}
276
+ // Sort
277
+ sortField={filters.sortField}
278
+ sortDirection={filters.sortDirection}
279
+ onSortChange={(field, direction) => setFilters({ sortField: field, sortDirection: direction })}
280
+ // Status/Owner filters
281
+ statuses={filters.statuses}
282
+ onStatusToggle={toggleStatus}
283
+ owners={filters.owners}
284
+ onOwnerToggle={toggleOwner}
285
+ availableOwners={availableOwners}
286
+ onClearFilters={clearFilters}
287
+ hasActiveFilters={hasActiveFilters}
288
+ />
289
+ </div>
290
+
291
+ {/* Kanban Columns */}
292
+ <main className="flex-1 overflow-hidden p-4">
293
+ {beadsLoading ? (
294
+ <div className="flex items-center justify-center h-full">
295
+ <div role="status" className="text-zinc-500">Loading beads…</div>
296
+ </div>
297
+ ) : beadsError ? (
298
+ <div className="flex items-center justify-center h-full">
299
+ <div role="alert" className="text-red-400">Error loading beads: {beadsError.message}</div>
300
+ </div>
301
+ ) : (
302
+ <div className="grid grid-cols-4 gap-4 h-full">
303
+ {COLUMNS.map(({ status, title }) => (
304
+ <KanbanColumn
305
+ key={status}
306
+ status={status}
307
+ title={title}
308
+ beads={filteredBeadsByStatus[status] || []}
309
+ allBeads={beads}
310
+ selectedBeadId={selectedId}
311
+ ticketNumbers={ticketNumbers}
312
+ branchStatuses={branchStatuses}
313
+ onSelectBead={handleSelectBead}
314
+ onChildClick={handleChildClick}
315
+ onNavigateToDependency={handleNavigateToDependency}
316
+ projectPath={project?.path}
317
+ />
318
+ ))}
319
+ </div>
320
+ )}
321
+ </main>
322
+
323
+ {/* Bead Detail Sheet */}
324
+ {detailBead && (
325
+ <BeadDetail
326
+ bead={detailBead}
327
+ ticketNumber={ticketNumbers.get(detailBead.id)}
328
+ open={isDetailOpen}
329
+ onOpenChange={(open) => {
330
+ setIsDetailOpen(open);
331
+ if (!open) {
332
+ setDetailBeadId(null);
333
+ }
334
+ }}
335
+ projectPath={project?.path ?? ""}
336
+ allBeads={beads}
337
+ onChildClick={handleChildClick}
338
+ >
339
+ <CommentList
340
+ comments={detailBead.comments}
341
+ beadId={detailBead.id}
342
+ projectPath={project?.path ?? ""}
343
+ onCommentAdded={refreshBeads}
344
+ />
345
+ <ActivityTimeline
346
+ bead={detailBead}
347
+ comments={detailBead.comments}
348
+ childBeads={(detailBead.children || [])
349
+ .map(id => beads.find(b => b.id === id))
350
+ .filter((b): b is Bead => !!b)}
351
+ />
352
+ </BeadDetail>
353
+ )}
354
+ </div>
355
+ );
356
+ }
@@ -0,0 +1,18 @@
1
+ import { Suspense } from 'react';
2
+ import KanbanBoard from './kanban-board';
3
+
4
+ function LoadingFallback() {
5
+ return (
6
+ <div className="flex items-center justify-center h-screen">
7
+ <div className="text-muted-foreground">Loading...</div>
8
+ </div>
9
+ );
10
+ }
11
+
12
+ export default function ProjectPage() {
13
+ return (
14
+ <Suspense fallback={<LoadingFallback />}>
15
+ <KanbanBoard />
16
+ </Suspense>
17
+ );
18
+ }
@@ -0,0 +1,224 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import Link from "next/link";
5
+ import { getTags, createTag, deleteTag, type Tag } from "@/lib/db";
6
+ import { ColorPicker } from "@/components/color-picker";
7
+ import { Input } from "@/components/ui/input";
8
+ import { Button } from "@/components/ui/button";
9
+
10
+ export default function SettingsPage() {
11
+ const [tags, setTags] = useState<Tag[]>([]);
12
+ const [isAddingTag, setIsAddingTag] = useState(false);
13
+ const [newTagName, setNewTagName] = useState("");
14
+ const [newTagColor, setNewTagColor] = useState("#3b82f6");
15
+ const [isLoading, setIsLoading] = useState(true);
16
+
17
+ useEffect(() => {
18
+ async function loadTags() {
19
+ try {
20
+ const loadedTags = await getTags();
21
+ setTags(loadedTags);
22
+ } catch (error) {
23
+ console.error("Failed to load tags:", error);
24
+ }
25
+ setIsLoading(false);
26
+ }
27
+ loadTags();
28
+ }, []);
29
+
30
+ const handleCreateTag = async () => {
31
+ if (!newTagName.trim()) return;
32
+
33
+ try {
34
+ const tag = await createTag({ name: newTagName.trim(), color: newTagColor });
35
+ setTags((prev) => [...prev, tag]);
36
+ setNewTagName("");
37
+ setNewTagColor("#3b82f6");
38
+ setIsAddingTag(false);
39
+ } catch (error) {
40
+ console.error("Failed to create tag:", error);
41
+ }
42
+ };
43
+
44
+ const handleDeleteTag = async (tagId: string) => {
45
+ try {
46
+ await deleteTag(tagId);
47
+ setTags((prev) => prev.filter((t) => t.id !== tagId));
48
+ } catch (error) {
49
+ console.error("Failed to delete tag:", error);
50
+ }
51
+ };
52
+
53
+ return (
54
+ <div className="dark min-h-dvh bg-[#0a0a0a]">
55
+ {/* Header */}
56
+ <header className="sticky top-0 z-30 border-b border-zinc-800 bg-[#0a0a0a]/80 backdrop-blur-sm px-6 py-4">
57
+ <div className="flex items-center gap-4">
58
+ <Link
59
+ href="/"
60
+ aria-label="Go back to home"
61
+ className="rounded-md p-2 text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-100"
62
+ >
63
+ <svg
64
+ xmlns="http://www.w3.org/2000/svg"
65
+ width="20"
66
+ height="20"
67
+ viewBox="0 0 24 24"
68
+ fill="none"
69
+ stroke="currentColor"
70
+ strokeWidth="2"
71
+ strokeLinecap="round"
72
+ strokeLinejoin="round"
73
+ aria-hidden="true"
74
+ >
75
+ <path d="m12 19-7-7 7-7" />
76
+ <path d="M19 12H5" />
77
+ </svg>
78
+ </Link>
79
+ <h1 className="text-xl font-semibold text-white">Settings</h1>
80
+ </div>
81
+ </header>
82
+
83
+ {/* Settings Content */}
84
+ <main className="mx-auto max-w-2xl p-6">
85
+ {/* Tags Section */}
86
+ <section className="mb-8">
87
+ <h2 className="mb-4 text-lg font-medium text-zinc-100">Tags</h2>
88
+ <div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4">
89
+ <p className="text-sm text-zinc-400">
90
+ Manage your project tags here. Tags help organize and categorize your projects.
91
+ </p>
92
+
93
+ {/* Tags List */}
94
+ <div className="mt-4 space-y-2">
95
+ {isLoading ? (
96
+ <p className="text-sm text-zinc-400">Loading tags…</p>
97
+ ) : tags.length === 0 && !isAddingTag ? (
98
+ <p className="text-sm text-zinc-400">No tags yet. Create one to get started.</p>
99
+ ) : (
100
+ tags.map((tag) => (
101
+ <div
102
+ key={tag.id}
103
+ className="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-800/50 px-3 py-2"
104
+ >
105
+ <div className="flex items-center gap-2">
106
+ <span
107
+ className="size-4 rounded-full"
108
+ style={{ backgroundColor: tag.color }}
109
+ aria-hidden="true"
110
+ />
111
+ <span className="text-sm font-medium text-zinc-200">{tag.name}</span>
112
+ </div>
113
+ <button
114
+ onClick={() => handleDeleteTag(tag.id)}
115
+ className="rounded p-1 text-zinc-500 hover:bg-zinc-700 hover:text-zinc-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0a0a]"
116
+ title="Delete tag"
117
+ aria-label={`Delete tag ${tag.name}`}
118
+ >
119
+ <svg
120
+ xmlns="http://www.w3.org/2000/svg"
121
+ width="16"
122
+ height="16"
123
+ viewBox="0 0 24 24"
124
+ fill="none"
125
+ stroke="currentColor"
126
+ strokeWidth="2"
127
+ strokeLinecap="round"
128
+ strokeLinejoin="round"
129
+ >
130
+ <path d="M3 6h18" />
131
+ <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
132
+ <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
133
+ <line x1="10" x2="10" y1="11" y2="17" />
134
+ <line x1="14" x2="14" y1="11" y2="17" />
135
+ </svg>
136
+ </button>
137
+ </div>
138
+ ))
139
+ )}
140
+
141
+ {/* Add Tag Form */}
142
+ {isAddingTag && (
143
+ <div className="mt-3 space-y-3 rounded-md border border-zinc-800 bg-zinc-900/70 p-3">
144
+ <div className="flex items-center gap-2">
145
+ <ColorPicker value={newTagColor} onChange={setNewTagColor} />
146
+ <Input
147
+ value={newTagName}
148
+ onChange={(e) => setNewTagName(e.target.value)}
149
+ placeholder="Tag name…"
150
+ aria-label="Tag name"
151
+ className="flex-1 border-zinc-700 bg-zinc-800 text-zinc-100 placeholder:text-zinc-500"
152
+ autoFocus
153
+ onKeyDown={(e) => {
154
+ if (e.key === "Enter") {
155
+ handleCreateTag();
156
+ } else if (e.key === "Escape") {
157
+ setIsAddingTag(false);
158
+ setNewTagName("");
159
+ setNewTagColor("#3b82f6");
160
+ }
161
+ }}
162
+ />
163
+ </div>
164
+ <div className="flex justify-end gap-2">
165
+ <Button
166
+ variant="outline"
167
+ size="sm"
168
+ className="border-zinc-700 bg-transparent text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
169
+ onClick={() => {
170
+ setIsAddingTag(false);
171
+ setNewTagName("");
172
+ setNewTagColor("#3b82f6");
173
+ }}
174
+ >
175
+ Cancel
176
+ </Button>
177
+ <Button
178
+ size="sm"
179
+ className="bg-zinc-100 text-zinc-900 hover:bg-white"
180
+ onClick={handleCreateTag}
181
+ disabled={!newTagName.trim()}
182
+ >
183
+ Create Tag
184
+ </Button>
185
+ </div>
186
+ </div>
187
+ )}
188
+ </div>
189
+
190
+ {/* Add Tag Button */}
191
+ {!isAddingTag && (
192
+ <div className="mt-4">
193
+ <button
194
+ onClick={() => setIsAddingTag(true)}
195
+ className="rounded-md bg-zinc-100 px-3 py-1.5 text-sm text-zinc-900 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0a0a]"
196
+ >
197
+ Add Tag
198
+ </button>
199
+ </div>
200
+ )}
201
+ </div>
202
+ </section>
203
+
204
+ {/* Data Section */}
205
+ <section className="mb-8">
206
+ <h2 className="mb-4 text-lg font-medium text-zinc-100">Data</h2>
207
+ <div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4">
208
+ <div className="flex items-center justify-between">
209
+ <div>
210
+ <p className="font-medium text-red-400">Clear Local Database</p>
211
+ <p className="text-sm text-zinc-400">
212
+ Remove all projects and tags from local storage
213
+ </p>
214
+ </div>
215
+ <button className="rounded-md border border-red-800/50 bg-red-900/30 px-3 py-1.5 text-sm text-red-400 hover:bg-red-900/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0a0a]">
216
+ Clear Data
217
+ </button>
218
+ </div>
219
+ </div>
220
+ </section>
221
+ </main>
222
+ </div>
223
+ );
224
+ }
@@ -0,0 +1,5 @@
1
+ .beams-container {
2
+ position: relative;
3
+ width: 100%;
4
+ height: 100%;
5
+ }