@locusai/web 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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/next.config.js +7 -0
  3. package/package.json +37 -0
  4. package/postcss.config.mjs +5 -0
  5. package/src/app/backlog/page.tsx +19 -0
  6. package/src/app/docs/page.tsx +7 -0
  7. package/src/app/globals.css +603 -0
  8. package/src/app/layout.tsx +43 -0
  9. package/src/app/page.tsx +16 -0
  10. package/src/app/providers.tsx +16 -0
  11. package/src/app/settings/page.tsx +194 -0
  12. package/src/components/BoardFilter.tsx +98 -0
  13. package/src/components/Header.tsx +21 -0
  14. package/src/components/PropertyItem.tsx +98 -0
  15. package/src/components/Sidebar.tsx +109 -0
  16. package/src/components/TaskCard.tsx +138 -0
  17. package/src/components/TaskCreateModal.tsx +243 -0
  18. package/src/components/TaskPanel.tsx +765 -0
  19. package/src/components/index.ts +7 -0
  20. package/src/components/ui/Badge.tsx +77 -0
  21. package/src/components/ui/Button.tsx +47 -0
  22. package/src/components/ui/Checkbox.tsx +52 -0
  23. package/src/components/ui/Dropdown.tsx +107 -0
  24. package/src/components/ui/Input.tsx +36 -0
  25. package/src/components/ui/Modal.tsx +79 -0
  26. package/src/components/ui/Textarea.tsx +21 -0
  27. package/src/components/ui/index.ts +7 -0
  28. package/src/hooks/useTasks.ts +119 -0
  29. package/src/lib/api-client.ts +24 -0
  30. package/src/lib/utils.ts +6 -0
  31. package/src/services/doc.service.ts +27 -0
  32. package/src/services/index.ts +3 -0
  33. package/src/services/sprint.service.ts +26 -0
  34. package/src/services/task.service.ts +75 -0
  35. package/src/views/Backlog.tsx +691 -0
  36. package/src/views/Board.tsx +306 -0
  37. package/src/views/Docs.tsx +625 -0
  38. package/tsconfig.json +21 -0
@@ -0,0 +1,625 @@
1
+ "use client";
2
+
3
+ import MDEditor from "@uiw/react-md-editor";
4
+ import {
5
+ BookOpen,
6
+ Code,
7
+ File,
8
+ FileCode,
9
+ FileText,
10
+ Filter,
11
+ Folder,
12
+ FolderOpen,
13
+ Lightbulb,
14
+ Palette,
15
+ Plus,
16
+ Save,
17
+ Search,
18
+ Users,
19
+ X,
20
+ } from "lucide-react";
21
+ import { useCallback, useEffect, useState } from "react";
22
+ import { Button, Input } from "@/components/ui";
23
+ import { cn } from "@/lib/utils";
24
+ import { DocNode, docService } from "@/services";
25
+
26
+ // Document categories for role-based filtering
27
+ const DOC_CATEGORIES = [
28
+ {
29
+ id: "all",
30
+ label: "All Documents",
31
+ icon: BookOpen,
32
+ color: "text-foreground",
33
+ bgColor: "bg-secondary",
34
+ },
35
+ {
36
+ id: "product",
37
+ label: "Product",
38
+ icon: Lightbulb,
39
+ color: "text-amber-500",
40
+ bgColor: "bg-amber-500/10",
41
+ description: "PRDs, roadmaps, specs",
42
+ },
43
+ {
44
+ id: "engineering",
45
+ label: "Engineering",
46
+ icon: Code,
47
+ color: "text-blue-500",
48
+ bgColor: "bg-blue-500/10",
49
+ description: "Technical docs, APIs",
50
+ },
51
+ {
52
+ id: "design",
53
+ label: "Design",
54
+ icon: Palette,
55
+ color: "text-purple-500",
56
+ bgColor: "bg-purple-500/10",
57
+ description: "UI specs, guidelines",
58
+ },
59
+ {
60
+ id: "team",
61
+ label: "Team",
62
+ icon: Users,
63
+ color: "text-emerald-500",
64
+ bgColor: "bg-emerald-500/10",
65
+ description: "Processes, onboarding",
66
+ },
67
+ ];
68
+
69
+ // Template options for new documents
70
+ const DOC_TEMPLATES = [
71
+ { id: "blank", label: "Blank Document", content: "# Untitled\n\n" },
72
+ {
73
+ id: "prd",
74
+ label: "Product Spec (PRD)",
75
+ category: "product",
76
+ content: `# Product Requirements Document
77
+
78
+ ## Overview
79
+ Brief description of the feature/product.
80
+
81
+ ## Goals
82
+ - Goal 1
83
+ - Goal 2
84
+
85
+ ## User Stories
86
+ As a [user type], I want [action] so that [benefit].
87
+
88
+ ## Requirements
89
+ ### Functional Requirements
90
+ 1.
91
+
92
+ ### Non-Functional Requirements
93
+ 1.
94
+
95
+ ## Success Metrics
96
+ -
97
+
98
+ ## Timeline
99
+ | Phase | Description | Date |
100
+ |-------|-------------|------|
101
+ | | | |
102
+ `,
103
+ },
104
+ {
105
+ id: "technical",
106
+ label: "Technical Design",
107
+ category: "engineering",
108
+ content: `# Technical Design Document
109
+
110
+ ## Summary
111
+ Brief technical overview.
112
+
113
+ ## Architecture
114
+ Describe the system architecture.
115
+
116
+ ## API Design
117
+ \`\`\`typescript
118
+ // API endpoints
119
+ \`\`\`
120
+
121
+ ## Database Schema
122
+ \`\`\`sql
123
+ -- Schema changes
124
+ \`\`\`
125
+
126
+ ## Implementation Plan
127
+ 1.
128
+
129
+ ## Testing Strategy
130
+ - Unit tests
131
+ - Integration tests
132
+
133
+ ## Rollout Plan
134
+ -
135
+ `,
136
+ },
137
+ {
138
+ id: "api",
139
+ label: "API Documentation",
140
+ category: "engineering",
141
+ content: `# API Documentation
142
+
143
+ ## Endpoints
144
+
145
+ ### GET /api/resource
146
+ Description of the endpoint.
147
+
148
+ **Parameters:**
149
+ | Name | Type | Required | Description |
150
+ |------|------|----------|-------------|
151
+ | | | | |
152
+
153
+ **Response:**
154
+ \`\`\`json
155
+ {
156
+ "data": []
157
+ }
158
+ \`\`\`
159
+
160
+ ### POST /api/resource
161
+ `,
162
+ },
163
+ {
164
+ id: "runbook",
165
+ label: "Runbook",
166
+ category: "engineering",
167
+ content: `# Runbook: [Service Name]
168
+
169
+ ## Overview
170
+ What this service does.
171
+
172
+ ## Common Issues
173
+
174
+ ### Issue 1
175
+ **Symptoms:**
176
+ **Resolution:**
177
+
178
+ ## Monitoring
179
+ - Dashboard:
180
+ - Alerts:
181
+
182
+ ## Contacts
183
+ - Team:
184
+ - Escalation:
185
+ `,
186
+ },
187
+ ];
188
+
189
+ export function Docs() {
190
+ const [tree, setTree] = useState<DocNode[]>([]);
191
+ const [selectedPath, setSelectedPath] = useState<string | null>(null);
192
+ const [content, setContent] = useState("");
193
+ const [originalContent, setOriginalContent] = useState("");
194
+ const [isCreating, setIsCreating] = useState(false);
195
+ const [newFileName, setNewFileName] = useState("");
196
+ const [selectedTemplate, setSelectedTemplate] = useState("blank");
197
+ const [contentMode, setContentMode] = useState<"edit" | "preview">("edit");
198
+ const [searchQuery, setSearchQuery] = useState("");
199
+ const [activeCategory, setActiveCategory] = useState("all");
200
+ const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
201
+ new Set()
202
+ );
203
+
204
+ const hasUnsavedChanges = content !== originalContent;
205
+
206
+ const fetchTree = useCallback(async () => {
207
+ try {
208
+ const data = await docService.getTree();
209
+ setTree(data);
210
+ // Auto-expand all folders
211
+ const folders = new Set<string>();
212
+ const collectFolders = (nodes: DocNode[]) => {
213
+ nodes.forEach((n) => {
214
+ if (n.type === "directory") {
215
+ folders.add(n.path);
216
+ if (n.children) collectFolders(n.children);
217
+ }
218
+ });
219
+ };
220
+ collectFolders(data);
221
+ setExpandedFolders(folders);
222
+ } catch (err) {
223
+ console.error("Failed to fetch doc tree", err);
224
+ }
225
+ }, []);
226
+
227
+ useEffect(() => {
228
+ fetchTree();
229
+ }, [fetchTree]);
230
+
231
+ useEffect(() => {
232
+ if (selectedPath) {
233
+ docService.read(selectedPath).then((data) => {
234
+ setContent(data.content || "");
235
+ setOriginalContent(data.content || "");
236
+ });
237
+ }
238
+ }, [selectedPath]);
239
+
240
+ const handleSave = async () => {
241
+ if (!selectedPath) return;
242
+ try {
243
+ await docService.write(selectedPath, content);
244
+ setOriginalContent(content);
245
+ } catch (err) {
246
+ console.error("Failed to save document", err);
247
+ }
248
+ };
249
+
250
+ const handleCreateFile = async () => {
251
+ if (!newFileName.trim()) return;
252
+
253
+ // Build path with category prefix
254
+ let path = newFileName;
255
+ if (activeCategory !== "all" && !newFileName.includes("/")) {
256
+ path = `${activeCategory}/${newFileName}`;
257
+ }
258
+ if (!path.endsWith(".md")) path += ".md";
259
+
260
+ const template = DOC_TEMPLATES.find((t) => t.id === selectedTemplate);
261
+ const initialContent =
262
+ template?.content ||
263
+ `# ${newFileName.split("/").pop()?.replace(".md", "") || "New Document"}\n\n`;
264
+
265
+ try {
266
+ await docService.write(path, initialContent);
267
+ setIsCreating(false);
268
+ setNewFileName("");
269
+ setSelectedTemplate("blank");
270
+ fetchTree();
271
+ setSelectedPath(path);
272
+ } catch (err) {
273
+ console.error("Failed to create file", err);
274
+ }
275
+ };
276
+
277
+ const toggleFolder = (path: string) => {
278
+ setExpandedFolders((prev) => {
279
+ const next = new Set(prev);
280
+ if (next.has(path)) {
281
+ next.delete(path);
282
+ } else {
283
+ next.add(path);
284
+ }
285
+ return next;
286
+ });
287
+ };
288
+
289
+ // Filter documents based on search and category
290
+ const filterNodes = (nodes: DocNode[]): DocNode[] => {
291
+ return nodes
292
+ .map((node) => {
293
+ if (node.type === "directory") {
294
+ const filteredChildren = node.children
295
+ ? filterNodes(node.children)
296
+ : [];
297
+ // Keep directory if it has matching children or matches search
298
+ if (
299
+ filteredChildren.length > 0 ||
300
+ node.name.toLowerCase().includes(searchQuery.toLowerCase())
301
+ ) {
302
+ return { ...node, children: filteredChildren };
303
+ }
304
+ return null;
305
+ } else {
306
+ // File node - check category and search
307
+ const matchesCategory =
308
+ activeCategory === "all" ||
309
+ node.path.toLowerCase().startsWith(activeCategory);
310
+ const matchesSearch = node.name
311
+ .toLowerCase()
312
+ .includes(searchQuery.toLowerCase());
313
+ if (matchesCategory && matchesSearch) return node;
314
+ return null;
315
+ }
316
+ })
317
+ .filter(Boolean) as DocNode[];
318
+ };
319
+
320
+ const filteredTree = filterNodes(tree);
321
+
322
+ const renderTree = (nodes: DocNode[], depth = 0) => {
323
+ return nodes.map((node) => {
324
+ const isExpanded = expandedFolders.has(node.path);
325
+ const isSelected = selectedPath === node.path;
326
+
327
+ if (node.type === "directory") {
328
+ return (
329
+ <div key={node.path}>
330
+ <button
331
+ className={cn(
332
+ "flex items-center gap-2 w-full px-3 py-2 text-sm font-medium rounded-lg transition-all hover:bg-secondary/50",
333
+ "text-muted-foreground"
334
+ )}
335
+ style={{ paddingLeft: `${12 + depth * 16}px` }}
336
+ onClick={() => toggleFolder(node.path)}
337
+ >
338
+ {isExpanded ? (
339
+ <FolderOpen size={16} className="text-amber-500 shrink-0" />
340
+ ) : (
341
+ <Folder size={16} className="text-amber-500 shrink-0" />
342
+ )}
343
+ <span className="truncate">{node.name}</span>
344
+ <span className="ml-auto text-[10px] text-muted-foreground/50">
345
+ {node.children?.length || 0}
346
+ </span>
347
+ </button>
348
+ {isExpanded &&
349
+ node.children &&
350
+ renderTree(node.children, depth + 1)}
351
+ </div>
352
+ );
353
+ }
354
+
355
+ // Determine file icon based on path/name
356
+ const getFileIcon = () => {
357
+ const pathLower = node.path.toLowerCase();
358
+ if (pathLower.includes("api") || pathLower.includes("technical"))
359
+ return <FileCode size={16} className="text-blue-400" />;
360
+ if (pathLower.includes("product") || pathLower.includes("prd"))
361
+ return <Lightbulb size={16} className="text-amber-400" />;
362
+ if (pathLower.includes("design"))
363
+ return <Palette size={16} className="text-purple-400" />;
364
+ return <FileText size={16} />;
365
+ };
366
+
367
+ return (
368
+ <button
369
+ key={node.path}
370
+ className={cn(
371
+ "flex items-center gap-2 w-full px-3 py-2 text-sm font-medium rounded-lg transition-all",
372
+ isSelected
373
+ ? "bg-primary text-primary-foreground"
374
+ : "text-muted-foreground hover:bg-secondary/50 hover:text-foreground"
375
+ )}
376
+ style={{ paddingLeft: `${12 + depth * 16}px` }}
377
+ onClick={() => setSelectedPath(node.path)}
378
+ >
379
+ <span className="shrink-0">{getFileIcon()}</span>
380
+ <span className="truncate">{node.name.replace(".md", "")}</span>
381
+ </button>
382
+ );
383
+ });
384
+ };
385
+
386
+ return (
387
+ <div className="flex gap-6 h-[calc(100vh-8rem)]">
388
+ {/* Sidebar */}
389
+ <aside className="w-80 flex flex-col bg-card/50 border border-border/50 rounded-xl overflow-hidden">
390
+ {/* Header */}
391
+ <div className="p-4 border-b border-border/50">
392
+ <div className="flex justify-between items-center mb-3">
393
+ <h3 className="text-sm font-bold text-foreground">Documentation</h3>
394
+ <Button
395
+ size="icon"
396
+ variant="secondary"
397
+ className="h-8 w-8"
398
+ onClick={() => setIsCreating(true)}
399
+ >
400
+ <Plus size={16} />
401
+ </Button>
402
+ </div>
403
+
404
+ {/* Search */}
405
+ <div className="relative">
406
+ <Search
407
+ size={14}
408
+ className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
409
+ />
410
+ <Input
411
+ placeholder="Search docs..."
412
+ value={searchQuery}
413
+ onChange={(e) => setSearchQuery(e.target.value)}
414
+ className="h-9 pl-9 text-sm bg-secondary/40"
415
+ />
416
+ </div>
417
+ </div>
418
+
419
+ {/* Category Filters */}
420
+ <div className="p-3 border-b border-border/50">
421
+ <div className="flex items-center gap-1.5 text-[10px] text-muted-foreground uppercase tracking-wider mb-2 px-1">
422
+ <Filter size={12} />
423
+ Categories
424
+ </div>
425
+ <div className="flex flex-wrap gap-1.5">
426
+ {DOC_CATEGORIES.map((cat) => {
427
+ const Icon = cat.icon;
428
+ return (
429
+ <button
430
+ key={cat.id}
431
+ className={cn(
432
+ "flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all",
433
+ activeCategory === cat.id
434
+ ? `${cat.bgColor} ${cat.color}`
435
+ : "text-muted-foreground hover:bg-secondary/50"
436
+ )}
437
+ onClick={() => setActiveCategory(cat.id)}
438
+ >
439
+ <Icon size={12} />
440
+ {cat.label}
441
+ </button>
442
+ );
443
+ })}
444
+ </div>
445
+ </div>
446
+
447
+ {/* Create New Document Modal */}
448
+ {isCreating && (
449
+ <div className="p-4 bg-secondary/30 border-b border-border/50 animate-in fade-in slide-in-from-top-2 duration-200">
450
+ <div className="flex items-center justify-between mb-3">
451
+ <span className="text-xs font-semibold text-foreground">
452
+ New Document
453
+ </span>
454
+ <button
455
+ className="text-muted-foreground hover:text-foreground"
456
+ onClick={() => setIsCreating(false)}
457
+ >
458
+ <X size={14} />
459
+ </button>
460
+ </div>
461
+
462
+ <Input
463
+ autoFocus
464
+ placeholder="document-name"
465
+ value={newFileName}
466
+ onChange={(e) => setNewFileName(e.target.value)}
467
+ onKeyDown={(e) => {
468
+ if (e.key === "Enter") handleCreateFile();
469
+ if (e.key === "Escape") setIsCreating(false);
470
+ }}
471
+ className="h-9 mb-3"
472
+ />
473
+
474
+ <div className="mb-3">
475
+ <label className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 block">
476
+ Template
477
+ </label>
478
+ <div className="grid grid-cols-2 gap-1.5">
479
+ {DOC_TEMPLATES.slice(0, 4).map((template) => (
480
+ <button
481
+ key={template.id}
482
+ className={cn(
483
+ "px-2.5 py-2 text-[11px] font-medium rounded-lg border transition-all text-left",
484
+ selectedTemplate === template.id
485
+ ? "border-primary bg-primary/10 text-foreground"
486
+ : "border-border/50 text-muted-foreground hover:border-border hover:bg-secondary/30"
487
+ )}
488
+ onClick={() => setSelectedTemplate(template.id)}
489
+ >
490
+ {template.label}
491
+ </button>
492
+ ))}
493
+ </div>
494
+ </div>
495
+
496
+ <Button
497
+ size="sm"
498
+ className="w-full h-8"
499
+ onClick={handleCreateFile}
500
+ disabled={!newFileName.trim()}
501
+ >
502
+ Create Document
503
+ </Button>
504
+ </div>
505
+ )}
506
+
507
+ {/* File Tree */}
508
+ <div className="flex-1 overflow-y-auto p-2">
509
+ {filteredTree.length > 0 ? (
510
+ renderTree(filteredTree)
511
+ ) : (
512
+ <div className="flex flex-col items-center justify-center h-full text-center p-4">
513
+ <File size={32} className="text-muted-foreground/30 mb-2" />
514
+ <p className="text-sm text-muted-foreground">No documents</p>
515
+ <p className="text-xs text-muted-foreground/60">
516
+ Create your first document
517
+ </p>
518
+ </div>
519
+ )}
520
+ </div>
521
+ </aside>
522
+
523
+ {/* Main Content */}
524
+ <main className="flex-1 flex flex-col min-w-0">
525
+ {selectedPath ? (
526
+ <div className="flex flex-col h-full gap-4" data-color-mode="dark">
527
+ {/* Document Header */}
528
+ <header className="flex justify-between items-center bg-card/50 border border-border/50 p-4 rounded-xl">
529
+ <div className="flex items-center gap-3 min-w-0">
530
+ <div className="p-2.5 bg-primary/10 rounded-xl shrink-0">
531
+ <FileText size={18} className="text-primary" />
532
+ </div>
533
+ <div className="flex flex-col min-w-0">
534
+ <span className="font-semibold text-foreground truncate">
535
+ {selectedPath?.split("/").pop()?.replace(".md", "")}
536
+ </span>
537
+ <span className="text-[11px] text-muted-foreground truncate flex items-center gap-1.5">
538
+ <span className="opacity-60">/</span>
539
+ {selectedPath}
540
+ {hasUnsavedChanges && (
541
+ <span className="w-1.5 h-1.5 rounded-full bg-amber-500 ml-1" />
542
+ )}
543
+ </span>
544
+ </div>
545
+ </div>
546
+ <div className="flex items-center gap-3">
547
+ <div className="flex bg-secondary/50 p-1 rounded-lg border border-border/50">
548
+ <button
549
+ className={cn(
550
+ "px-3 py-1.5 text-xs font-semibold rounded-md transition-all",
551
+ contentMode === "edit"
552
+ ? "bg-background text-foreground shadow-sm"
553
+ : "text-muted-foreground hover:text-foreground"
554
+ )}
555
+ onClick={() => setContentMode("edit")}
556
+ >
557
+ Edit
558
+ </button>
559
+ <button
560
+ className={cn(
561
+ "px-3 py-1.5 text-xs font-semibold rounded-md transition-all",
562
+ contentMode === "preview"
563
+ ? "bg-background text-foreground shadow-sm"
564
+ : "text-muted-foreground hover:text-foreground"
565
+ )}
566
+ onClick={() => setContentMode("preview")}
567
+ >
568
+ Preview
569
+ </button>
570
+ </div>
571
+ <Button
572
+ onClick={handleSave}
573
+ className="h-9"
574
+ disabled={!hasUnsavedChanges}
575
+ >
576
+ <Save size={14} className="mr-2" />
577
+ Save
578
+ </Button>
579
+ </div>
580
+ </header>
581
+
582
+ {/* Editor */}
583
+ <div className="flex-1 bg-card/50 border border-border/50 rounded-xl overflow-hidden p-4 markdown-container">
584
+ <MDEditor
585
+ value={content}
586
+ onChange={(v) => setContent(v || "")}
587
+ height="100%"
588
+ preview={contentMode}
589
+ hideToolbar={contentMode === "preview"}
590
+ className="bg-transparent! border-none! text-foreground!"
591
+ />
592
+ </div>
593
+ </div>
594
+ ) : (
595
+ <div className="h-full flex items-center justify-center">
596
+ <div className="max-w-lg w-full p-10 bg-card/50 border border-border/50 rounded-2xl flex flex-col items-center text-center space-y-5">
597
+ <div className="p-5 bg-secondary/50 rounded-2xl">
598
+ <BookOpen size={40} className="text-muted-foreground/40" />
599
+ </div>
600
+ <div>
601
+ <h2 className="text-xl font-bold text-foreground mb-2">
602
+ Welcome to Documentation
603
+ </h2>
604
+ <p className="text-muted-foreground text-sm leading-relaxed max-w-sm">
605
+ Select a document from the sidebar or create a new one using
606
+ templates for PRDs, technical specs, and more.
607
+ </p>
608
+ </div>
609
+ <div className="flex gap-2 pt-2">
610
+ <Button
611
+ variant="secondary"
612
+ size="sm"
613
+ onClick={() => setIsCreating(true)}
614
+ >
615
+ <Plus size={14} className="mr-1.5" />
616
+ New Document
617
+ </Button>
618
+ </div>
619
+ </div>
620
+ </div>
621
+ )}
622
+ </main>
623
+ </div>
624
+ );
625
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "jsx": "preserve",
5
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
6
+ "moduleResolution": "bundler",
7
+ "allowImportingTsExtensions": true,
8
+ "noEmit": true,
9
+ "allowJs": true,
10
+ "plugins": [
11
+ {
12
+ "name": "next"
13
+ }
14
+ ],
15
+ "paths": {
16
+ "@/*": ["./src/*"]
17
+ }
18
+ },
19
+ "include": ["src", ".next/types/**/*.ts"],
20
+ "exclude": ["node_modules"]
21
+ }