idea-manager 0.1.2 → 0.3.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/README.md +19 -10
- package/next.config.ts +0 -1
- package/package.json +2 -2
- package/public/favicon.svg +10 -0
- package/public/icon.svg +2 -11
- package/src/app/api/filesystem/route.ts +49 -0
- package/src/app/api/projects/[id]/cleanup/route.ts +32 -0
- package/src/app/api/projects/[id]/items/[itemId]/refine/route.ts +36 -0
- package/src/app/api/projects/[id]/items/[itemId]/route.ts +23 -1
- package/src/app/api/projects/[id]/items/route.ts +51 -1
- package/src/app/api/projects/[id]/scan/route.ts +73 -0
- package/src/app/api/projects/[id]/scan/stream/route.ts +112 -0
- package/src/app/api/projects/[id]/structure/route.ts +34 -3
- package/src/app/api/projects/[id]/structure/stream/route.ts +157 -0
- package/src/app/api/projects/[id]/sub-projects/[subId]/route.ts +39 -0
- package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route.ts +60 -0
- package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route.ts +26 -0
- package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route.ts +39 -0
- package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/route.ts +33 -0
- package/src/app/api/projects/[id]/sub-projects/route.ts +31 -0
- package/src/app/api/projects/route.ts +1 -1
- package/src/app/globals.css +465 -5
- package/src/app/layout.tsx +3 -0
- package/src/app/page.tsx +260 -88
- package/src/app/projects/[id]/page.tsx +366 -183
- package/src/cli.ts +44 -12
- package/src/components/DirectoryPicker.tsx +137 -0
- package/src/components/ScanPanel.tsx +743 -0
- package/src/components/brainstorm/Editor.tsx +20 -4
- package/src/components/brainstorm/MemoPin.tsx +91 -5
- package/src/components/dashboard/SubProjectCard.tsx +76 -0
- package/src/components/dashboard/TabBar.tsx +42 -0
- package/src/components/task/ProjectTree.tsx +223 -0
- package/src/components/task/PromptEditor.tsx +107 -0
- package/src/components/task/StatusFlow.tsx +43 -0
- package/src/components/task/TaskChat.tsx +134 -0
- package/src/components/task/TaskDetail.tsx +205 -0
- package/src/components/task/TaskList.tsx +119 -0
- package/src/components/tree/CardView.tsx +206 -0
- package/src/components/tree/RefinePopover.tsx +157 -0
- package/src/components/tree/TreeNode.tsx +147 -38
- package/src/components/tree/TreeView.tsx +270 -26
- package/src/components/ui/ConfirmDialog.tsx +88 -0
- package/src/lib/ai/chat-responder.ts +4 -2
- package/src/lib/ai/cleanup.ts +87 -0
- package/src/lib/ai/client.ts +175 -58
- package/src/lib/ai/prompter.ts +19 -24
- package/src/lib/ai/refiner.ts +128 -0
- package/src/lib/ai/structurer.ts +340 -11
- package/src/lib/db/queries/context.ts +76 -0
- package/src/lib/db/queries/items.ts +133 -12
- package/src/lib/db/queries/projects.ts +12 -8
- package/src/lib/db/queries/sub-projects.ts +122 -0
- package/src/lib/db/queries/task-conversations.ts +27 -0
- package/src/lib/db/queries/task-prompts.ts +32 -0
- package/src/lib/db/queries/tasks.ts +133 -0
- package/src/lib/db/schema.ts +75 -0
- package/src/lib/mcp/server.ts +38 -39
- package/src/lib/mcp/tools.ts +47 -45
- package/src/lib/scanner.ts +573 -0
- package/src/lib/task-store.ts +97 -0
- package/src/types/index.ts +65 -0
- package/src/app/icon.svg +0 -19
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
4
|
+
import type { ITaskConversation } from '@/types';
|
|
5
|
+
|
|
6
|
+
export default function TaskChat({
|
|
7
|
+
basePath,
|
|
8
|
+
onApplyToPrompt,
|
|
9
|
+
}: {
|
|
10
|
+
basePath: string;
|
|
11
|
+
onApplyToPrompt: (content: string) => void;
|
|
12
|
+
}) {
|
|
13
|
+
const [messages, setMessages] = useState<ITaskConversation[]>([]);
|
|
14
|
+
const [input, setInput] = useState('');
|
|
15
|
+
const [loading, setLoading] = useState(false);
|
|
16
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
17
|
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
fetch(`${basePath}/chat`)
|
|
21
|
+
.then(r => r.json())
|
|
22
|
+
.then(data => setMessages(Array.isArray(data) ? data : []));
|
|
23
|
+
}, [basePath]);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
27
|
+
}, [messages]);
|
|
28
|
+
|
|
29
|
+
const send = useCallback(async () => {
|
|
30
|
+
const text = input.trim();
|
|
31
|
+
if (!text || loading) return;
|
|
32
|
+
|
|
33
|
+
setInput('');
|
|
34
|
+
setLoading(true);
|
|
35
|
+
|
|
36
|
+
// Optimistic user message
|
|
37
|
+
const tempId = `temp-${Date.now()}`;
|
|
38
|
+
const userMsg: ITaskConversation = {
|
|
39
|
+
id: tempId, task_id: '', role: 'user', content: text,
|
|
40
|
+
created_at: new Date().toISOString(),
|
|
41
|
+
};
|
|
42
|
+
setMessages(prev => [...prev, userMsg]);
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch(`${basePath}/chat`, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'Content-Type': 'application/json' },
|
|
48
|
+
body: JSON.stringify({ message: text }),
|
|
49
|
+
});
|
|
50
|
+
if (res.ok) {
|
|
51
|
+
const data = await res.json();
|
|
52
|
+
setMessages(prev => {
|
|
53
|
+
const withoutTemp = prev.filter(m => m.id !== tempId);
|
|
54
|
+
return [...withoutTemp, data.userMessage, data.aiMessage];
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
} catch { /* silent */ }
|
|
58
|
+
setLoading(false);
|
|
59
|
+
inputRef.current?.focus();
|
|
60
|
+
}, [input, loading, basePath]);
|
|
61
|
+
|
|
62
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
63
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
send();
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="flex flex-col h-full border-t border-border">
|
|
71
|
+
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border">
|
|
72
|
+
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">AI Chat</span>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
{/* Messages */}
|
|
76
|
+
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-2 min-h-0">
|
|
77
|
+
{messages.length === 0 && !loading && (
|
|
78
|
+
<div className="text-sm text-muted-foreground text-center py-4">
|
|
79
|
+
Ask AI to help refine your task or prompt
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
{messages.map((msg) => (
|
|
83
|
+
<div key={msg.id} className={`flex flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}>
|
|
84
|
+
<div className={`max-w-[90%] px-3 py-2 rounded-lg text-sm leading-relaxed whitespace-pre-wrap ${
|
|
85
|
+
msg.role === 'user'
|
|
86
|
+
? 'bg-accent text-white rounded-br-sm'
|
|
87
|
+
: 'bg-muted text-foreground rounded-bl-sm'
|
|
88
|
+
}`}>
|
|
89
|
+
{msg.content}
|
|
90
|
+
</div>
|
|
91
|
+
{msg.role === 'assistant' && (
|
|
92
|
+
<button
|
|
93
|
+
onClick={() => onApplyToPrompt(msg.content)}
|
|
94
|
+
className="text-xs text-muted-foreground hover:text-primary mt-0.5 px-1 transition-colors"
|
|
95
|
+
>
|
|
96
|
+
Apply to prompt
|
|
97
|
+
</button>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
))}
|
|
101
|
+
{loading && (
|
|
102
|
+
<div className="flex gap-1 px-2 py-2">
|
|
103
|
+
<div className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '0ms' }} />
|
|
104
|
+
<div className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '150ms' }} />
|
|
105
|
+
<div className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '300ms' }} />
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
<div ref={messagesEndRef} />
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{/* Input */}
|
|
112
|
+
<div className="flex gap-1.5 px-2 py-2 border-t border-border">
|
|
113
|
+
<textarea
|
|
114
|
+
ref={inputRef}
|
|
115
|
+
value={input}
|
|
116
|
+
onChange={(e) => setInput(e.target.value)}
|
|
117
|
+
onKeyDown={handleKeyDown}
|
|
118
|
+
placeholder="Ask AI..."
|
|
119
|
+
rows={1}
|
|
120
|
+
className="flex-1 bg-input border border-border rounded-md px-3 py-2 text-sm
|
|
121
|
+
text-foreground resize-none focus:border-primary focus:outline-none"
|
|
122
|
+
/>
|
|
123
|
+
<button
|
|
124
|
+
onClick={send}
|
|
125
|
+
disabled={!input.trim() || loading}
|
|
126
|
+
className="px-3 py-2 bg-accent text-white text-sm rounded-md
|
|
127
|
+
disabled:opacity-40 hover:bg-accent/80 transition-colors flex-shrink-0"
|
|
128
|
+
>
|
|
129
|
+
Send
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
import type { ITask, TaskStatus, ItemPriority } from '@/types';
|
|
5
|
+
import StatusFlow from './StatusFlow';
|
|
6
|
+
import PromptEditor from './PromptEditor';
|
|
7
|
+
import TaskChat from './TaskChat';
|
|
8
|
+
|
|
9
|
+
export default function TaskDetail({
|
|
10
|
+
task,
|
|
11
|
+
projectId,
|
|
12
|
+
subProjectId,
|
|
13
|
+
onUpdate,
|
|
14
|
+
onDelete,
|
|
15
|
+
}: {
|
|
16
|
+
task: ITask;
|
|
17
|
+
projectId: string;
|
|
18
|
+
subProjectId: string;
|
|
19
|
+
onUpdate: (data: Partial<ITask>) => void;
|
|
20
|
+
onDelete: () => void;
|
|
21
|
+
}) {
|
|
22
|
+
const [title, setTitle] = useState(task.title);
|
|
23
|
+
const [description, setDescription] = useState(task.description);
|
|
24
|
+
const [promptContent, setPromptContent] = useState('');
|
|
25
|
+
const [refining, setRefining] = useState(false);
|
|
26
|
+
const [editingTitle, setEditingTitle] = useState(false);
|
|
27
|
+
const [showChat, setShowChat] = useState(false);
|
|
28
|
+
|
|
29
|
+
const basePath = `/api/projects/${projectId}/sub-projects/${subProjectId}/tasks/${task.id}`;
|
|
30
|
+
|
|
31
|
+
// Load prompt
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
setTitle(task.title);
|
|
34
|
+
setDescription(task.description);
|
|
35
|
+
setShowChat(false);
|
|
36
|
+
fetch(`${basePath}/prompt`)
|
|
37
|
+
.then(r => r.json())
|
|
38
|
+
.then(data => setPromptContent(data.content || ''));
|
|
39
|
+
}, [task.id, task.title, task.description, basePath]);
|
|
40
|
+
|
|
41
|
+
const saveTitle = useCallback(() => {
|
|
42
|
+
const trimmed = title.trim();
|
|
43
|
+
if (trimmed && trimmed !== task.title) {
|
|
44
|
+
onUpdate({ title: trimmed });
|
|
45
|
+
} else {
|
|
46
|
+
setTitle(task.title);
|
|
47
|
+
}
|
|
48
|
+
setEditingTitle(false);
|
|
49
|
+
}, [title, task.title, onUpdate]);
|
|
50
|
+
|
|
51
|
+
const saveDescription = useCallback(() => {
|
|
52
|
+
if (description !== task.description) {
|
|
53
|
+
onUpdate({ description });
|
|
54
|
+
}
|
|
55
|
+
}, [description, task.description, onUpdate]);
|
|
56
|
+
|
|
57
|
+
const savePrompt = useCallback(async (content: string) => {
|
|
58
|
+
setPromptContent(content);
|
|
59
|
+
await fetch(`${basePath}/prompt`, {
|
|
60
|
+
method: 'PUT',
|
|
61
|
+
headers: { 'Content-Type': 'application/json' },
|
|
62
|
+
body: JSON.stringify({ content, prompt_type: 'manual' }),
|
|
63
|
+
});
|
|
64
|
+
}, [basePath]);
|
|
65
|
+
|
|
66
|
+
const handleRefine = useCallback(async () => {
|
|
67
|
+
setRefining(true);
|
|
68
|
+
try {
|
|
69
|
+
const res = await fetch(`${basePath}/chat`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'Content-Type': 'application/json' },
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
message: `Please refine and improve this prompt for a coding assistant. Current prompt: ${promptContent || '(empty - generate one based on the task)'}. Task: ${task.title}. Description: ${task.description}. Output ONLY the improved prompt text, nothing else.`,
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
if (res.ok) {
|
|
77
|
+
const data = await res.json();
|
|
78
|
+
const refined = data.aiMessage?.content || '';
|
|
79
|
+
if (refined) {
|
|
80
|
+
await savePrompt(refined);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch { /* silent */ }
|
|
84
|
+
setRefining(false);
|
|
85
|
+
}, [basePath, promptContent, task.title, task.description, savePrompt]);
|
|
86
|
+
|
|
87
|
+
const handleApplyToPrompt = useCallback(async (content: string) => {
|
|
88
|
+
await savePrompt(content);
|
|
89
|
+
}, [savePrompt]);
|
|
90
|
+
|
|
91
|
+
const priorities: ItemPriority[] = ['high', 'medium', 'low'];
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="flex flex-col h-full">
|
|
95
|
+
{/* Upper: Task info + Prompt */}
|
|
96
|
+
<div className={`overflow-y-auto ${showChat ? 'flex-1 min-h-0' : 'flex-1'}`}>
|
|
97
|
+
<div className="p-4 space-y-4">
|
|
98
|
+
{/* Title */}
|
|
99
|
+
<div>
|
|
100
|
+
{editingTitle ? (
|
|
101
|
+
<input
|
|
102
|
+
value={title}
|
|
103
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
104
|
+
onBlur={saveTitle}
|
|
105
|
+
onKeyDown={(e) => { if (e.key === 'Enter') saveTitle(); if (e.key === 'Escape') { setTitle(task.title); setEditingTitle(false); } }}
|
|
106
|
+
className="w-full bg-transparent text-xl font-semibold border-b border-primary
|
|
107
|
+
focus:outline-none pb-1 text-foreground"
|
|
108
|
+
autoFocus
|
|
109
|
+
/>
|
|
110
|
+
) : (
|
|
111
|
+
<h2
|
|
112
|
+
onClick={() => setEditingTitle(true)}
|
|
113
|
+
className="text-xl font-semibold cursor-text hover:text-primary transition-colors"
|
|
114
|
+
>
|
|
115
|
+
{task.title}
|
|
116
|
+
</h2>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
{/* Status + Priority + Today */}
|
|
121
|
+
<div className="flex items-center gap-4 flex-wrap">
|
|
122
|
+
<StatusFlow status={task.status} onChange={(status: TaskStatus) => onUpdate({ status })} />
|
|
123
|
+
<div className="flex items-center gap-1">
|
|
124
|
+
{priorities.map(p => (
|
|
125
|
+
<button
|
|
126
|
+
key={p}
|
|
127
|
+
onClick={() => onUpdate({ priority: p })}
|
|
128
|
+
className={`px-2.5 py-1 text-sm rounded transition-colors ${
|
|
129
|
+
task.priority === p
|
|
130
|
+
? p === 'high' ? 'bg-destructive/20 text-destructive' : p === 'medium' ? 'bg-warning/20 text-warning' : 'bg-muted text-muted-foreground'
|
|
131
|
+
: 'text-muted-foreground/40 hover:text-muted-foreground'
|
|
132
|
+
}`}
|
|
133
|
+
>
|
|
134
|
+
{p}
|
|
135
|
+
</button>
|
|
136
|
+
))}
|
|
137
|
+
</div>
|
|
138
|
+
<button
|
|
139
|
+
onClick={() => onUpdate({ is_today: !task.is_today })}
|
|
140
|
+
className={`text-sm px-2.5 py-1 rounded transition-colors ${
|
|
141
|
+
task.is_today
|
|
142
|
+
? 'bg-primary/20 text-primary'
|
|
143
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
144
|
+
}`}
|
|
145
|
+
>
|
|
146
|
+
{task.is_today ? 'Today *' : 'Mark today'}
|
|
147
|
+
</button>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
{/* Description */}
|
|
151
|
+
<div>
|
|
152
|
+
<textarea
|
|
153
|
+
value={description}
|
|
154
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
155
|
+
onBlur={saveDescription}
|
|
156
|
+
placeholder="Background, conditions, notes..."
|
|
157
|
+
className="w-full bg-input border border-border rounded-lg px-3 py-2.5 text-sm
|
|
158
|
+
focus:border-primary focus:outline-none text-foreground resize-none min-h-[60px]
|
|
159
|
+
leading-relaxed"
|
|
160
|
+
rows={2}
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
{/* Prompt */}
|
|
165
|
+
<PromptEditor
|
|
166
|
+
content={promptContent}
|
|
167
|
+
onSave={savePrompt}
|
|
168
|
+
onRefine={handleRefine}
|
|
169
|
+
refining={refining}
|
|
170
|
+
/>
|
|
171
|
+
|
|
172
|
+
{/* Actions */}
|
|
173
|
+
<div className="pt-4 border-t border-border flex items-center justify-between">
|
|
174
|
+
<button
|
|
175
|
+
onClick={() => setShowChat(!showChat)}
|
|
176
|
+
className={`text-xs px-2.5 py-1 rounded-md transition-colors border ${
|
|
177
|
+
showChat
|
|
178
|
+
? 'bg-accent/20 text-accent border-accent/30'
|
|
179
|
+
: 'text-muted-foreground hover:text-foreground border-border hover:border-muted-foreground'
|
|
180
|
+
}`}
|
|
181
|
+
>
|
|
182
|
+
{showChat ? 'Hide AI Chat' : 'AI Chat'}
|
|
183
|
+
</button>
|
|
184
|
+
<button
|
|
185
|
+
onClick={onDelete}
|
|
186
|
+
className="text-xs text-muted-foreground hover:text-destructive transition-colors"
|
|
187
|
+
>
|
|
188
|
+
Delete task
|
|
189
|
+
</button>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
{/* Lower: AI Chat */}
|
|
195
|
+
{showChat && (
|
|
196
|
+
<div className="h-[45%] flex-shrink-0">
|
|
197
|
+
<TaskChat
|
|
198
|
+
basePath={basePath}
|
|
199
|
+
onApplyToPrompt={handleApplyToPrompt}
|
|
200
|
+
/>
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import type { ITask, TaskStatus } from '@/types';
|
|
5
|
+
import { statusIcon } from './StatusFlow';
|
|
6
|
+
|
|
7
|
+
const PRIORITY_COLORS: Record<string, string> = {
|
|
8
|
+
high: 'bg-destructive',
|
|
9
|
+
medium: 'bg-warning',
|
|
10
|
+
low: 'bg-muted-foreground',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default function TaskList({
|
|
14
|
+
tasks,
|
|
15
|
+
selectedTaskId,
|
|
16
|
+
onSelect,
|
|
17
|
+
onCreate,
|
|
18
|
+
onStatusChange,
|
|
19
|
+
onTodayToggle,
|
|
20
|
+
}: {
|
|
21
|
+
tasks: ITask[];
|
|
22
|
+
selectedTaskId: string | null;
|
|
23
|
+
onSelect: (taskId: string) => void;
|
|
24
|
+
onCreate: (title: string) => void;
|
|
25
|
+
onStatusChange: (taskId: string, status: TaskStatus) => void;
|
|
26
|
+
onTodayToggle: (taskId: string, isToday: boolean) => void;
|
|
27
|
+
}) {
|
|
28
|
+
const [newTitle, setNewTitle] = useState('');
|
|
29
|
+
const [adding, setAdding] = useState(false);
|
|
30
|
+
|
|
31
|
+
const handleAdd = () => {
|
|
32
|
+
const title = newTitle.trim();
|
|
33
|
+
if (!title) return;
|
|
34
|
+
onCreate(title);
|
|
35
|
+
setNewTitle('');
|
|
36
|
+
setAdding(false);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="flex flex-col h-full">
|
|
41
|
+
<div className="flex-1 overflow-y-auto">
|
|
42
|
+
{tasks.length === 0 && !adding && (
|
|
43
|
+
<div className="text-center py-8 text-muted-foreground text-xs">
|
|
44
|
+
No tasks yet
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
{tasks.map((task) => (
|
|
48
|
+
<div
|
|
49
|
+
key={task.id}
|
|
50
|
+
onClick={() => onSelect(task.id)}
|
|
51
|
+
className={`flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors text-sm border-l-2 ${
|
|
52
|
+
selectedTaskId === task.id
|
|
53
|
+
? 'bg-card-hover border-l-primary'
|
|
54
|
+
: 'border-l-transparent hover:bg-card-hover/50'
|
|
55
|
+
}`}
|
|
56
|
+
>
|
|
57
|
+
<button
|
|
58
|
+
onClick={(e) => {
|
|
59
|
+
e.stopPropagation();
|
|
60
|
+
const nextStatus = getNextStatus(task.status);
|
|
61
|
+
onStatusChange(task.id, nextStatus);
|
|
62
|
+
}}
|
|
63
|
+
className="flex-shrink-0 text-sm"
|
|
64
|
+
title={`Status: ${task.status}`}
|
|
65
|
+
>
|
|
66
|
+
{statusIcon(task.status)}
|
|
67
|
+
</button>
|
|
68
|
+
<span className={`tree-priority-dot ${PRIORITY_COLORS[task.priority]}`} />
|
|
69
|
+
<span className={`flex-1 truncate ${task.status === 'done' ? 'text-muted-foreground line-through' : ''}`}>
|
|
70
|
+
{task.title}
|
|
71
|
+
</span>
|
|
72
|
+
{task.is_today && (
|
|
73
|
+
<button
|
|
74
|
+
onClick={(e) => { e.stopPropagation(); onTodayToggle(task.id, false); }}
|
|
75
|
+
className="text-xs flex-shrink-0" title="Remove from today"
|
|
76
|
+
>
|
|
77
|
+
*
|
|
78
|
+
</button>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{adding ? (
|
|
85
|
+
<div className="p-2 border-t border-border">
|
|
86
|
+
<input
|
|
87
|
+
type="text"
|
|
88
|
+
value={newTitle}
|
|
89
|
+
onChange={(e) => setNewTitle(e.target.value)}
|
|
90
|
+
onKeyDown={(e) => {
|
|
91
|
+
if (e.key === 'Enter') handleAdd();
|
|
92
|
+
if (e.key === 'Escape') { setNewTitle(''); setAdding(false); }
|
|
93
|
+
}}
|
|
94
|
+
placeholder="Task title..."
|
|
95
|
+
className="w-full bg-input border border-border rounded px-2 py-1.5 text-sm
|
|
96
|
+
focus:border-primary focus:outline-none text-foreground"
|
|
97
|
+
autoFocus
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
100
|
+
) : (
|
|
101
|
+
<button
|
|
102
|
+
data-add-task
|
|
103
|
+
onClick={() => setAdding(true)}
|
|
104
|
+
className="p-2 text-xs text-muted-foreground hover:text-foreground
|
|
105
|
+
border-t border-border transition-colors text-left"
|
|
106
|
+
>
|
|
107
|
+
+ Add task <span className="text-muted-foreground/50 ml-1">T</span>
|
|
108
|
+
</button>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function getNextStatus(current: TaskStatus): TaskStatus {
|
|
115
|
+
const flow: TaskStatus[] = ['idea', 'writing', 'submitted', 'testing', 'done'];
|
|
116
|
+
const idx = flow.indexOf(current);
|
|
117
|
+
if (idx === -1) return 'idea';
|
|
118
|
+
return flow[(idx + 1) % flow.length];
|
|
119
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import StatusBadge from './StatusBadge';
|
|
5
|
+
|
|
6
|
+
interface IItemTree {
|
|
7
|
+
id: string;
|
|
8
|
+
title: string;
|
|
9
|
+
description: string;
|
|
10
|
+
item_type: string;
|
|
11
|
+
priority: string;
|
|
12
|
+
status: string;
|
|
13
|
+
is_locked: boolean;
|
|
14
|
+
is_pinned: boolean;
|
|
15
|
+
children: IItemTree[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface CardViewProps {
|
|
19
|
+
items: IItemTree[];
|
|
20
|
+
onItemUpdate: (itemId: string, data: Record<string, unknown>) => void;
|
|
21
|
+
onItemDelete: (itemId: string) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const typeConfig: Record<string, { icon: string; color: string }> = {
|
|
25
|
+
feature: { icon: '\u{1F4E6}', color: 'var(--primary)' },
|
|
26
|
+
task: { icon: '\u{2705}', color: 'var(--success)' },
|
|
27
|
+
bug: { icon: '\u{1F41B}', color: 'var(--destructive)' },
|
|
28
|
+
idea: { icon: '\u{1F4A1}', color: 'var(--warning)' },
|
|
29
|
+
note: { icon: '\u{1F4DD}', color: 'var(--muted-foreground)' },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function countAll(items: IItemTree[]): { total: number; done: number; inProgress: number; pending: number } {
|
|
33
|
+
let total = 0, done = 0, inProgress = 0, pending = 0;
|
|
34
|
+
for (const item of items) {
|
|
35
|
+
total++;
|
|
36
|
+
if (item.status === 'done') done++;
|
|
37
|
+
else if (item.status === 'in_progress') inProgress++;
|
|
38
|
+
else pending++;
|
|
39
|
+
const sub = countAll(item.children);
|
|
40
|
+
total += sub.total;
|
|
41
|
+
done += sub.done;
|
|
42
|
+
inProgress += sub.inProgress;
|
|
43
|
+
pending += sub.pending;
|
|
44
|
+
}
|
|
45
|
+
return { total, done, inProgress, pending };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function flattenChildren(item: IItemTree, maxDepth = 2, depth = 0): { item: IItemTree; depth: number }[] {
|
|
49
|
+
const result: { item: IItemTree; depth: number }[] = [];
|
|
50
|
+
for (const child of item.children) {
|
|
51
|
+
result.push({ item: child, depth });
|
|
52
|
+
if (child.children.length > 0 && depth < maxDepth - 1) {
|
|
53
|
+
result.push(...flattenChildren(child, maxDepth, depth + 1));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function ProjectCard({ item, onItemUpdate, onItemDelete }: {
|
|
60
|
+
item: IItemTree;
|
|
61
|
+
onItemUpdate: CardViewProps['onItemUpdate'];
|
|
62
|
+
onItemDelete: CardViewProps['onItemDelete'];
|
|
63
|
+
}) {
|
|
64
|
+
const [expanded, setExpanded] = useState(false);
|
|
65
|
+
const baseCfg = typeConfig[item.item_type] || typeConfig.note;
|
|
66
|
+
const isDone = item.status === 'done';
|
|
67
|
+
const cfg = isDone
|
|
68
|
+
? { icon: '\u{2705}', color: 'var(--success)' }
|
|
69
|
+
: item.status === 'in_progress'
|
|
70
|
+
? { icon: baseCfg.icon, color: 'var(--primary)' }
|
|
71
|
+
: baseCfg;
|
|
72
|
+
const stats = countAll(item.children);
|
|
73
|
+
const totalWithSelf = stats.total + 1;
|
|
74
|
+
const doneWithSelf = stats.done + (isDone ? 1 : 0);
|
|
75
|
+
const progressPct = totalWithSelf > 0 ? (doneWithSelf / totalWithSelf) * 100 : 0;
|
|
76
|
+
const flatChildren = flattenChildren(item);
|
|
77
|
+
const hasMore = flatChildren.length > 5;
|
|
78
|
+
const displayChildren = expanded ? flatChildren : flatChildren.slice(0, 5);
|
|
79
|
+
|
|
80
|
+
const progressColor = progressPct === 100 ? 'hsl(var(--success))'
|
|
81
|
+
: progressPct > 50 ? 'hsl(var(--primary))'
|
|
82
|
+
: 'hsl(var(--accent))';
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="project-card" style={{ borderTopColor: `hsl(${cfg.color})`, borderTopWidth: '3px' }}>
|
|
86
|
+
<div className="project-card-header group">
|
|
87
|
+
<span className="project-card-icon">{cfg.icon}</span>
|
|
88
|
+
<div className="flex-1 min-w-0">
|
|
89
|
+
<div className="project-card-title">{item.title}</div>
|
|
90
|
+
</div>
|
|
91
|
+
<button
|
|
92
|
+
onClick={() => onItemDelete(item.id)}
|
|
93
|
+
className="text-[10px] text-muted-foreground/40 hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity px-1"
|
|
94
|
+
title="삭제"
|
|
95
|
+
>
|
|
96
|
+
✕
|
|
97
|
+
</button>
|
|
98
|
+
<StatusBadge
|
|
99
|
+
status={item.status}
|
|
100
|
+
onStatusChange={(status) => onItemUpdate(item.id, { status })}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{item.description && (
|
|
105
|
+
<p className="project-card-desc">{item.description}</p>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{/* Progress bar */}
|
|
109
|
+
{stats.total > 0 && (
|
|
110
|
+
<div className="project-card-progress">
|
|
111
|
+
<div
|
|
112
|
+
className="project-card-progress-fill"
|
|
113
|
+
style={{ width: `${progressPct}%`, background: progressColor }}
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
|
|
118
|
+
{/* Stats */}
|
|
119
|
+
<div className="project-card-stats">
|
|
120
|
+
{stats.done > 0 && (
|
|
121
|
+
<span className="project-card-stat">
|
|
122
|
+
<span style={{ color: 'hsl(var(--success))' }}>●</span> {stats.done} 완료
|
|
123
|
+
</span>
|
|
124
|
+
)}
|
|
125
|
+
{stats.inProgress > 0 && (
|
|
126
|
+
<span className="project-card-stat">
|
|
127
|
+
<span style={{ color: 'hsl(var(--primary))' }}>●</span> {stats.inProgress} 진행
|
|
128
|
+
</span>
|
|
129
|
+
)}
|
|
130
|
+
{stats.pending > 0 && (
|
|
131
|
+
<span className="project-card-stat">
|
|
132
|
+
<span style={{ color: 'hsl(var(--muted-foreground))' }}>●</span> {stats.pending} 대기
|
|
133
|
+
</span>
|
|
134
|
+
)}
|
|
135
|
+
<span className="ml-auto">{stats.total}개 항목</span>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
{/* Children list */}
|
|
139
|
+
{displayChildren.length > 0 && (
|
|
140
|
+
<div className="project-card-children">
|
|
141
|
+
{displayChildren.map(({ item: child, depth }) => {
|
|
142
|
+
const childBaseCfg = typeConfig[child.item_type] || typeConfig.note;
|
|
143
|
+
const childIcon = child.status === 'done' ? '\u{2705}' : childBaseCfg.icon;
|
|
144
|
+
const isDone = child.status === 'done';
|
|
145
|
+
return (
|
|
146
|
+
<div
|
|
147
|
+
key={child.id}
|
|
148
|
+
className={`project-card-child group/child ${isDone ? 'project-card-child-done' : ''}`}
|
|
149
|
+
style={{ paddingLeft: `${14 + depth * 16}px` }}
|
|
150
|
+
>
|
|
151
|
+
<span className="text-[11px]">{childIcon}</span>
|
|
152
|
+
<span className="flex-1 truncate">{child.title}</span>
|
|
153
|
+
<button
|
|
154
|
+
onClick={() => onItemDelete(child.id)}
|
|
155
|
+
className="text-[10px] text-muted-foreground/30 hover:text-destructive opacity-0 group-hover/child:opacity-100 transition-opacity px-0.5"
|
|
156
|
+
>
|
|
157
|
+
✕
|
|
158
|
+
</button>
|
|
159
|
+
<span className="tree-priority-dot flex-shrink-0" style={{
|
|
160
|
+
background: child.priority === 'high' ? 'hsl(var(--destructive))'
|
|
161
|
+
: child.priority === 'medium' ? 'hsl(var(--warning))'
|
|
162
|
+
: 'hsl(var(--success))'
|
|
163
|
+
}} />
|
|
164
|
+
<StatusBadge
|
|
165
|
+
status={child.status}
|
|
166
|
+
onStatusChange={(status) => onItemUpdate(child.id, { status })}
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
})}
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{/* Expand toggle */}
|
|
175
|
+
{hasMore && (
|
|
176
|
+
<div className="project-card-expand" onClick={() => setExpanded(!expanded)}>
|
|
177
|
+
{expanded ? '접기' : `+${flatChildren.length - 5}개 더 보기`}
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export default function CardView({ items, onItemUpdate, onItemDelete }: CardViewProps) {
|
|
185
|
+
if (items.length === 0) {
|
|
186
|
+
return (
|
|
187
|
+
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">
|
|
188
|
+
<div className="text-4xl mb-3">🗂</div>
|
|
189
|
+
<p>아직 구조화된 항목이 없습니다</p>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div className="card-grid">
|
|
196
|
+
{items.map((item) => (
|
|
197
|
+
<ProjectCard
|
|
198
|
+
key={item.id}
|
|
199
|
+
item={item}
|
|
200
|
+
onItemUpdate={onItemUpdate}
|
|
201
|
+
onItemDelete={onItemDelete}
|
|
202
|
+
/>
|
|
203
|
+
))}
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|