idea-manager 0.4.0 → 0.5.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/package.json +1 -1
- package/src/app/projects/[id]/page.tsx +0 -4
- package/src/components/brainstorm/Editor.tsx +1 -84
- package/src/lib/ai/client.ts +0 -123
- package/src/lib/db/schema.ts +0 -70
- package/src/types/index.ts +0 -90
- package/src/app/api/projects/[id]/cleanup/route.ts +0 -32
- package/src/app/api/projects/[id]/conversations/route.ts +0 -50
- package/src/app/api/projects/[id]/items/[itemId]/prompt/route.ts +0 -51
- package/src/app/api/projects/[id]/items/[itemId]/refine/route.ts +0 -36
- package/src/app/api/projects/[id]/items/[itemId]/route.ts +0 -95
- package/src/app/api/projects/[id]/items/route.ts +0 -67
- package/src/app/api/projects/[id]/memos/route.ts +0 -18
- package/src/app/api/projects/[id]/scan/route.ts +0 -73
- package/src/app/api/projects/[id]/scan/stream/route.ts +0 -112
- package/src/app/api/projects/[id]/structure/route.ts +0 -59
- package/src/app/api/projects/[id]/structure/stream/route.ts +0 -157
- package/src/components/ScanPanel.tsx +0 -743
- package/src/components/brainstorm/MemoPin.tsx +0 -117
- package/src/components/tree/CardView.tsx +0 -206
- package/src/components/tree/ItemDetail.tsx +0 -196
- package/src/components/tree/LockToggle.tsx +0 -23
- package/src/components/tree/RefinePopover.tsx +0 -157
- package/src/components/tree/StatusBadge.tsx +0 -32
- package/src/components/tree/TreeNode.tsx +0 -227
- package/src/components/tree/TreeView.tsx +0 -304
- package/src/lib/ai/chat-responder.ts +0 -71
- package/src/lib/ai/cleanup.ts +0 -87
- package/src/lib/ai/prompter.ts +0 -78
- package/src/lib/ai/refiner.ts +0 -128
- package/src/lib/ai/structurer.ts +0 -403
- package/src/lib/db/queries/context.ts +0 -76
- package/src/lib/db/queries/conversations.ts +0 -46
- package/src/lib/db/queries/items.ts +0 -268
- package/src/lib/db/queries/memos.ts +0 -66
- package/src/lib/db/queries/prompts.ts +0 -68
- package/src/lib/scanner.ts +0 -573
- package/src/lib/task-store.ts +0 -97
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState, useRef, useEffect } from 'react';
|
|
4
|
-
|
|
5
|
-
interface MemoPinProps {
|
|
6
|
-
question: string;
|
|
7
|
-
anchorText: string;
|
|
8
|
-
top: number;
|
|
9
|
-
left: number;
|
|
10
|
-
loading?: boolean;
|
|
11
|
-
onSendMessage?: (message: string) => void;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export default function MemoPin({ question, anchorText, top, left, loading, onSendMessage }: MemoPinProps) {
|
|
15
|
-
const [showTooltip, setShowTooltip] = useState(false);
|
|
16
|
-
const [open, setOpen] = useState(false);
|
|
17
|
-
const [input, setInput] = useState('');
|
|
18
|
-
const [replies, setReplies] = useState<{ role: 'user' | 'assistant'; text: string }[]>([]);
|
|
19
|
-
const popoverRef = useRef<HTMLDivElement>(null);
|
|
20
|
-
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
21
|
-
|
|
22
|
-
// Close popover on outside click
|
|
23
|
-
useEffect(() => {
|
|
24
|
-
if (!open) return;
|
|
25
|
-
const handler = (e: MouseEvent) => {
|
|
26
|
-
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
|
27
|
-
setOpen(false);
|
|
28
|
-
}
|
|
29
|
-
};
|
|
30
|
-
document.addEventListener('mousedown', handler);
|
|
31
|
-
return () => document.removeEventListener('mousedown', handler);
|
|
32
|
-
}, [open]);
|
|
33
|
-
|
|
34
|
-
// Focus input when popover opens
|
|
35
|
-
useEffect(() => {
|
|
36
|
-
if (open) inputRef.current?.focus();
|
|
37
|
-
}, [open]);
|
|
38
|
-
|
|
39
|
-
const handleClick = () => {
|
|
40
|
-
setOpen(!open);
|
|
41
|
-
setShowTooltip(false);
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const handleSubmit = () => {
|
|
45
|
-
const trimmed = input.trim();
|
|
46
|
-
if (!trimmed || loading) return;
|
|
47
|
-
setReplies(prev => [...prev, { role: 'user', text: trimmed }]);
|
|
48
|
-
setInput('');
|
|
49
|
-
onSendMessage?.(trimmed);
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
53
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
54
|
-
e.preventDefault();
|
|
55
|
-
handleSubmit();
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
return (
|
|
60
|
-
<div
|
|
61
|
-
ref={popoverRef}
|
|
62
|
-
className="memo-pin"
|
|
63
|
-
style={{ top: `${top}px`, left: `${left}px` }}
|
|
64
|
-
onMouseEnter={() => !open && setShowTooltip(true)}
|
|
65
|
-
onMouseLeave={() => setShowTooltip(false)}
|
|
66
|
-
>
|
|
67
|
-
<span className="memo-pin-icon" onClick={handleClick}>📌</span>
|
|
68
|
-
|
|
69
|
-
{/* Hover tooltip (only when popover is closed) */}
|
|
70
|
-
{showTooltip && !open && (
|
|
71
|
-
<div className="memo-tooltip">
|
|
72
|
-
<div className="memo-tooltip-anchor">“{anchorText}”</div>
|
|
73
|
-
<div className="memo-tooltip-question">{question}</div>
|
|
74
|
-
</div>
|
|
75
|
-
)}
|
|
76
|
-
|
|
77
|
-
{/* Click popover with inline chat */}
|
|
78
|
-
{open && (
|
|
79
|
-
<div className="memo-popover">
|
|
80
|
-
<div className="memo-popover-anchor">“{anchorText}”</div>
|
|
81
|
-
<div className="memo-popover-messages">
|
|
82
|
-
<div className="memo-popover-bubble memo-popover-bubble-ai">{question}</div>
|
|
83
|
-
{replies.map((r, i) => (
|
|
84
|
-
<div key={i} className={`memo-popover-bubble memo-popover-bubble-${r.role === 'user' ? 'user' : 'ai'}`}>
|
|
85
|
-
{r.text}
|
|
86
|
-
</div>
|
|
87
|
-
))}
|
|
88
|
-
{loading && (
|
|
89
|
-
<div className="memo-popover-bubble memo-popover-bubble-ai chat-loading">
|
|
90
|
-
<span className="dot" /><span className="dot" /><span className="dot" />
|
|
91
|
-
</div>
|
|
92
|
-
)}
|
|
93
|
-
</div>
|
|
94
|
-
<div className="memo-popover-input-area">
|
|
95
|
-
<textarea
|
|
96
|
-
ref={inputRef}
|
|
97
|
-
value={input}
|
|
98
|
-
onChange={(e) => setInput(e.target.value)}
|
|
99
|
-
onKeyDown={handleKeyDown}
|
|
100
|
-
placeholder="답변 입력..."
|
|
101
|
-
rows={1}
|
|
102
|
-
disabled={loading}
|
|
103
|
-
className="memo-popover-input"
|
|
104
|
-
/>
|
|
105
|
-
<button
|
|
106
|
-
onClick={handleSubmit}
|
|
107
|
-
disabled={!input.trim() || loading}
|
|
108
|
-
className="memo-popover-send"
|
|
109
|
-
>
|
|
110
|
-
전송
|
|
111
|
-
</button>
|
|
112
|
-
</div>
|
|
113
|
-
</div>
|
|
114
|
-
)}
|
|
115
|
-
</div>
|
|
116
|
-
);
|
|
117
|
-
}
|
|
@@ -1,206 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState, useEffect } from 'react';
|
|
4
|
-
|
|
5
|
-
interface ItemDetailProps {
|
|
6
|
-
itemId: string;
|
|
7
|
-
projectId: string;
|
|
8
|
-
title: string;
|
|
9
|
-
description: string;
|
|
10
|
-
itemType: string;
|
|
11
|
-
priority: string;
|
|
12
|
-
status: string;
|
|
13
|
-
isLocked: boolean;
|
|
14
|
-
depth: number;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
interface IPrompt {
|
|
18
|
-
id: string;
|
|
19
|
-
content: string;
|
|
20
|
-
prompt_type: string;
|
|
21
|
-
version: number;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const typeLabels: Record<string, string> = {
|
|
25
|
-
feature: '기능',
|
|
26
|
-
task: '작업',
|
|
27
|
-
bug: '버그',
|
|
28
|
-
idea: '아이디어',
|
|
29
|
-
note: '메모',
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const priorityLabels: Record<string, string> = {
|
|
33
|
-
high: '높음',
|
|
34
|
-
medium: '보통',
|
|
35
|
-
low: '낮음',
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const statusLabels: Record<string, string> = {
|
|
39
|
-
pending: '대기',
|
|
40
|
-
in_progress: '진행 중',
|
|
41
|
-
done: '완료',
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
export default function ItemDetail({
|
|
45
|
-
itemId,
|
|
46
|
-
projectId,
|
|
47
|
-
title,
|
|
48
|
-
description,
|
|
49
|
-
itemType,
|
|
50
|
-
priority,
|
|
51
|
-
status,
|
|
52
|
-
isLocked,
|
|
53
|
-
depth,
|
|
54
|
-
}: ItemDetailProps) {
|
|
55
|
-
const [prompt, setPrompt] = useState<IPrompt | null>(null);
|
|
56
|
-
const [loadingPrompt, setLoadingPrompt] = useState(false);
|
|
57
|
-
const [generating, setGenerating] = useState(false);
|
|
58
|
-
const [copied, setCopied] = useState(false);
|
|
59
|
-
const [editing, setEditing] = useState(false);
|
|
60
|
-
const [editContent, setEditContent] = useState('');
|
|
61
|
-
|
|
62
|
-
// Load existing prompt
|
|
63
|
-
useEffect(() => {
|
|
64
|
-
const loadPrompt = async () => {
|
|
65
|
-
setLoadingPrompt(true);
|
|
66
|
-
const res = await fetch(`/api/projects/${projectId}/items/${itemId}/prompt`);
|
|
67
|
-
if (res.ok) {
|
|
68
|
-
setPrompt(await res.json());
|
|
69
|
-
}
|
|
70
|
-
setLoadingPrompt(false);
|
|
71
|
-
};
|
|
72
|
-
loadPrompt();
|
|
73
|
-
}, [itemId, projectId]);
|
|
74
|
-
|
|
75
|
-
const handleGenerate = async () => {
|
|
76
|
-
setGenerating(true);
|
|
77
|
-
try {
|
|
78
|
-
const res = await fetch(`/api/projects/${projectId}/items/${itemId}/prompt`, {
|
|
79
|
-
method: 'POST',
|
|
80
|
-
headers: { 'Content-Type': 'application/json' },
|
|
81
|
-
body: JSON.stringify({}),
|
|
82
|
-
});
|
|
83
|
-
if (res.ok) {
|
|
84
|
-
setPrompt(await res.json());
|
|
85
|
-
}
|
|
86
|
-
} finally {
|
|
87
|
-
setGenerating(false);
|
|
88
|
-
}
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
const handleCopy = async () => {
|
|
92
|
-
if (!prompt) return;
|
|
93
|
-
await navigator.clipboard.writeText(prompt.content);
|
|
94
|
-
setCopied(true);
|
|
95
|
-
setTimeout(() => setCopied(false), 2000);
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
const handleSaveEdit = async () => {
|
|
99
|
-
if (!editContent.trim()) return;
|
|
100
|
-
setGenerating(true);
|
|
101
|
-
try {
|
|
102
|
-
const res = await fetch(`/api/projects/${projectId}/items/${itemId}/prompt`, {
|
|
103
|
-
method: 'POST',
|
|
104
|
-
headers: { 'Content-Type': 'application/json' },
|
|
105
|
-
body: JSON.stringify({ content: editContent }),
|
|
106
|
-
});
|
|
107
|
-
if (res.ok) {
|
|
108
|
-
setPrompt(await res.json());
|
|
109
|
-
setEditing(false);
|
|
110
|
-
}
|
|
111
|
-
} finally {
|
|
112
|
-
setGenerating(false);
|
|
113
|
-
}
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
return (
|
|
117
|
-
<div
|
|
118
|
-
className="item-detail"
|
|
119
|
-
style={{ marginLeft: `${depth * 20 + 8}px` }}
|
|
120
|
-
>
|
|
121
|
-
<p className="text-muted-foreground mb-2 text-xs">{description || '설명 없음'}</p>
|
|
122
|
-
|
|
123
|
-
<div className="flex gap-3 text-xs text-muted-foreground mb-3">
|
|
124
|
-
<span>유형: {typeLabels[itemType] || itemType}</span>
|
|
125
|
-
<span>우선순위: {priorityLabels[priority] || priority}</span>
|
|
126
|
-
<span>상태: {statusLabels[status] || status}</span>
|
|
127
|
-
<span>잠금: {isLocked ? '잠금' : '해제'}</span>
|
|
128
|
-
</div>
|
|
129
|
-
|
|
130
|
-
{/* Prompt section */}
|
|
131
|
-
<div className="prompt-section">
|
|
132
|
-
<div className="prompt-header">
|
|
133
|
-
<span className="text-xs font-medium text-muted-foreground">프롬프트</span>
|
|
134
|
-
<div className="flex gap-1">
|
|
135
|
-
{prompt && (
|
|
136
|
-
<>
|
|
137
|
-
<button onClick={handleCopy} className="prompt-action-btn" title="복사">
|
|
138
|
-
{copied ? '복사됨' : '복사'}
|
|
139
|
-
</button>
|
|
140
|
-
<button
|
|
141
|
-
onClick={() => { setEditing(!editing); setEditContent(prompt.content); }}
|
|
142
|
-
className="prompt-action-btn"
|
|
143
|
-
title="수정"
|
|
144
|
-
>
|
|
145
|
-
{editing ? '취소' : '수정'}
|
|
146
|
-
</button>
|
|
147
|
-
</>
|
|
148
|
-
)}
|
|
149
|
-
<button
|
|
150
|
-
onClick={handleGenerate}
|
|
151
|
-
disabled={generating}
|
|
152
|
-
className="prompt-action-btn prompt-generate-btn"
|
|
153
|
-
title={prompt ? '재생성' : '생성'}
|
|
154
|
-
>
|
|
155
|
-
{generating ? '생성 중...' : prompt ? '재생성' : '생성'}
|
|
156
|
-
</button>
|
|
157
|
-
</div>
|
|
158
|
-
</div>
|
|
159
|
-
|
|
160
|
-
{loadingPrompt ? (
|
|
161
|
-
<p className="text-xs text-muted-foreground">로딩 중...</p>
|
|
162
|
-
) : editing ? (
|
|
163
|
-
<div className="prompt-edit">
|
|
164
|
-
<textarea
|
|
165
|
-
value={editContent}
|
|
166
|
-
onChange={(e) => setEditContent(e.target.value)}
|
|
167
|
-
className="prompt-edit-textarea"
|
|
168
|
-
rows={4}
|
|
169
|
-
/>
|
|
170
|
-
<button
|
|
171
|
-
onClick={handleSaveEdit}
|
|
172
|
-
disabled={generating}
|
|
173
|
-
className="prompt-action-btn prompt-generate-btn mt-1"
|
|
174
|
-
>
|
|
175
|
-
저장
|
|
176
|
-
</button>
|
|
177
|
-
</div>
|
|
178
|
-
) : prompt ? (
|
|
179
|
-
<div className="prompt-content">
|
|
180
|
-
{prompt.content}
|
|
181
|
-
</div>
|
|
182
|
-
) : (
|
|
183
|
-
<p className="text-xs text-muted-foreground/60">
|
|
184
|
-
아직 프롬프트가 없습니다. "생성" 버튼을 클릭하세요.
|
|
185
|
-
</p>
|
|
186
|
-
)}
|
|
187
|
-
|
|
188
|
-
{prompt && (
|
|
189
|
-
<div className="text-[10px] text-muted-foreground/40 mt-1">
|
|
190
|
-
v{prompt.version} · {prompt.prompt_type === 'manual' ? '수동' : '자동'}
|
|
191
|
-
</div>
|
|
192
|
-
)}
|
|
193
|
-
</div>
|
|
194
|
-
</div>
|
|
195
|
-
);
|
|
196
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
interface LockToggleProps {
|
|
4
|
-
isLocked: boolean;
|
|
5
|
-
onToggle: (locked: boolean) => void;
|
|
6
|
-
disabled?: boolean;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export default function LockToggle({ isLocked, onToggle, disabled }: LockToggleProps) {
|
|
10
|
-
return (
|
|
11
|
-
<button
|
|
12
|
-
onClick={(e) => {
|
|
13
|
-
e.stopPropagation();
|
|
14
|
-
onToggle(!isLocked);
|
|
15
|
-
}}
|
|
16
|
-
disabled={disabled}
|
|
17
|
-
className="lock-toggle"
|
|
18
|
-
title={isLocked ? '잠금 해제' : '잠금'}
|
|
19
|
-
>
|
|
20
|
-
{isLocked ? '\u{1F510}' : '\u{1F513}'}
|
|
21
|
-
</button>
|
|
22
|
-
);
|
|
23
|
-
}
|
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState, useRef, useEffect } from 'react';
|
|
4
|
-
|
|
5
|
-
interface IItemTree {
|
|
6
|
-
id: string;
|
|
7
|
-
title: string;
|
|
8
|
-
description: string;
|
|
9
|
-
item_type: string;
|
|
10
|
-
priority: string;
|
|
11
|
-
status: string;
|
|
12
|
-
is_locked: boolean;
|
|
13
|
-
is_pinned: boolean;
|
|
14
|
-
children: IItemTree[];
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
interface RefinePopoverProps {
|
|
18
|
-
itemId: string;
|
|
19
|
-
projectId: string;
|
|
20
|
-
title: string;
|
|
21
|
-
description: string;
|
|
22
|
-
onClose: () => void;
|
|
23
|
-
onItemUpdate: (itemId: string, data: Record<string, unknown>) => void;
|
|
24
|
-
onTreeRefresh: (tree: IItemTree[]) => void;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export default function RefinePopover({
|
|
28
|
-
itemId,
|
|
29
|
-
projectId,
|
|
30
|
-
title,
|
|
31
|
-
description,
|
|
32
|
-
onClose,
|
|
33
|
-
onItemUpdate,
|
|
34
|
-
onTreeRefresh,
|
|
35
|
-
}: RefinePopoverProps) {
|
|
36
|
-
const [input, setInput] = useState('');
|
|
37
|
-
const [loading, setLoading] = useState(false);
|
|
38
|
-
const [messages, setMessages] = useState<{ role: 'user' | 'assistant'; text: string }[]>([]);
|
|
39
|
-
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
40
|
-
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
41
|
-
|
|
42
|
-
useEffect(() => {
|
|
43
|
-
inputRef.current?.focus();
|
|
44
|
-
}, []);
|
|
45
|
-
|
|
46
|
-
useEffect(() => {
|
|
47
|
-
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
48
|
-
}, [messages]);
|
|
49
|
-
|
|
50
|
-
const handleSubmit = async () => {
|
|
51
|
-
const trimmed = input.trim();
|
|
52
|
-
if (!trimmed || loading) return;
|
|
53
|
-
|
|
54
|
-
setMessages(prev => [...prev, { role: 'user', text: trimmed }]);
|
|
55
|
-
setInput('');
|
|
56
|
-
setLoading(true);
|
|
57
|
-
|
|
58
|
-
try {
|
|
59
|
-
const res = await fetch(`/api/projects/${projectId}/items/${itemId}/refine`, {
|
|
60
|
-
method: 'POST',
|
|
61
|
-
headers: { 'Content-Type': 'application/json' },
|
|
62
|
-
body: JSON.stringify({ message: trimmed }),
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
if (res.ok) {
|
|
66
|
-
const data = await res.json();
|
|
67
|
-
setMessages(prev => [
|
|
68
|
-
...prev,
|
|
69
|
-
{ role: 'assistant', text: `"${data.title}"\n${data.description}` },
|
|
70
|
-
]);
|
|
71
|
-
// Refresh the entire tree if structural changes were made
|
|
72
|
-
if (data.tree) {
|
|
73
|
-
onTreeRefresh(data.tree);
|
|
74
|
-
} else {
|
|
75
|
-
onItemUpdate(itemId, { title: data.title, description: data.description });
|
|
76
|
-
}
|
|
77
|
-
} else {
|
|
78
|
-
const data = await res.json();
|
|
79
|
-
setMessages(prev => [
|
|
80
|
-
...prev,
|
|
81
|
-
{ role: 'assistant', text: `오류: ${data.error || '다듬기에 실패했습니다'}` },
|
|
82
|
-
]);
|
|
83
|
-
}
|
|
84
|
-
} catch {
|
|
85
|
-
setMessages(prev => [
|
|
86
|
-
...prev,
|
|
87
|
-
{ role: 'assistant', text: '오류: AI 연결에 실패했습니다' },
|
|
88
|
-
]);
|
|
89
|
-
} finally {
|
|
90
|
-
setLoading(false);
|
|
91
|
-
}
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
95
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
96
|
-
e.preventDefault();
|
|
97
|
-
handleSubmit();
|
|
98
|
-
}
|
|
99
|
-
if (e.key === 'Escape') {
|
|
100
|
-
onClose();
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
return (
|
|
105
|
-
<div className="refine-popover">
|
|
106
|
-
<div className="refine-header">
|
|
107
|
-
<span className="text-xs font-medium">다듬기: {title}</span>
|
|
108
|
-
<button onClick={onClose} className="refine-close">×</button>
|
|
109
|
-
</div>
|
|
110
|
-
|
|
111
|
-
{description && (
|
|
112
|
-
<div className="refine-context">{description}</div>
|
|
113
|
-
)}
|
|
114
|
-
|
|
115
|
-
<div className="refine-messages">
|
|
116
|
-
{messages.length === 0 && (
|
|
117
|
-
<div className="text-xs text-muted-foreground text-center py-2">
|
|
118
|
-
이 항목을 어떻게 다듬을지 알려주세요
|
|
119
|
-
</div>
|
|
120
|
-
)}
|
|
121
|
-
{messages.map((m, i) => (
|
|
122
|
-
<div key={i} className={`memo-popover-bubble memo-popover-bubble-${m.role === 'user' ? 'user' : 'ai'}`}>
|
|
123
|
-
{m.text.split('\n').map((line, j) => (
|
|
124
|
-
<p key={j} className={j > 0 ? 'mt-1' : ''}>{line}</p>
|
|
125
|
-
))}
|
|
126
|
-
</div>
|
|
127
|
-
))}
|
|
128
|
-
{loading && (
|
|
129
|
-
<div className="memo-popover-bubble memo-popover-bubble-ai chat-loading">
|
|
130
|
-
<span className="dot" /><span className="dot" /><span className="dot" />
|
|
131
|
-
</div>
|
|
132
|
-
)}
|
|
133
|
-
<div ref={messagesEndRef} />
|
|
134
|
-
</div>
|
|
135
|
-
|
|
136
|
-
<div className="memo-popover-input-area">
|
|
137
|
-
<textarea
|
|
138
|
-
ref={inputRef}
|
|
139
|
-
value={input}
|
|
140
|
-
onChange={(e) => setInput(e.target.value)}
|
|
141
|
-
onKeyDown={handleKeyDown}
|
|
142
|
-
placeholder="예: 하위 항목을 상세하게 추가해줘, 범위를 줄여줘..."
|
|
143
|
-
rows={1}
|
|
144
|
-
disabled={loading}
|
|
145
|
-
className="memo-popover-input"
|
|
146
|
-
/>
|
|
147
|
-
<button
|
|
148
|
-
onClick={handleSubmit}
|
|
149
|
-
disabled={!input.trim() || loading}
|
|
150
|
-
className="memo-popover-send"
|
|
151
|
-
>
|
|
152
|
-
전송
|
|
153
|
-
</button>
|
|
154
|
-
</div>
|
|
155
|
-
</div>
|
|
156
|
-
);
|
|
157
|
-
}
|