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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +182 -0
  3. package/app/api/agent/execute/route.ts +157 -0
  4. package/app/api/agent/status/route.ts +38 -0
  5. package/app/api/check-api-key/route.ts +12 -0
  6. package/app/api/conversations/[id]/route.ts +35 -0
  7. package/app/api/conversations/route.ts +24 -0
  8. package/app/api/members/[id]/route.ts +37 -0
  9. package/app/api/members/route.ts +12 -0
  10. package/app/api/pm/breakdown/route.ts +142 -0
  11. package/app/api/pm/tickets/route.ts +147 -0
  12. package/app/api/projects/[id]/route.ts +56 -0
  13. package/app/api/projects/active/route.ts +15 -0
  14. package/app/api/projects/route.ts +53 -0
  15. package/app/api/tickets/[id]/logs/route.ts +16 -0
  16. package/app/api/tickets/[id]/route.ts +60 -0
  17. package/app/api/tickets/[id]/work-logs/route.ts +16 -0
  18. package/app/api/tickets/route.ts +37 -0
  19. package/app/design-system/page.tsx +242 -0
  20. package/app/favicon.ico +0 -0
  21. package/app/globals.css +318 -0
  22. package/app/layout.tsx +37 -0
  23. package/app/page.tsx +331 -0
  24. package/bin/cli.js +66 -0
  25. package/components/ThemeProvider.tsx +56 -0
  26. package/components/ThemeToggle.tsx +31 -0
  27. package/components/activity/ActivityLog.tsx +96 -0
  28. package/components/activity/index.ts +1 -0
  29. package/components/kanban/ConversationList.tsx +75 -0
  30. package/components/kanban/ConversationView.tsx +132 -0
  31. package/components/kanban/KanbanBoard.tsx +179 -0
  32. package/components/kanban/KanbanColumn.tsx +80 -0
  33. package/components/kanban/SortableTicket.tsx +58 -0
  34. package/components/kanban/TicketCard.tsx +98 -0
  35. package/components/kanban/TicketModal.tsx +510 -0
  36. package/components/kanban/TicketSidebar.tsx +448 -0
  37. package/components/kanban/index.ts +8 -0
  38. package/components/pm/PMRequestModal.tsx +196 -0
  39. package/components/pm/index.ts +1 -0
  40. package/components/project/ProjectSelector.tsx +211 -0
  41. package/components/project/index.ts +1 -0
  42. package/components/team/MemberCard.tsx +147 -0
  43. package/components/team/TeamPanel.tsx +57 -0
  44. package/components/team/index.ts +2 -0
  45. package/components/ui/ApiKeyModal.tsx +101 -0
  46. package/components/ui/Avatar.tsx +95 -0
  47. package/components/ui/Badge.tsx +59 -0
  48. package/components/ui/Button.tsx +60 -0
  49. package/components/ui/Card.tsx +64 -0
  50. package/components/ui/Input.tsx +41 -0
  51. package/components/ui/Modal.tsx +76 -0
  52. package/components/ui/ResizablePane.tsx +97 -0
  53. package/components/ui/Select.tsx +45 -0
  54. package/components/ui/Textarea.tsx +41 -0
  55. package/components/ui/index.ts +8 -0
  56. package/db/dev.sqlite +0 -0
  57. package/db/dev.sqlite-shm +0 -0
  58. package/db/dev.sqlite-wal +0 -0
  59. package/db/schema-conversations.sql +26 -0
  60. package/db/schema-projects.sql +29 -0
  61. package/db/schema.sql +94 -0
  62. package/lib/agent-jobs.ts +232 -0
  63. package/lib/db.ts +564 -0
  64. package/next.config.ts +10 -0
  65. package/package.json +80 -0
  66. package/postcss.config.mjs +7 -0
  67. package/public/app-icon.png +0 -0
  68. package/public/file.svg +1 -0
  69. package/public/globe.svg +1 -0
  70. package/public/next.svg +1 -0
  71. package/public/profiles/designer.png +0 -0
  72. package/public/profiles/dev-backend.png +0 -0
  73. package/public/profiles/dev-frontend.png +0 -0
  74. package/public/profiles/pm.png +0 -0
  75. package/public/profiles/qa.png +0 -0
  76. package/public/vercel.svg +1 -0
  77. package/public/window.svg +1 -0
  78. 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,2 @@
1
+ export { TeamPanel } from './TeamPanel';
2
+ export { MemberCard, SystemPromptEditor } from './MemberCard';
@@ -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
+ }