@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.
- package/LICENSE +21 -0
- package/next.config.js +7 -0
- package/package.json +37 -0
- package/postcss.config.mjs +5 -0
- package/src/app/backlog/page.tsx +19 -0
- package/src/app/docs/page.tsx +7 -0
- package/src/app/globals.css +603 -0
- package/src/app/layout.tsx +43 -0
- package/src/app/page.tsx +16 -0
- package/src/app/providers.tsx +16 -0
- package/src/app/settings/page.tsx +194 -0
- package/src/components/BoardFilter.tsx +98 -0
- package/src/components/Header.tsx +21 -0
- package/src/components/PropertyItem.tsx +98 -0
- package/src/components/Sidebar.tsx +109 -0
- package/src/components/TaskCard.tsx +138 -0
- package/src/components/TaskCreateModal.tsx +243 -0
- package/src/components/TaskPanel.tsx +765 -0
- package/src/components/index.ts +7 -0
- package/src/components/ui/Badge.tsx +77 -0
- package/src/components/ui/Button.tsx +47 -0
- package/src/components/ui/Checkbox.tsx +52 -0
- package/src/components/ui/Dropdown.tsx +107 -0
- package/src/components/ui/Input.tsx +36 -0
- package/src/components/ui/Modal.tsx +79 -0
- package/src/components/ui/Textarea.tsx +21 -0
- package/src/components/ui/index.ts +7 -0
- package/src/hooks/useTasks.ts +119 -0
- package/src/lib/api-client.ts +24 -0
- package/src/lib/utils.ts +6 -0
- package/src/services/doc.service.ts +27 -0
- package/src/services/index.ts +3 -0
- package/src/services/sprint.service.ts +26 -0
- package/src/services/task.service.ts +75 -0
- package/src/views/Backlog.tsx +691 -0
- package/src/views/Board.tsx +306 -0
- package/src/views/Docs.tsx +625 -0
- 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
|
+
}
|