@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,138 @@
1
+ "use client";
2
+
3
+ import { type Task, TaskPriority } from "@locusai/shared";
4
+ import { Calendar, Lock, MoreHorizontal, Tag, Trash2 } from "lucide-react";
5
+ import { useEffect, useRef, useState } from "react";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ interface TaskCardProps {
9
+ task: Task;
10
+ onClick: () => void;
11
+ onDelete: (id: number) => void;
12
+ isDragging?: boolean;
13
+ }
14
+
15
+ const PRIORITY_COLORS: Record<TaskPriority, string> = {
16
+ [TaskPriority.LOW]: "var(--text-muted)",
17
+ [TaskPriority.MEDIUM]: "#38bdf8",
18
+ [TaskPriority.HIGH]: "#f59e0b",
19
+ [TaskPriority.CRITICAL]: "#ef4444",
20
+ };
21
+
22
+ export function TaskCard({
23
+ task,
24
+ onClick,
25
+ onDelete,
26
+ isDragging,
27
+ }: TaskCardProps) {
28
+ const [showMenu, setShowMenu] = useState(false);
29
+ const menuRef = useRef<HTMLDivElement>(null);
30
+
31
+ useEffect(() => {
32
+ const handleClickOutside = (e: MouseEvent) => {
33
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
34
+ setShowMenu(false);
35
+ }
36
+ };
37
+ document.addEventListener("mousedown", handleClickOutside);
38
+ return () => document.removeEventListener("mousedown", handleClickOutside);
39
+ }, []);
40
+
41
+ const isLocked =
42
+ task.lockedBy && (!task.lockExpiresAt || task.lockExpiresAt > Date.now());
43
+ const priority = (task.priority as TaskPriority) || TaskPriority.MEDIUM;
44
+
45
+ return (
46
+ <div
47
+ className={cn(
48
+ "group relative bg-card border rounded-xl overflow-hidden shadow-sm transition-all hover:shadow-md hover:border-muted-foreground/20 cursor-pointer",
49
+ isDragging && "opacity-50 scale-95 rotate-1 shadow-lg"
50
+ )}
51
+ draggable
52
+ onDragStart={(e) => {
53
+ e.dataTransfer.setData("taskId", String(task.id));
54
+ e.dataTransfer.effectAllowed = "move";
55
+ }}
56
+ >
57
+ <div className="p-4" onClick={onClick}>
58
+ <div className="flex items-start gap-2 mb-3">
59
+ <div className="flex flex-col items-center gap-1.5 pt-1">
60
+ <span
61
+ className="h-2 w-2 rounded-full shrink-0"
62
+ style={{ background: PRIORITY_COLORS[priority] }}
63
+ title={`Priority: ${priority}`}
64
+ />
65
+ {isLocked && (
66
+ <span title={`Locked by ${task.lockedBy}`}>
67
+ <Lock size={12} className="text-muted-foreground" />
68
+ </span>
69
+ )}
70
+ </div>
71
+ <h4 className="text-[14px] font-semibold leading-tight text-foreground flex-1">
72
+ {task.title}
73
+ </h4>
74
+ </div>
75
+
76
+ {task.labels.length > 0 && (
77
+ <div className="flex flex-wrap gap-1.5 mb-4">
78
+ {task.labels.slice(0, 3).map((l) => (
79
+ <span
80
+ key={l}
81
+ className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-sm bg-secondary text-[10px] font-bold uppercase tracking-wider text-muted-foreground"
82
+ >
83
+ <Tag size={10} /> {l}
84
+ </span>
85
+ ))}
86
+ {task.labels.length > 3 && (
87
+ <span className="px-1.5 py-0.5 rounded-sm bg-secondary text-[10px] font-bold text-muted-foreground">
88
+ +{task.labels.length - 3}
89
+ </span>
90
+ )}
91
+ </div>
92
+ )}
93
+
94
+ <div className="flex justify-between items-center pt-2 border-t mt-auto">
95
+ <div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground">
96
+ <Calendar size={12} />
97
+ <span>{new Date(task.createdAt).toLocaleDateString()}</span>
98
+ </div>
99
+ {task.assigneeRole && (
100
+ <div className="h-6 w-6 rounded-full bg-primary text-primary-foreground text-[10px] font-bold flex items-center justify-center border shadow-sm">
101
+ {task.assigneeRole.charAt(0).toUpperCase()}
102
+ </div>
103
+ )}
104
+ </div>
105
+ </div>
106
+
107
+ <div className="absolute top-2 right-2" ref={menuRef}>
108
+ <button
109
+ className="p-1 rounded-md text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-secondary hover:text-foreground transition-all"
110
+ onClick={(e) => {
111
+ e.stopPropagation();
112
+ setShowMenu(!showMenu);
113
+ }}
114
+ >
115
+ <MoreHorizontal size={14} />
116
+ </button>
117
+
118
+ {showMenu && (
119
+ <div className="absolute top-full right-0 mt-1 w-32 bg-popover border rounded-md p-1 shadow-lg z-50 animate-in fade-in slide-in-from-top-1 duration-200">
120
+ <button
121
+ className="flex items-center gap-2 w-full px-2 py-1.5 text-xs font-medium text-destructive rounded-sm hover:bg-destructive/10 transition-colors"
122
+ onClick={(e) => {
123
+ e.stopPropagation();
124
+ if (confirm("Are you sure you want to delete this task?")) {
125
+ onDelete(task.id);
126
+ }
127
+ setShowMenu(false);
128
+ }}
129
+ >
130
+ <Trash2 size={14} />
131
+ Delete
132
+ </button>
133
+ </div>
134
+ )}
135
+ </div>
136
+ </div>
137
+ );
138
+ }
@@ -0,0 +1,243 @@
1
+ "use client";
2
+
3
+ import { AssigneeRole, TaskPriority, TaskStatus } from "@locusai/shared";
4
+ import { Plus, X } from "lucide-react";
5
+ import { useState } from "react";
6
+ import { Button, Dropdown, Input, Modal, Textarea } from "@/components/ui";
7
+ import { taskService } from "@/services";
8
+
9
+ interface TaskCreateModalProps {
10
+ isOpen: boolean;
11
+ onClose: () => void;
12
+ onCreated: () => void;
13
+ initialStatus?: TaskStatus;
14
+ sprintId?: number | null;
15
+ }
16
+
17
+ const STATUS_OPTIONS = Object.values(TaskStatus).map((status) => ({
18
+ value: status,
19
+ label: status.replace(/_/g, " "),
20
+ color: getStatusColor(status),
21
+ }));
22
+
23
+ const PRIORITY_OPTIONS = [
24
+ { value: TaskPriority.LOW, label: "Low", color: "var(--text-muted)" },
25
+ { value: TaskPriority.MEDIUM, label: "Medium", color: "#38bdf8" },
26
+ { value: TaskPriority.HIGH, label: "High", color: "#f59e0b" },
27
+ { value: TaskPriority.CRITICAL, label: "Critical", color: "#ef4444" },
28
+ ];
29
+
30
+ const ASSIGNEE_OPTIONS = Object.values(AssigneeRole).map((role) => ({
31
+ value: role,
32
+ label: role.charAt(0) + role.slice(1).toLowerCase(),
33
+ }));
34
+
35
+ function getStatusColor(status: TaskStatus): string {
36
+ const colors: Record<TaskStatus, string> = {
37
+ [TaskStatus.BACKLOG]: "#64748b",
38
+ [TaskStatus.IN_PROGRESS]: "#f59e0b",
39
+ [TaskStatus.REVIEW]: "#a855f7",
40
+ [TaskStatus.VERIFICATION]: "#38bdf8",
41
+ [TaskStatus.DONE]: "#10b981",
42
+ [TaskStatus.BLOCKED]: "#ef4444",
43
+ };
44
+ return colors[status];
45
+ }
46
+
47
+ export function TaskCreateModal({
48
+ isOpen,
49
+ onClose,
50
+ onCreated,
51
+ initialStatus = TaskStatus.BACKLOG,
52
+ sprintId = null,
53
+ }: TaskCreateModalProps) {
54
+ const [title, setTitle] = useState("");
55
+ const [description, setDescription] = useState("");
56
+ const [status, setStatus] = useState<TaskStatus>(initialStatus);
57
+ const [priority, setPriority] = useState<TaskPriority>(TaskPriority.MEDIUM);
58
+ const [assigneeRole, setAssigneeRole] = useState<AssigneeRole | undefined>();
59
+ const [labels, setLabels] = useState<string[]>([]);
60
+ const [labelInput, setLabelInput] = useState("");
61
+ const [isSubmitting, setIsSubmitting] = useState(false);
62
+
63
+ const resetForm = () => {
64
+ setTitle("");
65
+ setDescription("");
66
+ setStatus(initialStatus);
67
+ setPriority(TaskPriority.MEDIUM);
68
+ setAssigneeRole(undefined);
69
+ setLabels([]);
70
+ setLabelInput("");
71
+ };
72
+
73
+ const handleClose = () => {
74
+ resetForm();
75
+ onClose();
76
+ };
77
+
78
+ const handleAddLabel = () => {
79
+ const trimmedLabel = labelInput.trim();
80
+ if (trimmedLabel && !labels.includes(trimmedLabel)) {
81
+ setLabels([...labels, trimmedLabel]);
82
+ setLabelInput("");
83
+ }
84
+ };
85
+
86
+ const handleRemoveLabel = (label: string) => {
87
+ setLabels(labels.filter((l) => l !== label));
88
+ };
89
+
90
+ const handleSubmit = async (e: React.FormEvent) => {
91
+ e.preventDefault();
92
+ if (!title.trim()) return;
93
+
94
+ setIsSubmitting(true);
95
+ try {
96
+ await taskService.create({
97
+ title: title.trim(),
98
+ description,
99
+ status,
100
+ priority,
101
+ labels,
102
+ assigneeRole,
103
+ sprintId,
104
+ });
105
+
106
+ handleClose();
107
+ onCreated();
108
+ } catch (err) {
109
+ console.error("Failed to create task:", err);
110
+ } finally {
111
+ setIsSubmitting(false);
112
+ }
113
+ };
114
+
115
+ return (
116
+ <Modal
117
+ isOpen={isOpen}
118
+ onClose={handleClose}
119
+ title="Create New Task"
120
+ size="lg"
121
+ >
122
+ <form onSubmit={handleSubmit} className="space-y-8 py-2">
123
+ <div className="space-y-3">
124
+ <label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground ml-1">
125
+ Task Title <span className="text-destructive">*</span>
126
+ </label>
127
+ <Input
128
+ value={title}
129
+ onChange={(e) => setTitle(e.target.value)}
130
+ placeholder="e.g. Implement authentication flow"
131
+ autoFocus
132
+ className="text-lg font-medium h-12"
133
+ />
134
+ </div>
135
+
136
+ <div className="space-y-3">
137
+ <label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground ml-1">
138
+ Description
139
+ </label>
140
+ <Textarea
141
+ value={description}
142
+ onChange={(e) => setDescription(e.target.value)}
143
+ placeholder="Provide context for this task..."
144
+ rows={5}
145
+ className="resize-none"
146
+ />
147
+ </div>
148
+
149
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
150
+ <Dropdown
151
+ label="Initial Status"
152
+ value={status}
153
+ onChange={setStatus}
154
+ options={STATUS_OPTIONS}
155
+ />
156
+ <Dropdown
157
+ label="Priority Level"
158
+ value={priority}
159
+ onChange={setPriority}
160
+ options={PRIORITY_OPTIONS}
161
+ />
162
+ <Dropdown
163
+ label="Primary Assignee"
164
+ value={assigneeRole}
165
+ onChange={setAssigneeRole}
166
+ options={ASSIGNEE_OPTIONS}
167
+ placeholder="Unassigned"
168
+ />
169
+ </div>
170
+
171
+ <div className="space-y-4">
172
+ <label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground ml-1">
173
+ Task Labels
174
+ </label>
175
+ <div className="flex flex-wrap gap-2 min-h-[32px]">
176
+ {labels.length === 0 && (
177
+ <span className="text-xs text-muted-foreground/50 italic py-1">
178
+ No labels added...
179
+ </span>
180
+ )}
181
+ {labels.map((label) => (
182
+ <span
183
+ key={label}
184
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-secondary text-secondary-foreground text-[11px] font-semibold border shadow-sm transition-all hover:bg-secondary/80 translate-y-0 hover:-translate-y-0.5"
185
+ >
186
+ {label}
187
+ <button
188
+ type="button"
189
+ onClick={() => handleRemoveLabel(label)}
190
+ className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
191
+ >
192
+ <X size={12} />
193
+ </button>
194
+ </span>
195
+ ))}
196
+ </div>
197
+ <div className="flex gap-3">
198
+ <Input
199
+ value={labelInput}
200
+ onChange={(e) => setLabelInput(e.target.value)}
201
+ onKeyDown={(e) => {
202
+ if (e.key === "Enter") {
203
+ e.preventDefault();
204
+ handleAddLabel();
205
+ }
206
+ }}
207
+ placeholder="Add labels (e.g. Bug, Feature)..."
208
+ className="flex-1 h-10"
209
+ />
210
+ <Button
211
+ type="button"
212
+ onClick={handleAddLabel}
213
+ variant="secondary"
214
+ size="icon"
215
+ disabled={!labelInput.trim()}
216
+ className="h-10 w-10 shrink-0"
217
+ >
218
+ <Plus size={18} />
219
+ </Button>
220
+ </div>
221
+ </div>
222
+
223
+ <div className="flex justify-end gap-3 pt-6 border-t mt-4">
224
+ <Button
225
+ type="button"
226
+ onClick={handleClose}
227
+ variant="ghost"
228
+ className="px-6"
229
+ >
230
+ Discard
231
+ </Button>
232
+ <Button
233
+ type="submit"
234
+ disabled={!title.trim() || isSubmitting}
235
+ className="px-8 shadow-lg shadow-primary/10"
236
+ >
237
+ {isSubmitting ? "Creating..." : "Create Task"}
238
+ </Button>
239
+ </div>
240
+ </form>
241
+ </Modal>
242
+ );
243
+ }