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
|
@@ -13,7 +13,10 @@ interface Memo {
|
|
|
13
13
|
interface EditorProps {
|
|
14
14
|
projectId: string;
|
|
15
15
|
onContentChange: (content: string) => void;
|
|
16
|
+
onSendMessage: (message: string) => void;
|
|
16
17
|
memos?: Memo[];
|
|
18
|
+
chatLoading?: boolean;
|
|
19
|
+
onCollapse?: () => void;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
interface PinPosition {
|
|
@@ -22,7 +25,7 @@ interface PinPosition {
|
|
|
22
25
|
left: number;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
export default function Editor({ projectId, onContentChange, memos = [] }: EditorProps) {
|
|
28
|
+
export default function Editor({ projectId, onContentChange, onSendMessage, memos = [], chatLoading, onCollapse }: EditorProps) {
|
|
26
29
|
const [content, setContent] = useState('');
|
|
27
30
|
const [saving, setSaving] = useState(false);
|
|
28
31
|
const [loaded, setLoaded] = useState(false);
|
|
@@ -122,9 +125,20 @@ export default function Editor({ projectId, onContentChange, memos = [] }: Edito
|
|
|
122
125
|
<div className="flex flex-col h-full">
|
|
123
126
|
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
|
|
124
127
|
<h2 className="text-sm font-medium text-muted-foreground">브레인스토밍</h2>
|
|
125
|
-
<
|
|
126
|
-
|
|
127
|
-
|
|
128
|
+
<div className="flex items-center gap-2">
|
|
129
|
+
<span className="text-xs text-muted-foreground">
|
|
130
|
+
{saving ? '저장 중...' : content ? '저장됨' : ''}
|
|
131
|
+
</span>
|
|
132
|
+
{onCollapse && (
|
|
133
|
+
<button
|
|
134
|
+
onClick={onCollapse}
|
|
135
|
+
className="text-muted-foreground hover:text-foreground transition-colors text-xs px-1"
|
|
136
|
+
title="접기 (B)"
|
|
137
|
+
>
|
|
138
|
+
«
|
|
139
|
+
</button>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
128
142
|
</div>
|
|
129
143
|
<div className="editor-container">
|
|
130
144
|
<textarea
|
|
@@ -153,6 +167,8 @@ export default function Editor({ projectId, onContentChange, memos = [] }: Edito
|
|
|
153
167
|
anchorText={pin.memo.anchor_text}
|
|
154
168
|
top={pin.top}
|
|
155
169
|
left={pin.left}
|
|
170
|
+
loading={chatLoading}
|
|
171
|
+
onSendMessage={onSendMessage}
|
|
156
172
|
/>
|
|
157
173
|
))}
|
|
158
174
|
</div>
|
|
@@ -1,31 +1,117 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState } from 'react';
|
|
3
|
+
import { useState, useRef, useEffect } from 'react';
|
|
4
4
|
|
|
5
5
|
interface MemoPinProps {
|
|
6
6
|
question: string;
|
|
7
7
|
anchorText: string;
|
|
8
8
|
top: number;
|
|
9
9
|
left: number;
|
|
10
|
+
loading?: boolean;
|
|
11
|
+
onSendMessage?: (message: string) => void;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
|
-
export default function MemoPin({ question, anchorText, top, left }: MemoPinProps) {
|
|
14
|
+
export default function MemoPin({ question, anchorText, top, left, loading, onSendMessage }: MemoPinProps) {
|
|
13
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
|
+
};
|
|
14
58
|
|
|
15
59
|
return (
|
|
16
60
|
<div
|
|
61
|
+
ref={popoverRef}
|
|
17
62
|
className="memo-pin"
|
|
18
63
|
style={{ top: `${top}px`, left: `${left}px` }}
|
|
19
|
-
onMouseEnter={() => setShowTooltip(true)}
|
|
64
|
+
onMouseEnter={() => !open && setShowTooltip(true)}
|
|
20
65
|
onMouseLeave={() => setShowTooltip(false)}
|
|
21
66
|
>
|
|
22
|
-
<span className="memo-pin-icon">📌</span>
|
|
23
|
-
|
|
67
|
+
<span className="memo-pin-icon" onClick={handleClick}>📌</span>
|
|
68
|
+
|
|
69
|
+
{/* Hover tooltip (only when popover is closed) */}
|
|
70
|
+
{showTooltip && !open && (
|
|
24
71
|
<div className="memo-tooltip">
|
|
25
72
|
<div className="memo-tooltip-anchor">“{anchorText}”</div>
|
|
26
73
|
<div className="memo-tooltip-question">{question}</div>
|
|
27
74
|
</div>
|
|
28
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
|
+
)}
|
|
29
115
|
</div>
|
|
30
116
|
);
|
|
31
117
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ISubProjectWithStats, TaskStatus } from '@/types';
|
|
4
|
+
|
|
5
|
+
const STATUS_ICONS: Record<TaskStatus, string> = {
|
|
6
|
+
idea: '\u{1F4A1}',
|
|
7
|
+
writing: '\u{270F}\u{FE0F}',
|
|
8
|
+
submitted: '\u{1F680}',
|
|
9
|
+
testing: '\u{1F9EA}',
|
|
10
|
+
done: '\u{2705}',
|
|
11
|
+
problem: '\u{1F534}',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function timeAgo(dateStr: string | null): string {
|
|
15
|
+
if (!dateStr) return '';
|
|
16
|
+
const diff = Date.now() - new Date(dateStr).getTime();
|
|
17
|
+
const mins = Math.floor(diff / 60000);
|
|
18
|
+
if (mins < 1) return 'just now';
|
|
19
|
+
if (mins < 60) return `${mins}m ago`;
|
|
20
|
+
const hours = Math.floor(mins / 60);
|
|
21
|
+
if (hours < 24) return `${hours}h ago`;
|
|
22
|
+
const days = Math.floor(hours / 24);
|
|
23
|
+
return `${days}d ago`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function SubProjectCard({
|
|
27
|
+
subProject,
|
|
28
|
+
projectName,
|
|
29
|
+
onClick,
|
|
30
|
+
}: {
|
|
31
|
+
subProject: ISubProjectWithStats;
|
|
32
|
+
projectName: string;
|
|
33
|
+
onClick: () => void;
|
|
34
|
+
}) {
|
|
35
|
+
const { active_count, pending_count, done_count, problem_count, task_count, preview_tasks, last_activity } = subProject;
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
onClick={onClick}
|
|
40
|
+
className="p-4 bg-card hover:bg-card-hover border border-border rounded-xl
|
|
41
|
+
cursor-pointer transition-all group hover:border-muted-foreground/30
|
|
42
|
+
hover:shadow-md hover:shadow-black/20"
|
|
43
|
+
>
|
|
44
|
+
<div className="flex items-start justify-between mb-2">
|
|
45
|
+
<h3 className="text-sm font-semibold group-hover:text-primary transition-colors truncate flex-1">
|
|
46
|
+
{subProject.name}
|
|
47
|
+
</h3>
|
|
48
|
+
<span className="text-xs text-muted-foreground ml-2 flex-shrink-0">{projectName}</span>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
{preview_tasks.length > 0 && (
|
|
52
|
+
<div className="space-y-1 mb-3">
|
|
53
|
+
{preview_tasks.map((t, i) => (
|
|
54
|
+
<div key={i} className="flex items-center gap-2 text-xs">
|
|
55
|
+
<span className="flex-shrink-0">{STATUS_ICONS[t.status]}</span>
|
|
56
|
+
<span className={`truncate ${t.status === 'done' ? 'text-muted-foreground line-through' : 'text-foreground'}`}>
|
|
57
|
+
{t.title}
|
|
58
|
+
</span>
|
|
59
|
+
</div>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
)}
|
|
63
|
+
|
|
64
|
+
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
65
|
+
<div className="flex items-center gap-3">
|
|
66
|
+
{active_count > 0 && <span className="text-primary">active {active_count}</span>}
|
|
67
|
+
{pending_count > 0 && <span>pending {pending_count}</span>}
|
|
68
|
+
{done_count > 0 && <span className="text-success">done {done_count}</span>}
|
|
69
|
+
{problem_count > 0 && <span className="text-destructive">problem {problem_count}</span>}
|
|
70
|
+
{task_count === 0 && <span>no tasks</span>}
|
|
71
|
+
</div>
|
|
72
|
+
{last_activity && <span>{timeAgo(last_activity)}</span>}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export type DashboardTab = 'active' | 'all' | 'today';
|
|
6
|
+
|
|
7
|
+
const TABS: { key: DashboardTab; label: string }[] = [
|
|
8
|
+
{ key: 'active', label: 'Active' },
|
|
9
|
+
{ key: 'all', label: 'All' },
|
|
10
|
+
{ key: 'today', label: 'Today' },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export default function TabBar({
|
|
14
|
+
value,
|
|
15
|
+
onChange,
|
|
16
|
+
}: {
|
|
17
|
+
value: DashboardTab;
|
|
18
|
+
onChange: (tab: DashboardTab) => void;
|
|
19
|
+
}) {
|
|
20
|
+
const [mounted, setMounted] = useState(false);
|
|
21
|
+
useEffect(() => setMounted(true), []);
|
|
22
|
+
|
|
23
|
+
if (!mounted) return null;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="flex gap-1 bg-muted rounded-lg p-1">
|
|
27
|
+
{TABS.map((tab) => (
|
|
28
|
+
<button
|
|
29
|
+
key={tab.key}
|
|
30
|
+
onClick={() => onChange(tab.key)}
|
|
31
|
+
className={`px-4 py-1.5 text-sm rounded-md transition-all ${
|
|
32
|
+
value === tab.key
|
|
33
|
+
? 'bg-card text-foreground shadow-sm font-medium'
|
|
34
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
35
|
+
}`}
|
|
36
|
+
>
|
|
37
|
+
{tab.label}
|
|
38
|
+
</button>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import type { ITask, ISubProjectWithStats, 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 ProjectTree({
|
|
14
|
+
subProjects,
|
|
15
|
+
tasks,
|
|
16
|
+
selectedSubId,
|
|
17
|
+
selectedTaskId,
|
|
18
|
+
onSelectSub,
|
|
19
|
+
onSelectTask,
|
|
20
|
+
onCreateSub,
|
|
21
|
+
onDeleteSub,
|
|
22
|
+
onCreateTask,
|
|
23
|
+
onStatusChange,
|
|
24
|
+
onTodayToggle,
|
|
25
|
+
}: {
|
|
26
|
+
subProjects: ISubProjectWithStats[];
|
|
27
|
+
tasks: ITask[];
|
|
28
|
+
selectedSubId: string | null;
|
|
29
|
+
selectedTaskId: string | null;
|
|
30
|
+
onSelectSub: (subId: string) => void;
|
|
31
|
+
onSelectTask: (taskId: string) => void;
|
|
32
|
+
onCreateSub: () => void;
|
|
33
|
+
onDeleteSub: (subId: string) => void;
|
|
34
|
+
onCreateTask: (title: string) => void;
|
|
35
|
+
onStatusChange: (taskId: string, status: TaskStatus) => void;
|
|
36
|
+
onTodayToggle: (taskId: string, isToday: boolean) => void;
|
|
37
|
+
}) {
|
|
38
|
+
const [collapsedSubs, setCollapsedSubs] = useState<Set<string>>(new Set());
|
|
39
|
+
const [addingTaskFor, setAddingTaskFor] = useState<string | null>(null);
|
|
40
|
+
const [newTaskTitle, setNewTaskTitle] = useState('');
|
|
41
|
+
|
|
42
|
+
const toggleCollapse = (subId: string) => {
|
|
43
|
+
setCollapsedSubs(prev => {
|
|
44
|
+
const next = new Set(prev);
|
|
45
|
+
if (next.has(subId)) next.delete(subId);
|
|
46
|
+
else next.add(subId);
|
|
47
|
+
return next;
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const handleAddTask = (subId: string) => {
|
|
52
|
+
const title = newTaskTitle.trim();
|
|
53
|
+
if (!title) return;
|
|
54
|
+
onSelectSub(subId);
|
|
55
|
+
onCreateTask(title);
|
|
56
|
+
setNewTaskTitle('');
|
|
57
|
+
setAddingTaskFor(null);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="flex flex-col h-full">
|
|
62
|
+
<div className="flex items-center justify-between px-3 py-2 border-b border-border flex-shrink-0">
|
|
63
|
+
<h2 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Projects</h2>
|
|
64
|
+
<button
|
|
65
|
+
onClick={onCreateSub}
|
|
66
|
+
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
67
|
+
title="Add sub-project (N)"
|
|
68
|
+
>
|
|
69
|
+
+ <span className="text-muted-foreground/50">N</span>
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div className="flex-1 overflow-y-auto py-1">
|
|
74
|
+
{subProjects.length === 0 && (
|
|
75
|
+
<div className="text-center py-8 text-muted-foreground text-xs">
|
|
76
|
+
Create a sub-project to get started
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
{subProjects.map((sp) => {
|
|
81
|
+
const isSelected = selectedSubId === sp.id;
|
|
82
|
+
const isCollapsed = collapsedSubs.has(sp.id);
|
|
83
|
+
const subTasks = isSelected ? tasks : [];
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div key={sp.id} className="mb-0.5">
|
|
87
|
+
{/* Sub-project node */}
|
|
88
|
+
<div
|
|
89
|
+
onClick={() => {
|
|
90
|
+
onSelectSub(sp.id);
|
|
91
|
+
if (isCollapsed) toggleCollapse(sp.id);
|
|
92
|
+
}}
|
|
93
|
+
className={`flex items-center gap-1.5 px-2 py-1.5 cursor-pointer transition-colors group text-sm ${
|
|
94
|
+
isSelected
|
|
95
|
+
? 'text-foreground'
|
|
96
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
97
|
+
}`}
|
|
98
|
+
>
|
|
99
|
+
<button
|
|
100
|
+
onClick={(e) => { e.stopPropagation(); toggleCollapse(sp.id); }}
|
|
101
|
+
className="w-4 h-4 flex items-center justify-center text-xs text-muted-foreground flex-shrink-0"
|
|
102
|
+
>
|
|
103
|
+
{isCollapsed ? '\u25B6' : '\u25BC'}
|
|
104
|
+
</button>
|
|
105
|
+
<span className={`flex-1 truncate font-medium ${isSelected ? 'text-primary' : ''}`}>
|
|
106
|
+
{sp.name}
|
|
107
|
+
</span>
|
|
108
|
+
<div className="flex items-center gap-1.5">
|
|
109
|
+
{sp.task_count > 0 && (
|
|
110
|
+
<span className="text-xs text-muted-foreground tabular-nums">{sp.task_count}</span>
|
|
111
|
+
)}
|
|
112
|
+
<button
|
|
113
|
+
onClick={(e) => { e.stopPropagation(); onDeleteSub(sp.id); }}
|
|
114
|
+
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity text-xs"
|
|
115
|
+
>
|
|
116
|
+
x
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{/* Tasks (children) */}
|
|
122
|
+
{!isCollapsed && isSelected && (
|
|
123
|
+
<div className="ml-3 border-l border-border/50">
|
|
124
|
+
{subTasks.length === 0 && !addingTaskFor && (
|
|
125
|
+
<div className="text-xs text-muted-foreground py-2 pl-4">
|
|
126
|
+
No tasks
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
{subTasks.map((task) => (
|
|
130
|
+
<div
|
|
131
|
+
key={task.id}
|
|
132
|
+
onClick={() => onSelectTask(task.id)}
|
|
133
|
+
className={`flex items-center gap-1.5 pl-4 pr-2 py-1.5 cursor-pointer transition-colors text-sm border-l-2 ${
|
|
134
|
+
selectedTaskId === task.id
|
|
135
|
+
? 'bg-card-hover border-l-primary'
|
|
136
|
+
: 'border-l-transparent hover:bg-card-hover/50'
|
|
137
|
+
}`}
|
|
138
|
+
>
|
|
139
|
+
<button
|
|
140
|
+
onClick={(e) => {
|
|
141
|
+
e.stopPropagation();
|
|
142
|
+
const nextStatus = getNextStatus(task.status);
|
|
143
|
+
onStatusChange(task.id, nextStatus);
|
|
144
|
+
}}
|
|
145
|
+
className="flex-shrink-0 text-sm"
|
|
146
|
+
title={`Status: ${task.status}`}
|
|
147
|
+
>
|
|
148
|
+
{statusIcon(task.status)}
|
|
149
|
+
</button>
|
|
150
|
+
<span className={`tree-priority-dot ${PRIORITY_COLORS[task.priority]}`} />
|
|
151
|
+
<span className={`flex-1 truncate ${task.status === 'done' ? 'text-muted-foreground line-through' : ''}`}>
|
|
152
|
+
{task.title}
|
|
153
|
+
</span>
|
|
154
|
+
{task.is_today && (
|
|
155
|
+
<button
|
|
156
|
+
onClick={(e) => { e.stopPropagation(); onTodayToggle(task.id, false); }}
|
|
157
|
+
className="text-xs flex-shrink-0 text-primary" title="Remove from today"
|
|
158
|
+
>
|
|
159
|
+
*
|
|
160
|
+
</button>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
))}
|
|
164
|
+
|
|
165
|
+
{/* Add task input */}
|
|
166
|
+
{addingTaskFor === sp.id ? (
|
|
167
|
+
<div className="pl-4 pr-2 py-1">
|
|
168
|
+
<input
|
|
169
|
+
type="text"
|
|
170
|
+
value={newTaskTitle}
|
|
171
|
+
onChange={(e) => setNewTaskTitle(e.target.value)}
|
|
172
|
+
onKeyDown={(e) => {
|
|
173
|
+
if (e.key === 'Enter') handleAddTask(sp.id);
|
|
174
|
+
if (e.key === 'Escape') { setNewTaskTitle(''); setAddingTaskFor(null); }
|
|
175
|
+
}}
|
|
176
|
+
placeholder="Task title..."
|
|
177
|
+
className="w-full bg-input border border-border rounded px-2 py-1 text-sm
|
|
178
|
+
focus:border-primary focus:outline-none text-foreground"
|
|
179
|
+
autoFocus
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
) : (
|
|
183
|
+
<button
|
|
184
|
+
data-add-task
|
|
185
|
+
onClick={() => { onSelectSub(sp.id); setAddingTaskFor(sp.id); }}
|
|
186
|
+
className="pl-4 pr-2 py-1 text-xs text-muted-foreground hover:text-foreground
|
|
187
|
+
transition-colors text-left w-full"
|
|
188
|
+
>
|
|
189
|
+
+ Add task <span className="text-muted-foreground/50 ml-1">T</span>
|
|
190
|
+
</button>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{/* Show task previews for non-selected sub-projects */}
|
|
196
|
+
{!isCollapsed && !isSelected && sp.preview_tasks && sp.preview_tasks.length > 0 && (
|
|
197
|
+
<div className="ml-3 border-l border-border/50">
|
|
198
|
+
{sp.preview_tasks.map((pt, i) => (
|
|
199
|
+
<div
|
|
200
|
+
key={i}
|
|
201
|
+
onClick={() => onSelectSub(sp.id)}
|
|
202
|
+
className="flex items-center gap-1.5 pl-4 pr-2 py-1 cursor-pointer text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
203
|
+
>
|
|
204
|
+
<span className="flex-shrink-0">{statusIcon(pt.status)}</span>
|
|
205
|
+
<span className="truncate">{pt.title}</span>
|
|
206
|
+
</div>
|
|
207
|
+
))}
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
})}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function getNextStatus(current: TaskStatus): TaskStatus {
|
|
219
|
+
const flow: TaskStatus[] = ['idea', 'writing', 'submitted', 'testing', 'done'];
|
|
220
|
+
const idx = flow.indexOf(current);
|
|
221
|
+
if (idx === -1) return 'idea';
|
|
222
|
+
return flow[(idx + 1) % flow.length];
|
|
223
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
export default function PromptEditor({
|
|
6
|
+
content,
|
|
7
|
+
onSave,
|
|
8
|
+
onRefine,
|
|
9
|
+
refining,
|
|
10
|
+
}: {
|
|
11
|
+
content: string;
|
|
12
|
+
onSave: (content: string) => void;
|
|
13
|
+
onRefine?: () => void;
|
|
14
|
+
refining?: boolean;
|
|
15
|
+
}) {
|
|
16
|
+
const [editing, setEditing] = useState(false);
|
|
17
|
+
const [draft, setDraft] = useState(content);
|
|
18
|
+
const [copied, setCopied] = useState(false);
|
|
19
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
setDraft(content);
|
|
23
|
+
}, [content]);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (editing && textareaRef.current) {
|
|
27
|
+
textareaRef.current.focus();
|
|
28
|
+
textareaRef.current.style.height = 'auto';
|
|
29
|
+
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
|
|
30
|
+
}
|
|
31
|
+
}, [editing]);
|
|
32
|
+
|
|
33
|
+
const handleSave = () => {
|
|
34
|
+
onSave(draft);
|
|
35
|
+
setEditing(false);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const handleCopy = async () => {
|
|
39
|
+
if (!content) return;
|
|
40
|
+
await navigator.clipboard.writeText(content);
|
|
41
|
+
setCopied(true);
|
|
42
|
+
setTimeout(() => setCopied(false), 1500);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="flex flex-col gap-2">
|
|
47
|
+
<div className="flex items-center justify-between">
|
|
48
|
+
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Prompt</h4>
|
|
49
|
+
<div className="flex items-center gap-1.5">
|
|
50
|
+
{onRefine && (
|
|
51
|
+
<button
|
|
52
|
+
onClick={onRefine}
|
|
53
|
+
disabled={refining}
|
|
54
|
+
className="prompt-action-btn prompt-generate-btn"
|
|
55
|
+
>
|
|
56
|
+
{refining ? 'Refining...' : 'AI Refine'}
|
|
57
|
+
</button>
|
|
58
|
+
)}
|
|
59
|
+
{!editing && content && (
|
|
60
|
+
<button onClick={handleCopy} className="prompt-action-btn">
|
|
61
|
+
{copied ? 'Copied!' : 'Copy'}
|
|
62
|
+
</button>
|
|
63
|
+
)}
|
|
64
|
+
{!editing ? (
|
|
65
|
+
<button onClick={() => setEditing(true)} className="prompt-action-btn">
|
|
66
|
+
Edit
|
|
67
|
+
</button>
|
|
68
|
+
) : (
|
|
69
|
+
<>
|
|
70
|
+
<button onClick={() => { setDraft(content); setEditing(false); }} className="prompt-action-btn">
|
|
71
|
+
Cancel
|
|
72
|
+
</button>
|
|
73
|
+
<button onClick={handleSave} className="prompt-action-btn" style={{ color: 'hsl(var(--success))' }}>
|
|
74
|
+
Save
|
|
75
|
+
</button>
|
|
76
|
+
</>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{editing ? (
|
|
82
|
+
<textarea
|
|
83
|
+
ref={textareaRef}
|
|
84
|
+
value={draft}
|
|
85
|
+
onChange={(e) => {
|
|
86
|
+
setDraft(e.target.value);
|
|
87
|
+
e.target.style.height = 'auto';
|
|
88
|
+
e.target.style.height = e.target.scrollHeight + 'px';
|
|
89
|
+
}}
|
|
90
|
+
onKeyDown={(e) => {
|
|
91
|
+
if (e.key === 'Escape') { setDraft(content); setEditing(false); }
|
|
92
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') handleSave();
|
|
93
|
+
}}
|
|
94
|
+
className="prompt-edit-textarea"
|
|
95
|
+
rows={4}
|
|
96
|
+
placeholder="Write your prompt here..."
|
|
97
|
+
/>
|
|
98
|
+
) : content ? (
|
|
99
|
+
<div className="prompt-content text-sm">{content}</div>
|
|
100
|
+
) : (
|
|
101
|
+
<div className="text-sm text-muted-foreground italic py-6 text-center border border-dashed border-border rounded-lg">
|
|
102
|
+
No prompt yet. Click Edit to write one.
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { TaskStatus } from '@/types';
|
|
4
|
+
|
|
5
|
+
const STATUSES: { key: TaskStatus; label: string; icon: string; color: string }[] = [
|
|
6
|
+
{ key: 'idea', label: 'Idea', icon: '\u{1F4A1}', color: 'text-muted-foreground' },
|
|
7
|
+
{ key: 'writing', label: 'Writing', icon: '\u{270F}\u{FE0F}', color: 'text-warning' },
|
|
8
|
+
{ key: 'submitted', label: 'Submitted', icon: '\u{1F680}', color: 'text-primary' },
|
|
9
|
+
{ key: 'testing', label: 'Testing', icon: '\u{1F9EA}', color: 'text-accent' },
|
|
10
|
+
{ key: 'done', label: 'Done', icon: '\u{2705}', color: 'text-success' },
|
|
11
|
+
{ key: 'problem', label: 'Problem', icon: '\u{1F534}', color: 'text-destructive' },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export default function StatusFlow({
|
|
15
|
+
status,
|
|
16
|
+
onChange,
|
|
17
|
+
}: {
|
|
18
|
+
status: TaskStatus;
|
|
19
|
+
onChange: (status: TaskStatus) => void;
|
|
20
|
+
}) {
|
|
21
|
+
return (
|
|
22
|
+
<div className="flex items-center gap-1">
|
|
23
|
+
{STATUSES.map((s) => (
|
|
24
|
+
<button
|
|
25
|
+
key={s.key}
|
|
26
|
+
onClick={() => onChange(s.key)}
|
|
27
|
+
title={s.label}
|
|
28
|
+
className={`px-2 py-1 rounded text-base transition-all ${
|
|
29
|
+
status === s.key
|
|
30
|
+
? `${s.color} bg-muted scale-110`
|
|
31
|
+
: 'opacity-40 hover:opacity-80'
|
|
32
|
+
}`}
|
|
33
|
+
>
|
|
34
|
+
{s.icon}
|
|
35
|
+
</button>
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function statusIcon(status: TaskStatus): string {
|
|
42
|
+
return STATUSES.find(s => s.key === status)?.icon ?? '';
|
|
43
|
+
}
|