olly-molly 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/README.md +182 -0
- package/app/api/agent/execute/route.ts +157 -0
- package/app/api/agent/status/route.ts +38 -0
- package/app/api/check-api-key/route.ts +12 -0
- package/app/api/conversations/[id]/route.ts +35 -0
- package/app/api/conversations/route.ts +24 -0
- package/app/api/members/[id]/route.ts +37 -0
- package/app/api/members/route.ts +12 -0
- package/app/api/pm/breakdown/route.ts +142 -0
- package/app/api/pm/tickets/route.ts +147 -0
- package/app/api/projects/[id]/route.ts +56 -0
- package/app/api/projects/active/route.ts +15 -0
- package/app/api/projects/route.ts +53 -0
- package/app/api/tickets/[id]/logs/route.ts +16 -0
- package/app/api/tickets/[id]/route.ts +60 -0
- package/app/api/tickets/[id]/work-logs/route.ts +16 -0
- package/app/api/tickets/route.ts +37 -0
- package/app/design-system/page.tsx +242 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +318 -0
- package/app/layout.tsx +37 -0
- package/app/page.tsx +331 -0
- package/bin/cli.js +66 -0
- package/components/ThemeProvider.tsx +56 -0
- package/components/ThemeToggle.tsx +31 -0
- package/components/activity/ActivityLog.tsx +96 -0
- package/components/activity/index.ts +1 -0
- package/components/kanban/ConversationList.tsx +75 -0
- package/components/kanban/ConversationView.tsx +132 -0
- package/components/kanban/KanbanBoard.tsx +179 -0
- package/components/kanban/KanbanColumn.tsx +80 -0
- package/components/kanban/SortableTicket.tsx +58 -0
- package/components/kanban/TicketCard.tsx +98 -0
- package/components/kanban/TicketModal.tsx +510 -0
- package/components/kanban/TicketSidebar.tsx +448 -0
- package/components/kanban/index.ts +8 -0
- package/components/pm/PMRequestModal.tsx +196 -0
- package/components/pm/index.ts +1 -0
- package/components/project/ProjectSelector.tsx +211 -0
- package/components/project/index.ts +1 -0
- package/components/team/MemberCard.tsx +147 -0
- package/components/team/TeamPanel.tsx +57 -0
- package/components/team/index.ts +2 -0
- package/components/ui/ApiKeyModal.tsx +101 -0
- package/components/ui/Avatar.tsx +95 -0
- package/components/ui/Badge.tsx +59 -0
- package/components/ui/Button.tsx +60 -0
- package/components/ui/Card.tsx +64 -0
- package/components/ui/Input.tsx +41 -0
- package/components/ui/Modal.tsx +76 -0
- package/components/ui/ResizablePane.tsx +97 -0
- package/components/ui/Select.tsx +45 -0
- package/components/ui/Textarea.tsx +41 -0
- package/components/ui/index.ts +8 -0
- package/db/dev.sqlite +0 -0
- package/db/dev.sqlite-shm +0 -0
- package/db/dev.sqlite-wal +0 -0
- package/db/schema-conversations.sql +26 -0
- package/db/schema-projects.sql +29 -0
- package/db/schema.sql +94 -0
- package/lib/agent-jobs.ts +232 -0
- package/lib/db.ts +564 -0
- package/next.config.ts +10 -0
- package/package.json +80 -0
- package/postcss.config.mjs +7 -0
- package/public/app-icon.png +0 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/profiles/designer.png +0 -0
- package/public/profiles/dev-backend.png +0 -0
- package/public/profiles/dev-frontend.png +0 -0
- package/public/profiles/pm.png +0 -0
- package/public/profiles/qa.png +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { Button } from '@/components/ui/Button';
|
|
5
|
+
import { Modal } from '@/components/ui/Modal';
|
|
6
|
+
import { Input } from '@/components/ui/Input';
|
|
7
|
+
|
|
8
|
+
interface Project {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
path: string;
|
|
12
|
+
description: string | null;
|
|
13
|
+
is_active: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ProjectSelectorProps {
|
|
17
|
+
onProjectChange?: (project: Project | null) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ProjectSelector({ onProjectChange }: ProjectSelectorProps) {
|
|
21
|
+
const [projects, setProjects] = useState<Project[]>([]);
|
|
22
|
+
const [activeProject, setActiveProject] = useState<Project | null>(null);
|
|
23
|
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
24
|
+
const [newPath, setNewPath] = useState('');
|
|
25
|
+
const [newName, setNewName] = useState('');
|
|
26
|
+
const [loading, setLoading] = useState(false);
|
|
27
|
+
const [error, setError] = useState<string | null>(null);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
fetchProjects();
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
const fetchProjects = async () => {
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch('/api/projects');
|
|
36
|
+
const data = await res.json();
|
|
37
|
+
setProjects(data);
|
|
38
|
+
const active = data.find((p: Project) => p.is_active);
|
|
39
|
+
setActiveProject(active || null);
|
|
40
|
+
onProjectChange?.(active || null);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error('Failed to fetch projects:', err);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const handleAddProject = async () => {
|
|
47
|
+
if (!newPath.trim()) return;
|
|
48
|
+
|
|
49
|
+
setLoading(true);
|
|
50
|
+
setError(null);
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch('/api/projects', {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'Content-Type': 'application/json' },
|
|
56
|
+
body: JSON.stringify({
|
|
57
|
+
path: newPath.trim(),
|
|
58
|
+
name: newName.trim() || undefined,
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const data = await res.json();
|
|
63
|
+
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
throw new Error(data.error);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
setNewPath('');
|
|
69
|
+
setNewName('');
|
|
70
|
+
await fetchProjects();
|
|
71
|
+
|
|
72
|
+
// Auto-select if it's the first project
|
|
73
|
+
if (projects.length === 0) {
|
|
74
|
+
handleSelectProject(data.id);
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
setError(err instanceof Error ? err.message : 'Failed to add project');
|
|
78
|
+
} finally {
|
|
79
|
+
setLoading(false);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const handleSelectProject = async (id: string) => {
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(`/api/projects/${id}`, {
|
|
86
|
+
method: 'PATCH',
|
|
87
|
+
headers: { 'Content-Type': 'application/json' },
|
|
88
|
+
body: JSON.stringify({ is_active: true }),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (res.ok) {
|
|
92
|
+
await fetchProjects();
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error('Failed to select project:', err);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const handleDeleteProject = async (id: string) => {
|
|
100
|
+
try {
|
|
101
|
+
await fetch(`/api/projects/${id}`, { method: 'DELETE' });
|
|
102
|
+
await fetchProjects();
|
|
103
|
+
} catch (err) {
|
|
104
|
+
console.error('Failed to delete project:', err);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<>
|
|
110
|
+
<button
|
|
111
|
+
onClick={() => setIsModalOpen(true)}
|
|
112
|
+
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
|
113
|
+
bg-[var(--bg-tertiary)] border border-[var(--border-primary)]
|
|
114
|
+
hover:border-[var(--border-secondary)] transition-colors"
|
|
115
|
+
>
|
|
116
|
+
<span>📁</span>
|
|
117
|
+
<span className="text-[var(--text-secondary)]">
|
|
118
|
+
{activeProject ? activeProject.name : '프로젝트 선택'}
|
|
119
|
+
</span>
|
|
120
|
+
{activeProject && (
|
|
121
|
+
<span className="w-2 h-2 rounded-full bg-emerald-500" title="Active" />
|
|
122
|
+
)}
|
|
123
|
+
</button>
|
|
124
|
+
|
|
125
|
+
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} title="📁 프로젝트 관리" size="lg">
|
|
126
|
+
<div className="space-y-4">
|
|
127
|
+
{/* Add new project */}
|
|
128
|
+
<div className="p-4 bg-[var(--bg-tertiary)] rounded-lg space-y-3">
|
|
129
|
+
<h4 className="text-sm font-medium text-[var(--text-primary)]">새 프로젝트 추가</h4>
|
|
130
|
+
<Input
|
|
131
|
+
placeholder="/Users/yongmin/my-project"
|
|
132
|
+
value={newPath}
|
|
133
|
+
onChange={(e) => setNewPath(e.target.value)}
|
|
134
|
+
label="프로젝트 경로"
|
|
135
|
+
/>
|
|
136
|
+
<Input
|
|
137
|
+
placeholder="My Project (선택사항)"
|
|
138
|
+
value={newName}
|
|
139
|
+
onChange={(e) => setNewName(e.target.value)}
|
|
140
|
+
label="프로젝트 이름"
|
|
141
|
+
/>
|
|
142
|
+
{error && (
|
|
143
|
+
<p className="text-sm text-red-400">{error}</p>
|
|
144
|
+
)}
|
|
145
|
+
<Button
|
|
146
|
+
variant="primary"
|
|
147
|
+
size="sm"
|
|
148
|
+
onClick={handleAddProject}
|
|
149
|
+
disabled={!newPath.trim() || loading}
|
|
150
|
+
>
|
|
151
|
+
{loading ? '추가 중...' : '프로젝트 추가'}
|
|
152
|
+
</Button>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
{/* Project list */}
|
|
156
|
+
<div className="space-y-2">
|
|
157
|
+
<h4 className="text-sm font-medium text-[var(--text-secondary)]">등록된 프로젝트</h4>
|
|
158
|
+
{projects.length === 0 ? (
|
|
159
|
+
<p className="text-sm text-[var(--text-muted)] py-4 text-center">
|
|
160
|
+
등록된 프로젝트가 없습니다
|
|
161
|
+
</p>
|
|
162
|
+
) : (
|
|
163
|
+
projects.map((project) => (
|
|
164
|
+
<div
|
|
165
|
+
key={project.id}
|
|
166
|
+
className={`p-3 rounded-lg border transition-colors ${project.is_active
|
|
167
|
+
? 'bg-indigo-500/10 border-indigo-500/30'
|
|
168
|
+
: 'bg-[var(--bg-card)] border-[var(--border-primary)] hover:border-[var(--border-secondary)]'
|
|
169
|
+
}`}
|
|
170
|
+
>
|
|
171
|
+
<div className="flex items-start justify-between gap-2">
|
|
172
|
+
<div className="flex-1 min-w-0">
|
|
173
|
+
<div className="flex items-center gap-2">
|
|
174
|
+
<span className="font-medium text-[var(--text-primary)]">{project.name}</span>
|
|
175
|
+
{project.is_active && (
|
|
176
|
+
<span className="px-1.5 py-0.5 text-xs bg-emerald-500/20 text-emerald-400 rounded">
|
|
177
|
+
Active
|
|
178
|
+
</span>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
<p className="text-xs text-[var(--text-muted)] truncate mt-1">{project.path}</p>
|
|
182
|
+
</div>
|
|
183
|
+
<div className="flex items-center gap-1">
|
|
184
|
+
{!project.is_active && (
|
|
185
|
+
<Button
|
|
186
|
+
variant="ghost"
|
|
187
|
+
size="sm"
|
|
188
|
+
onClick={() => handleSelectProject(project.id)}
|
|
189
|
+
>
|
|
190
|
+
선택
|
|
191
|
+
</Button>
|
|
192
|
+
)}
|
|
193
|
+
<Button
|
|
194
|
+
variant="ghost"
|
|
195
|
+
size="sm"
|
|
196
|
+
onClick={() => handleDeleteProject(project.id)}
|
|
197
|
+
className="text-red-400 hover:text-red-300"
|
|
198
|
+
>
|
|
199
|
+
삭제
|
|
200
|
+
</Button>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
))
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
</Modal>
|
|
209
|
+
</>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ProjectSelector } from './ProjectSelector';
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { Modal } from '@/components/ui/Modal';
|
|
5
|
+
import { Button } from '@/components/ui/Button';
|
|
6
|
+
import { Textarea } from '@/components/ui/Textarea';
|
|
7
|
+
import { Avatar } from '@/components/ui/Avatar';
|
|
8
|
+
import { Badge } from '@/components/ui/Badge';
|
|
9
|
+
|
|
10
|
+
interface Member {
|
|
11
|
+
id: string;
|
|
12
|
+
role: string;
|
|
13
|
+
name: string;
|
|
14
|
+
avatar?: string | null;
|
|
15
|
+
system_prompt: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface MemberCardProps {
|
|
19
|
+
member: Member;
|
|
20
|
+
onClick: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
export function MemberCard({ member, onClick }: MemberCardProps) {
|
|
25
|
+
const roleLabels: Record<string, string> = {
|
|
26
|
+
PM: 'Project Manager',
|
|
27
|
+
FE_DEV: 'Frontend Developer',
|
|
28
|
+
BACKEND_DEV: 'Backend Developer',
|
|
29
|
+
QA: 'QA Engineer',
|
|
30
|
+
DEVOPS: 'DevOps Engineer',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const roleColors: Record<string, 'default' | 'info' | 'success' | 'warning' | 'purple'> = {
|
|
34
|
+
PM: 'purple',
|
|
35
|
+
FE_DEV: 'info',
|
|
36
|
+
BACKEND_DEV: 'success',
|
|
37
|
+
QA: 'warning',
|
|
38
|
+
DEVOPS: 'default',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const roleImages: Record<string, string> = {
|
|
42
|
+
PM: '/profiles/pm.png',
|
|
43
|
+
FE_DEV: '/profiles/dev-frontend.png',
|
|
44
|
+
BACKEND_DEV: '/profiles/dev-backend.png',
|
|
45
|
+
QA: '/profiles/qa.png',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const profileImage = roleImages[member.role];
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
onClick={onClick}
|
|
53
|
+
className="p-4 bg-[var(--bg-tertiary)] rounded-xl border border-[var(--border-primary)]
|
|
54
|
+
hover:border-[var(--border-secondary)] hover:bg-[var(--bg-card-hover)]
|
|
55
|
+
cursor-pointer transition-all duration-200"
|
|
56
|
+
>
|
|
57
|
+
<div className="flex items-center gap-3">
|
|
58
|
+
<Avatar
|
|
59
|
+
name={member.name}
|
|
60
|
+
src={profileImage}
|
|
61
|
+
emoji={!profileImage ? member.avatar : undefined}
|
|
62
|
+
badge={profileImage ? member.avatar : undefined}
|
|
63
|
+
size="lg"
|
|
64
|
+
/>
|
|
65
|
+
<div className="flex-1 min-w-0">
|
|
66
|
+
<h3 className="font-medium text-[var(--text-primary)] truncate">{member.name}</h3>
|
|
67
|
+
<Badge variant={roleColors[member.role]} size="sm">
|
|
68
|
+
{roleLabels[member.role] || member.role}
|
|
69
|
+
</Badge>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
<p className="mt-3 text-xs text-[var(--text-muted)] line-clamp-2">
|
|
73
|
+
{member.system_prompt.slice(0, 100)}...
|
|
74
|
+
</p>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface SystemPromptEditorProps {
|
|
80
|
+
isOpen: boolean;
|
|
81
|
+
onClose: () => void;
|
|
82
|
+
member: Member | null;
|
|
83
|
+
onSave: (id: string, systemPrompt: string) => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
export function SystemPromptEditor({ isOpen, onClose, member, onSave }: SystemPromptEditorProps) {
|
|
88
|
+
const [prompt, setPrompt] = useState(member?.system_prompt || '');
|
|
89
|
+
|
|
90
|
+
const roleImages: Record<string, string> = {
|
|
91
|
+
PM: '/profiles/pm.png',
|
|
92
|
+
FE_DEV: '/profiles/dev-frontend.png',
|
|
93
|
+
BACKEND_DEV: '/profiles/dev-backend.png',
|
|
94
|
+
QA: '/profiles/qa.png',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const profileImage = member ? roleImages[member.role] : undefined;
|
|
98
|
+
|
|
99
|
+
const handleSave = () => {
|
|
100
|
+
if (member) {
|
|
101
|
+
onSave(member.id, prompt);
|
|
102
|
+
onClose();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Update prompt when member changes
|
|
107
|
+
if (member && member.system_prompt !== prompt && !isOpen) {
|
|
108
|
+
setPrompt(member.system_prompt);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<Modal isOpen={isOpen} onClose={onClose} title={`Edit ${member?.name}'s System Prompt`} size="xl">
|
|
113
|
+
<div className="space-y-4">
|
|
114
|
+
<div className="flex items-center gap-3 p-3 bg-[var(--bg-tertiary)] rounded-lg">
|
|
115
|
+
{member && (
|
|
116
|
+
<>
|
|
117
|
+
<Avatar
|
|
118
|
+
name={member.name}
|
|
119
|
+
src={profileImage}
|
|
120
|
+
emoji={!profileImage ? member.avatar : undefined}
|
|
121
|
+
badge={profileImage ? member.avatar : undefined}
|
|
122
|
+
size="md"
|
|
123
|
+
/>
|
|
124
|
+
<div>
|
|
125
|
+
<p className="font-medium text-[var(--text-primary)]">{member.name}</p>
|
|
126
|
+
<p className="text-xs text-[var(--text-tertiary)]">{member.role}</p>
|
|
127
|
+
</div>
|
|
128
|
+
</>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<Textarea
|
|
133
|
+
label="System Prompt"
|
|
134
|
+
value={prompt}
|
|
135
|
+
onChange={(e) => setPrompt(e.target.value)}
|
|
136
|
+
rows={12}
|
|
137
|
+
placeholder="Enter the system prompt for this AI agent..."
|
|
138
|
+
/>
|
|
139
|
+
|
|
140
|
+
<div className="flex justify-end gap-3 pt-2">
|
|
141
|
+
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
|
142
|
+
<Button variant="primary" onClick={handleSave}>Save Changes</Button>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</Modal>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { MemberCard, SystemPromptEditor } from './MemberCard';
|
|
5
|
+
|
|
6
|
+
interface Member {
|
|
7
|
+
id: string;
|
|
8
|
+
role: string;
|
|
9
|
+
name: string;
|
|
10
|
+
avatar?: string | null;
|
|
11
|
+
system_prompt: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface TeamPanelProps {
|
|
15
|
+
members: Member[];
|
|
16
|
+
onUpdateMember: (id: string, systemPrompt: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function TeamPanel({ members, onUpdateMember }: TeamPanelProps) {
|
|
20
|
+
const [selectedMember, setSelectedMember] = useState<Member | null>(null);
|
|
21
|
+
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
|
22
|
+
|
|
23
|
+
const handleMemberClick = (member: Member) => {
|
|
24
|
+
setSelectedMember(member);
|
|
25
|
+
setIsEditorOpen(true);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const handleSave = (id: string, systemPrompt: string) => {
|
|
29
|
+
onUpdateMember(id, systemPrompt);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="h-full flex flex-col">
|
|
34
|
+
<div className="mb-4">
|
|
35
|
+
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Team Members</h2>
|
|
36
|
+
<p className="text-sm text-[var(--text-tertiary)]">Click to edit system prompts</p>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<div className="flex-1 space-y-3 overflow-y-auto">
|
|
40
|
+
{members.map((member) => (
|
|
41
|
+
<MemberCard
|
|
42
|
+
key={member.id}
|
|
43
|
+
member={member}
|
|
44
|
+
onClick={() => handleMemberClick(member)}
|
|
45
|
+
/>
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<SystemPromptEditor
|
|
50
|
+
isOpen={isEditorOpen}
|
|
51
|
+
onClose={() => setIsEditorOpen(false)}
|
|
52
|
+
member={selectedMember}
|
|
53
|
+
onSave={handleSave}
|
|
54
|
+
/>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { Button } from '@/components/ui/Button';
|
|
5
|
+
import { Input } from '@/components/ui/Input';
|
|
6
|
+
|
|
7
|
+
interface ApiKeyModalProps {
|
|
8
|
+
isOpen: boolean;
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
onSubmit: (apiKey: string) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) {
|
|
14
|
+
const [apiKey, setApiKey] = useState('');
|
|
15
|
+
const [showKey, setShowKey] = useState(false);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (isOpen) {
|
|
19
|
+
setApiKey('');
|
|
20
|
+
}
|
|
21
|
+
}, [isOpen]);
|
|
22
|
+
|
|
23
|
+
const handleSubmit = () => {
|
|
24
|
+
if (apiKey.trim()) {
|
|
25
|
+
onSubmit(apiKey.trim());
|
|
26
|
+
onClose();
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
if (!isOpen) return null;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
34
|
+
<div className="bg-secondary border border-primary rounded-lg p-6 max-w-lg w-full mx-4 shadow-xl">
|
|
35
|
+
<h2 className="text-xl font-bold text-primary mb-4">🔑 OpenAI API Key Required</h2>
|
|
36
|
+
|
|
37
|
+
<div className="space-y-4">
|
|
38
|
+
<p className="text-sm text-muted">
|
|
39
|
+
To use AI agents, you need to provide an OpenAI API key. This key will be stored locally in your browser.
|
|
40
|
+
</p>
|
|
41
|
+
|
|
42
|
+
<div className="bg-tertiary border border-primary rounded-lg p-4 space-y-2">
|
|
43
|
+
<h3 className="font-semibold text-primary text-sm">📖 How to get your API key:</h3>
|
|
44
|
+
<ol className="text-xs text-muted space-y-1 list-decimal list-inside">
|
|
45
|
+
<li>Visit <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" className="text-indigo-400 hover:underline">platform.openai.com/api-keys</a></li>
|
|
46
|
+
<li>Sign in or create an OpenAI account</li>
|
|
47
|
+
<li>Click "Create new secret key"</li>
|
|
48
|
+
<li>Give it a name (e.g., "Olly Molly")</li>
|
|
49
|
+
<li>Copy the key (starts with "sk-")</li>
|
|
50
|
+
<li>Paste it below</li>
|
|
51
|
+
</ol>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div className="space-y-2">
|
|
55
|
+
<label className="text-sm font-medium text-primary">API Key</label>
|
|
56
|
+
<div className="relative">
|
|
57
|
+
<Input
|
|
58
|
+
type={showKey ? 'text' : 'password'}
|
|
59
|
+
value={apiKey}
|
|
60
|
+
onChange={(e) => setApiKey(e.target.value)}
|
|
61
|
+
placeholder="sk-..."
|
|
62
|
+
className="pr-20"
|
|
63
|
+
onKeyDown={(e) => {
|
|
64
|
+
if (e.key === 'Enter') handleSubmit();
|
|
65
|
+
}}
|
|
66
|
+
autoFocus
|
|
67
|
+
/>
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
onClick={() => setShowKey(!showKey)}
|
|
71
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted hover:text-primary"
|
|
72
|
+
>
|
|
73
|
+
{showKey ? '🙈 Hide' : '👁️ Show'}
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
<p className="text-xs text-amber-400">
|
|
77
|
+
⚠️ Your API key is stored only in your browser's localStorage and never sent to our servers.
|
|
78
|
+
</p>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div className="flex gap-3 pt-2">
|
|
82
|
+
<Button
|
|
83
|
+
onClick={handleSubmit}
|
|
84
|
+
variant="primary"
|
|
85
|
+
disabled={!apiKey.trim()}
|
|
86
|
+
className="flex-1"
|
|
87
|
+
>
|
|
88
|
+
💾 Save API Key
|
|
89
|
+
</Button>
|
|
90
|
+
<Button
|
|
91
|
+
onClick={onClose}
|
|
92
|
+
variant="ghost"
|
|
93
|
+
>
|
|
94
|
+
Cancel
|
|
95
|
+
</Button>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
interface AvatarProps {
|
|
4
|
+
src?: string | null;
|
|
5
|
+
name: string;
|
|
6
|
+
size?: 'sm' | 'md' | 'lg';
|
|
7
|
+
emoji?: string | null;
|
|
8
|
+
badge?: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function Avatar({ src, name, size = 'md', emoji, badge }: AvatarProps) {
|
|
12
|
+
const sizes = {
|
|
13
|
+
sm: 'w-6 h-6 text-xs',
|
|
14
|
+
md: 'w-8 h-8 text-sm',
|
|
15
|
+
lg: 'w-12 h-12 text-lg',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const badgeSizes = {
|
|
19
|
+
sm: 'w-3 h-3 text-[8px]',
|
|
20
|
+
md: 'w-4 h-4 text-[10px]',
|
|
21
|
+
lg: 'w-6 h-6 text-sm',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Get initials from name
|
|
25
|
+
const initials = name
|
|
26
|
+
.split(' ')
|
|
27
|
+
.map(n => n[0])
|
|
28
|
+
.join('')
|
|
29
|
+
.toUpperCase()
|
|
30
|
+
.slice(0, 2);
|
|
31
|
+
|
|
32
|
+
// Generate consistent color based on name
|
|
33
|
+
const colors = [
|
|
34
|
+
'from-violet-500 to-purple-600',
|
|
35
|
+
'from-blue-500 to-indigo-600',
|
|
36
|
+
'from-emerald-500 to-teal-600',
|
|
37
|
+
'from-amber-500 to-orange-600',
|
|
38
|
+
'from-rose-500 to-pink-600',
|
|
39
|
+
];
|
|
40
|
+
const colorIndex = name.charCodeAt(0) % colors.length;
|
|
41
|
+
|
|
42
|
+
const Badge = () => {
|
|
43
|
+
if (!badge) return null;
|
|
44
|
+
return (
|
|
45
|
+
<div className={`
|
|
46
|
+
absolute -bottom-1 -right-1
|
|
47
|
+
${badgeSizes[size]} rounded-full flex items-center justify-center
|
|
48
|
+
bg-white dark:bg-slate-800 shadow-sm border border-slate-200 dark:border-slate-700
|
|
49
|
+
`}>
|
|
50
|
+
<span>{badge}</span>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (src) {
|
|
56
|
+
return (
|
|
57
|
+
<div className="relative inline-block">
|
|
58
|
+
<img
|
|
59
|
+
src={src}
|
|
60
|
+
alt={name}
|
|
61
|
+
className={`${sizes[size]} rounded-full object-cover bg-white`}
|
|
62
|
+
/>
|
|
63
|
+
<Badge />
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (emoji) {
|
|
69
|
+
return (
|
|
70
|
+
<div className="relative inline-block">
|
|
71
|
+
<div className={`
|
|
72
|
+
${sizes[size]} rounded-full flex items-center justify-center
|
|
73
|
+
bg-gradient-to-br ${colors[colorIndex]}
|
|
74
|
+
`}>
|
|
75
|
+
<span className={size === 'lg' ? 'text-2xl' : size === 'md' ? 'text-lg' : 'text-base'}>
|
|
76
|
+
{emoji}
|
|
77
|
+
</span>
|
|
78
|
+
</div>
|
|
79
|
+
<Badge />
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="relative inline-block">
|
|
86
|
+
<div className={`
|
|
87
|
+
${sizes[size]} rounded-full flex items-center justify-center font-medium text-white
|
|
88
|
+
bg-gradient-to-br ${colors[colorIndex]}
|
|
89
|
+
`}>
|
|
90
|
+
{initials}
|
|
91
|
+
</div>
|
|
92
|
+
<Badge />
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
interface BadgeProps {
|
|
4
|
+
children: React.ReactNode;
|
|
5
|
+
variant?: 'default' | 'success' | 'warning' | 'danger' | 'info' | 'purple';
|
|
6
|
+
size?: 'sm' | 'md';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function Badge({ children, variant = 'default', size = 'sm' }: BadgeProps) {
|
|
10
|
+
const variants = {
|
|
11
|
+
default: 'text-[var(--text-tertiary)] bg-transparent border-[var(--border-primary)]',
|
|
12
|
+
success: 'text-[var(--status-done-text)] bg-transparent border-[var(--status-done-text)]/30',
|
|
13
|
+
warning: 'text-[var(--priority-medium-text)] bg-transparent border-[var(--priority-medium-text)]/30',
|
|
14
|
+
danger: 'text-[var(--priority-high-text)] bg-transparent border-[var(--priority-high-text)]/30',
|
|
15
|
+
info: 'text-[var(--status-progress-text)] bg-transparent border-[var(--status-progress-text)]/30',
|
|
16
|
+
purple: 'text-[var(--status-review-text)] bg-transparent border-[var(--status-review-text)]/30',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const sizes = {
|
|
20
|
+
sm: 'px-1.5 py-0.5 text-[10px]',
|
|
21
|
+
md: 'px-2 py-0.5 text-xs',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<span className={`
|
|
26
|
+
inline-flex items-center font-medium border uppercase tracking-wide
|
|
27
|
+
${variants[variant]} ${sizes[size]}
|
|
28
|
+
`}>
|
|
29
|
+
{children}
|
|
30
|
+
</span>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Status-specific badge
|
|
35
|
+
export function StatusBadge({ status }: { status: string }) {
|
|
36
|
+
const statusConfig: Record<string, { label: string; variant: BadgeProps['variant'] }> = {
|
|
37
|
+
TODO: { label: 'Todo', variant: 'default' },
|
|
38
|
+
IN_PROGRESS: { label: 'Progress', variant: 'info' },
|
|
39
|
+
IN_REVIEW: { label: 'Review', variant: 'purple' },
|
|
40
|
+
COMPLETE: { label: 'Done', variant: 'success' },
|
|
41
|
+
ON_HOLD: { label: 'Hold', variant: 'warning' },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const config = statusConfig[status] || { label: status, variant: 'default' as const };
|
|
45
|
+
return <Badge variant={config.variant}>{config.label}</Badge>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Priority badge
|
|
49
|
+
export function PriorityBadge({ priority }: { priority: string }) {
|
|
50
|
+
const priorityConfig: Record<string, { label: string; variant: BadgeProps['variant'] }> = {
|
|
51
|
+
LOW: { label: 'Low', variant: 'default' },
|
|
52
|
+
MEDIUM: { label: 'Med', variant: 'warning' },
|
|
53
|
+
HIGH: { label: 'High', variant: 'danger' },
|
|
54
|
+
CRITICAL: { label: '!', variant: 'danger' },
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const config = priorityConfig[priority] || { label: priority, variant: 'default' as const };
|
|
58
|
+
return <Badge variant={config.variant}>{config.label}</Badge>;
|
|
59
|
+
}
|