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.
Files changed (63) hide show
  1. package/README.md +19 -10
  2. package/next.config.ts +0 -1
  3. package/package.json +2 -2
  4. package/public/favicon.svg +10 -0
  5. package/public/icon.svg +2 -11
  6. package/src/app/api/filesystem/route.ts +49 -0
  7. package/src/app/api/projects/[id]/cleanup/route.ts +32 -0
  8. package/src/app/api/projects/[id]/items/[itemId]/refine/route.ts +36 -0
  9. package/src/app/api/projects/[id]/items/[itemId]/route.ts +23 -1
  10. package/src/app/api/projects/[id]/items/route.ts +51 -1
  11. package/src/app/api/projects/[id]/scan/route.ts +73 -0
  12. package/src/app/api/projects/[id]/scan/stream/route.ts +112 -0
  13. package/src/app/api/projects/[id]/structure/route.ts +34 -3
  14. package/src/app/api/projects/[id]/structure/stream/route.ts +157 -0
  15. package/src/app/api/projects/[id]/sub-projects/[subId]/route.ts +39 -0
  16. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route.ts +60 -0
  17. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route.ts +26 -0
  18. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route.ts +39 -0
  19. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/route.ts +33 -0
  20. package/src/app/api/projects/[id]/sub-projects/route.ts +31 -0
  21. package/src/app/api/projects/route.ts +1 -1
  22. package/src/app/globals.css +465 -5
  23. package/src/app/layout.tsx +3 -0
  24. package/src/app/page.tsx +260 -88
  25. package/src/app/projects/[id]/page.tsx +366 -183
  26. package/src/cli.ts +44 -12
  27. package/src/components/DirectoryPicker.tsx +137 -0
  28. package/src/components/ScanPanel.tsx +743 -0
  29. package/src/components/brainstorm/Editor.tsx +20 -4
  30. package/src/components/brainstorm/MemoPin.tsx +91 -5
  31. package/src/components/dashboard/SubProjectCard.tsx +76 -0
  32. package/src/components/dashboard/TabBar.tsx +42 -0
  33. package/src/components/task/ProjectTree.tsx +223 -0
  34. package/src/components/task/PromptEditor.tsx +107 -0
  35. package/src/components/task/StatusFlow.tsx +43 -0
  36. package/src/components/task/TaskChat.tsx +134 -0
  37. package/src/components/task/TaskDetail.tsx +205 -0
  38. package/src/components/task/TaskList.tsx +119 -0
  39. package/src/components/tree/CardView.tsx +206 -0
  40. package/src/components/tree/RefinePopover.tsx +157 -0
  41. package/src/components/tree/TreeNode.tsx +147 -38
  42. package/src/components/tree/TreeView.tsx +270 -26
  43. package/src/components/ui/ConfirmDialog.tsx +88 -0
  44. package/src/lib/ai/chat-responder.ts +4 -2
  45. package/src/lib/ai/cleanup.ts +87 -0
  46. package/src/lib/ai/client.ts +175 -58
  47. package/src/lib/ai/prompter.ts +19 -24
  48. package/src/lib/ai/refiner.ts +128 -0
  49. package/src/lib/ai/structurer.ts +340 -11
  50. package/src/lib/db/queries/context.ts +76 -0
  51. package/src/lib/db/queries/items.ts +133 -12
  52. package/src/lib/db/queries/projects.ts +12 -8
  53. package/src/lib/db/queries/sub-projects.ts +122 -0
  54. package/src/lib/db/queries/task-conversations.ts +27 -0
  55. package/src/lib/db/queries/task-prompts.ts +32 -0
  56. package/src/lib/db/queries/tasks.ts +133 -0
  57. package/src/lib/db/schema.ts +75 -0
  58. package/src/lib/mcp/server.ts +38 -39
  59. package/src/lib/mcp/tools.ts +47 -45
  60. package/src/lib/scanner.ts +573 -0
  61. package/src/lib/task-store.ts +97 -0
  62. package/src/types/index.ts +65 -0
  63. 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">&#x1F5C2;</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
+ }