idea-manager 0.6.1 → 0.7.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.
@@ -0,0 +1,291 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import { useTabContext } from '@/components/tabs/TabContext';
5
+ import DirectoryPicker from '@/components/DirectoryPicker';
6
+ import ConfirmDialog from '@/components/ui/ConfirmDialog';
7
+ import DashboardTabBar, { type DashboardTab } from '@/components/dashboard/TabBar';
8
+ import SubProjectCard from '@/components/dashboard/SubProjectCard';
9
+ import type { ISubProjectWithStats, ITask } from '@/types';
10
+
11
+ interface IProject {
12
+ id: string;
13
+ name: string;
14
+ description: string;
15
+ project_path: string | null;
16
+ created_at: string;
17
+ updated_at: string;
18
+ }
19
+
20
+ interface ProjectWithSubs extends IProject {
21
+ subProjects: ISubProjectWithStats[];
22
+ }
23
+
24
+ export default function DashboardPanel() {
25
+ const { state, openProject, closeTab } = useTabContext();
26
+ const isVisible = state.activeTabId === 'dashboard';
27
+
28
+ const [projects, setProjects] = useState<ProjectWithSubs[]>([]);
29
+ const [todayTasks, setTodayTasks] = useState<(ITask & { projectName: string; subProjectName: string })[]>([]);
30
+ const [showForm, setShowForm] = useState(false);
31
+ const [name, setName] = useState('');
32
+ const [description, setDescription] = useState('');
33
+ const [projectPath, setProjectPath] = useState('');
34
+ const [loading, setLoading] = useState(true);
35
+ const [showDirPicker, setShowDirPicker] = useState(false);
36
+ const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
37
+ const [tab, setTab] = useState<DashboardTab>(() => {
38
+ if (typeof window !== 'undefined') {
39
+ return (localStorage.getItem('im-dashboard-tab') as DashboardTab) || 'active';
40
+ }
41
+ return 'active';
42
+ });
43
+
44
+ const fetchData = useCallback(async () => {
45
+ const res = await fetch('/api/projects');
46
+ const projectList: IProject[] = await res.json();
47
+
48
+ const withSubs = await Promise.all(
49
+ projectList.map(async (p) => {
50
+ const subRes = await fetch(`/api/projects/${p.id}/sub-projects`);
51
+ const subProjects: ISubProjectWithStats[] = await subRes.json();
52
+ return { ...p, subProjects };
53
+ })
54
+ );
55
+
56
+ setProjects(withSubs);
57
+
58
+ // Gather today tasks
59
+ const allToday: (ITask & { projectName: string; subProjectName: string })[] = [];
60
+ for (const p of withSubs) {
61
+ for (const sp of p.subProjects) {
62
+ if (sp.task_count > 0) {
63
+ const tasksRes = await fetch(`/api/projects/${p.id}/sub-projects/${sp.id}/tasks`);
64
+ const tasks: ITask[] = await tasksRes.json();
65
+ for (const t of tasks) {
66
+ if (t.is_today) {
67
+ allToday.push({ ...t, projectName: p.name, subProjectName: sp.name });
68
+ }
69
+ }
70
+ }
71
+ }
72
+ }
73
+ setTodayTasks(allToday);
74
+ setLoading(false);
75
+ }, []);
76
+
77
+ useEffect(() => {
78
+ fetchData();
79
+ }, [fetchData]);
80
+
81
+ // Refresh when tab becomes visible
82
+ useEffect(() => {
83
+ if (isVisible && !loading) fetchData();
84
+ // eslint-disable-next-line react-hooks/exhaustive-deps
85
+ }, [isVisible]);
86
+
87
+ const handleTabChange = (newTab: DashboardTab) => {
88
+ setTab(newTab);
89
+ localStorage.setItem('im-dashboard-tab', newTab);
90
+ };
91
+
92
+ const handleCreate = async (e: React.FormEvent) => {
93
+ e.preventDefault();
94
+ if (!name.trim()) return;
95
+
96
+ const res = await fetch('/api/projects', {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json' },
99
+ body: JSON.stringify({ name: name.trim(), description: description.trim(), project_path: projectPath.trim() || undefined }),
100
+ });
101
+
102
+ if (res.ok) {
103
+ const project = await res.json();
104
+ setName('');
105
+ setDescription('');
106
+ setProjectPath('');
107
+ setShowForm(false);
108
+ openProject(project.id, project.name);
109
+ }
110
+ };
111
+
112
+ const handleDeleteClick = (id: string, e: React.MouseEvent) => {
113
+ e.stopPropagation();
114
+ setDeleteTarget(id);
115
+ };
116
+
117
+ const handleDeleteConfirm = async () => {
118
+ if (!deleteTarget) return;
119
+ await fetch(`/api/projects/${deleteTarget}`, { method: 'DELETE' });
120
+ closeTab(deleteTarget); // Close tab if open
121
+ setDeleteTarget(null);
122
+ fetchData();
123
+ };
124
+
125
+ const getVisibleCards = (): { sp: ISubProjectWithStats; projectName: string; projectId: string }[] => {
126
+ const cards: { sp: ISubProjectWithStats; projectName: string; projectId: string }[] = [];
127
+ for (const p of projects) {
128
+ for (const sp of p.subProjects) {
129
+ if (tab === 'active') {
130
+ if (sp.active_count > 0 || sp.problem_count > 0) {
131
+ cards.push({ sp, projectName: p.name, projectId: p.id });
132
+ }
133
+ } else if (tab === 'all') {
134
+ cards.push({ sp, projectName: p.name, projectId: p.id });
135
+ }
136
+ }
137
+ }
138
+ cards.sort((a, b) => (b.sp.active_count + b.sp.problem_count) - (a.sp.active_count + a.sp.problem_count));
139
+ return cards;
140
+ };
141
+
142
+ const STATUS_ICONS: Record<string, string> = {
143
+ idea: '\u{1F4A1}', writing: '\u{270F}\u{FE0F}', submitted: '\u{1F680}',
144
+ testing: '\u{1F9EA}', done: '\u{2705}', problem: '\u{1F534}',
145
+ };
146
+
147
+ return (
148
+ <div className="h-full overflow-y-auto p-8 max-w-5xl mx-auto">
149
+ <header className="flex items-center justify-between mb-6">
150
+ <div>
151
+ <h1 className="text-2xl font-bold tracking-tight">
152
+ IM <span className="text-muted-foreground font-normal text-sm ml-2">Idea Manager v2</span>
153
+ </h1>
154
+ </div>
155
+ <div className="flex items-center gap-3">
156
+ <DashboardTabBar value={tab} onChange={handleTabChange} />
157
+ <button
158
+ onClick={() => setShowForm(!showForm)}
159
+ className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg
160
+ transition-colors font-medium text-sm"
161
+ >
162
+ + Project
163
+ </button>
164
+ </div>
165
+ </header>
166
+
167
+ {showForm && (
168
+ <form onSubmit={handleCreate} className="mb-6 p-5 bg-card rounded-lg border border-border">
169
+ <input type="text" placeholder="Project name" value={name}
170
+ onChange={(e) => setName(e.target.value)}
171
+ className="w-full bg-input border border-border rounded-lg px-4 py-2.5 mb-3 focus:border-primary focus:outline-none text-foreground"
172
+ autoFocus />
173
+ <input type="text" placeholder="Description (optional)" value={description}
174
+ onChange={(e) => setDescription(e.target.value)}
175
+ className="w-full bg-input border border-border rounded-lg px-4 py-2.5 mb-3 focus:border-primary focus:outline-none text-foreground" />
176
+ <div className="mb-4">
177
+ <button type="button" onClick={() => setShowDirPicker(true)}
178
+ className="w-full bg-input border border-border rounded-lg px-4 py-2.5 text-left text-sm hover:border-primary transition-colors">
179
+ {projectPath ? <span className="font-mono text-foreground">{projectPath}</span>
180
+ : <span className="text-muted-foreground">Project folder (optional)</span>}
181
+ </button>
182
+ </div>
183
+ <div className="flex gap-2 justify-end">
184
+ <button type="button" onClick={() => setShowForm(false)}
185
+ className="px-4 py-2 text-muted-foreground hover:text-foreground transition-colors text-sm">Cancel</button>
186
+ <button type="submit"
187
+ className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg transition-colors text-sm">Create</button>
188
+ </div>
189
+ </form>
190
+ )}
191
+
192
+ {loading ? (
193
+ <div className="text-center text-muted-foreground py-20">Loading...</div>
194
+ ) : tab === 'today' ? (
195
+ todayTasks.length === 0 ? (
196
+ <div className="text-center py-20 text-muted-foreground">
197
+ <p className="text-lg mb-2">No tasks marked for today</p>
198
+ <p className="text-sm">Mark tasks with the Today button in task detail</p>
199
+ </div>
200
+ ) : (
201
+ <div className="space-y-2">
202
+ {todayTasks.map((task) => (
203
+ <div key={task.id}
204
+ onClick={() => openProject(task.project_id, task.projectName, task.sub_project_id, task.id)}
205
+ className="flex items-center gap-3 p-3 bg-card hover:bg-card-hover border border-border rounded-lg cursor-pointer transition-colors">
206
+ <span className="text-sm">{STATUS_ICONS[task.status]}</span>
207
+ <div className="flex-1 min-w-0">
208
+ <span className="text-sm font-medium">{task.title}</span>
209
+ <span className="text-xs text-muted-foreground ml-2">{task.projectName} / {task.subProjectName}</span>
210
+ </div>
211
+ </div>
212
+ ))}
213
+ </div>
214
+ )
215
+ ) : (
216
+ <>
217
+ {tab === 'all' ? (
218
+ projects.length === 0 ? (
219
+ <div className="text-center py-20">
220
+ <p className="text-muted-foreground text-lg mb-2">No projects yet</p>
221
+ <p className="text-muted-foreground text-sm">Click + Project to get started</p>
222
+ </div>
223
+ ) : (
224
+ <div className="space-y-6">
225
+ {projects.map((project) => (
226
+ <div key={project.id}>
227
+ <div className="flex items-center justify-between mb-3">
228
+ <div className="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors"
229
+ onClick={() => openProject(project.id, project.name)}>
230
+ <h2 className="text-sm font-semibold">{project.name}</h2>
231
+ {project.project_path && (
232
+ <span className="text-xs text-muted-foreground font-mono truncate max-w-48">{project.project_path}</span>
233
+ )}
234
+ </div>
235
+ <button onClick={(e) => handleDeleteClick(project.id, e)}
236
+ className="text-xs text-muted-foreground hover:text-destructive transition-colors">Delete</button>
237
+ </div>
238
+ {project.subProjects.length === 0 ? (
239
+ <div className="text-xs text-muted-foreground py-4 text-center border border-dashed border-border rounded-lg">
240
+ No sub-projects.{' '}
241
+ <span className="text-primary cursor-pointer hover:underline"
242
+ onClick={() => openProject(project.id, project.name)}>Open project</span>{' '}to add one.
243
+ </div>
244
+ ) : (
245
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
246
+ {project.subProjects.map((sp) => (
247
+ <SubProjectCard key={sp.id} subProject={sp} projectName={project.name}
248
+ onClick={() => openProject(project.id, project.name, sp.id)} />
249
+ ))}
250
+ </div>
251
+ )}
252
+ </div>
253
+ ))}
254
+ </div>
255
+ )
256
+ ) : (
257
+ (() => {
258
+ const cards = getVisibleCards();
259
+ return cards.length === 0 ? (
260
+ <div className="text-center py-20 text-muted-foreground">
261
+ <p className="text-lg mb-2">No active tasks</p>
262
+ <p className="text-sm">Submit tasks to see them here</p>
263
+ </div>
264
+ ) : (
265
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
266
+ {cards.map(({ sp, projectName, projectId }) => (
267
+ <SubProjectCard key={sp.id} subProject={sp} projectName={projectName}
268
+ onClick={() => {
269
+ const proj = projects.find(p => p.id === projectId);
270
+ openProject(projectId, proj?.name || projectName, sp.id);
271
+ }} />
272
+ ))}
273
+ </div>
274
+ );
275
+ })()
276
+ )}
277
+ </>
278
+ )}
279
+
280
+ {showDirPicker && (
281
+ <DirectoryPicker onSelect={(path) => { setProjectPath(path); setShowDirPicker(false); }}
282
+ onCancel={() => setShowDirPicker(false)} />
283
+ )}
284
+
285
+ <ConfirmDialog open={!!deleteTarget} title="Delete project?"
286
+ description="This will permanently delete the project and all its data."
287
+ confirmLabel="Delete" variant="danger"
288
+ onConfirm={handleDeleteConfirm} onCancel={() => setDeleteTarget(null)} />
289
+ </div>
290
+ );
291
+ }
@@ -0,0 +1,46 @@
1
+ 'use client';
2
+
3
+ import { useTabContext } from './TabContext';
4
+
5
+ export default function TabBar() {
6
+ const { state, setActiveTab, closeTab } = useTabContext();
7
+
8
+ return (
9
+ <div className="tab-bar">
10
+ {state.tabs.map((tab) => {
11
+ const isActive = state.activeTabId === tab.id;
12
+ const isDashboard = tab.type === 'dashboard';
13
+
14
+ return (
15
+ <div
16
+ key={tab.id}
17
+ onClick={() => setActiveTab(tab.id)}
18
+ onMouseDown={(e) => {
19
+ // Middle-click to close
20
+ if (e.button === 1 && !isDashboard) {
21
+ e.preventDefault();
22
+ closeTab(tab.id);
23
+ }
24
+ }}
25
+ className={`tab-item ${isActive ? 'tab-item-active' : ''}`}
26
+ >
27
+ <span className="truncate">
28
+ {isDashboard ? 'Dashboard' : tab.projectName || 'Project'}
29
+ </span>
30
+ {!isDashboard && (
31
+ <button
32
+ onClick={(e) => {
33
+ e.stopPropagation();
34
+ closeTab(tab.id);
35
+ }}
36
+ className="tab-close"
37
+ >
38
+ &times;
39
+ </button>
40
+ )}
41
+ </div>
42
+ );
43
+ })}
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,201 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext, useReducer, useEffect, useCallback, type ReactNode } from 'react';
4
+
5
+ export interface ITab {
6
+ id: string;
7
+ type: 'dashboard' | 'project';
8
+ projectId?: string;
9
+ projectName?: string;
10
+ initialSubId?: string;
11
+ initialTaskId?: string;
12
+ }
13
+
14
+ interface ITabState {
15
+ tabs: ITab[];
16
+ activeTabId: string;
17
+ }
18
+
19
+ type TabAction =
20
+ | { type: 'OPEN_PROJECT'; projectId: string; projectName: string; initialSubId?: string; initialTaskId?: string }
21
+ | { type: 'CLOSE_TAB'; tabId: string }
22
+ | { type: 'SET_ACTIVE'; tabId: string }
23
+ | { type: 'UPDATE_TAB_NAME'; tabId: string; name: string }
24
+ | { type: 'CONSUME_INITIAL'; tabId: string }
25
+ | { type: 'HYDRATE'; state: ITabState };
26
+
27
+ const DASHBOARD_TAB: ITab = { id: 'dashboard', type: 'dashboard' };
28
+
29
+ function ensureDashboard(tabs: ITab[]): ITab[] {
30
+ if (!tabs.some(t => t.id === 'dashboard')) {
31
+ return [DASHBOARD_TAB, ...tabs];
32
+ }
33
+ return tabs;
34
+ }
35
+
36
+ function tabReducer(state: ITabState, action: TabAction): ITabState {
37
+ switch (action.type) {
38
+ case 'OPEN_PROJECT': {
39
+ const existing = state.tabs.find(t => t.projectId === action.projectId);
40
+ if (existing) {
41
+ // Update initial selection if provided
42
+ const tabs = state.tabs.map(t =>
43
+ t.id === existing.id
44
+ ? { ...t, initialSubId: action.initialSubId, initialTaskId: action.initialTaskId }
45
+ : t
46
+ );
47
+ return { tabs, activeTabId: existing.id };
48
+ }
49
+ const newTab: ITab = {
50
+ id: action.projectId,
51
+ type: 'project',
52
+ projectId: action.projectId,
53
+ projectName: action.projectName,
54
+ initialSubId: action.initialSubId,
55
+ initialTaskId: action.initialTaskId,
56
+ };
57
+ return { tabs: [...state.tabs, newTab], activeTabId: newTab.id };
58
+ }
59
+ case 'CLOSE_TAB': {
60
+ if (action.tabId === 'dashboard') return state;
61
+ const idx = state.tabs.findIndex(t => t.id === action.tabId);
62
+ const tabs = state.tabs.filter(t => t.id !== action.tabId);
63
+ let activeTabId = state.activeTabId;
64
+ if (state.activeTabId === action.tabId) {
65
+ // Activate previous tab or dashboard
66
+ activeTabId = tabs[Math.max(0, idx - 1)]?.id || 'dashboard';
67
+ }
68
+ return { tabs: ensureDashboard(tabs), activeTabId };
69
+ }
70
+ case 'SET_ACTIVE':
71
+ return { ...state, activeTabId: action.tabId };
72
+ case 'UPDATE_TAB_NAME':
73
+ return {
74
+ ...state,
75
+ tabs: state.tabs.map(t =>
76
+ t.id === action.tabId ? { ...t, projectName: action.name } : t
77
+ ),
78
+ };
79
+ case 'CONSUME_INITIAL':
80
+ return {
81
+ ...state,
82
+ tabs: state.tabs.map(t =>
83
+ t.id === action.tabId ? { ...t, initialSubId: undefined, initialTaskId: undefined } : t
84
+ ),
85
+ };
86
+ case 'HYDRATE':
87
+ return { tabs: ensureDashboard(action.state.tabs), activeTabId: action.state.activeTabId };
88
+ default:
89
+ return state;
90
+ }
91
+ }
92
+
93
+ const STORAGE_KEY = 'im-tabs';
94
+
95
+ function loadState(): ITabState {
96
+ if (typeof window === 'undefined') return { tabs: [DASHBOARD_TAB], activeTabId: 'dashboard' };
97
+ try {
98
+ const raw = localStorage.getItem(STORAGE_KEY);
99
+ if (raw) {
100
+ const parsed = JSON.parse(raw) as ITabState;
101
+ return { tabs: ensureDashboard(parsed.tabs), activeTabId: parsed.activeTabId };
102
+ }
103
+ } catch { /* ignore */ }
104
+ return { tabs: [DASHBOARD_TAB], activeTabId: 'dashboard' };
105
+ }
106
+
107
+ interface TabContextValue {
108
+ state: ITabState;
109
+ openProject: (projectId: string, projectName: string, initialSubId?: string, initialTaskId?: string) => void;
110
+ closeTab: (tabId: string) => void;
111
+ setActiveTab: (tabId: string) => void;
112
+ updateTabName: (tabId: string, name: string) => void;
113
+ consumeInitial: (tabId: string) => void;
114
+ }
115
+
116
+ const TabCtx = createContext<TabContextValue | null>(null);
117
+
118
+ export function useTabContext() {
119
+ const ctx = useContext(TabCtx);
120
+ if (!ctx) throw new Error('useTabContext must be used within TabProvider');
121
+ return ctx;
122
+ }
123
+
124
+ export function TabProvider({ children }: { children: ReactNode }) {
125
+ const [state, dispatch] = useReducer(tabReducer, undefined, loadState);
126
+
127
+ // Persist to localStorage
128
+ useEffect(() => {
129
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
130
+ }, [state]);
131
+
132
+ // URL sync: update URL when active tab changes
133
+ useEffect(() => {
134
+ const activeTab = state.tabs.find(t => t.id === state.activeTabId);
135
+ if (!activeTab) return;
136
+ const path = activeTab.type === 'dashboard' ? '/' : `/projects/${activeTab.projectId}`;
137
+ if (window.location.pathname !== path) {
138
+ window.history.replaceState(null, '', path);
139
+ }
140
+ }, [state.activeTabId, state.tabs]);
141
+
142
+ // Handle initial URL on mount (e.g., direct bookmark access)
143
+ useEffect(() => {
144
+ const path = window.location.pathname;
145
+ const match = path.match(/^\/projects\/(.+)$/);
146
+ if (match) {
147
+ const projectId = match[1];
148
+ if (!state.tabs.some(t => t.projectId === projectId)) {
149
+ // Fetch project name and open tab
150
+ fetch(`/api/projects/${projectId}`)
151
+ .then(r => r.ok ? r.json() : null)
152
+ .then(data => {
153
+ if (data) {
154
+ dispatch({ type: 'OPEN_PROJECT', projectId, projectName: data.name });
155
+ }
156
+ });
157
+ } else {
158
+ dispatch({ type: 'SET_ACTIVE', tabId: projectId });
159
+ }
160
+ }
161
+ // Also check sessionStorage redirect
162
+ const redirectId = sessionStorage.getItem('im-open-project');
163
+ if (redirectId) {
164
+ sessionStorage.removeItem('im-open-project');
165
+ fetch(`/api/projects/${redirectId}`)
166
+ .then(r => r.ok ? r.json() : null)
167
+ .then(data => {
168
+ if (data) {
169
+ dispatch({ type: 'OPEN_PROJECT', projectId: redirectId, projectName: data.name });
170
+ }
171
+ });
172
+ }
173
+ // eslint-disable-next-line react-hooks/exhaustive-deps
174
+ }, []);
175
+
176
+ const openProject = useCallback((projectId: string, projectName: string, initialSubId?: string, initialTaskId?: string) => {
177
+ dispatch({ type: 'OPEN_PROJECT', projectId, projectName, initialSubId, initialTaskId });
178
+ }, []);
179
+
180
+ const closeTab = useCallback((tabId: string) => {
181
+ dispatch({ type: 'CLOSE_TAB', tabId });
182
+ }, []);
183
+
184
+ const setActiveTab = useCallback((tabId: string) => {
185
+ dispatch({ type: 'SET_ACTIVE', tabId });
186
+ }, []);
187
+
188
+ const updateTabName = useCallback((tabId: string, name: string) => {
189
+ dispatch({ type: 'UPDATE_TAB_NAME', tabId, name });
190
+ }, []);
191
+
192
+ const consumeInitial = useCallback((tabId: string) => {
193
+ dispatch({ type: 'CONSUME_INITIAL', tabId });
194
+ }, []);
195
+
196
+ return (
197
+ <TabCtx.Provider value={{ state, openProject, closeTab, setActiveTab, updateTabName, consumeInitial }}>
198
+ {children}
199
+ </TabCtx.Provider>
200
+ );
201
+ }
@@ -0,0 +1,35 @@
1
+ 'use client';
2
+
3
+ import { useTabContext } from './TabContext';
4
+ import TabBar from './TabBar';
5
+ import DashboardPanel from '@/components/dashboard/DashboardPanel';
6
+ import WorkspacePanel from '@/components/workspace/WorkspacePanel';
7
+
8
+ export default function TabShell() {
9
+ const { state } = useTabContext();
10
+
11
+ return (
12
+ <div className="h-screen flex flex-col">
13
+ <TabBar />
14
+ <div className="flex-1 min-h-0 relative">
15
+ {state.tabs.map((tab) => (
16
+ <div
17
+ key={tab.id}
18
+ className="absolute inset-0 flex flex-col"
19
+ style={{ display: tab.id === state.activeTabId ? 'flex' : 'none' }}
20
+ >
21
+ {tab.type === 'dashboard' ? (
22
+ <DashboardPanel />
23
+ ) : (
24
+ <WorkspacePanel
25
+ id={tab.projectId!}
26
+ initialSubId={tab.initialSubId}
27
+ initialTaskId={tab.initialTaskId}
28
+ />
29
+ )}
30
+ </div>
31
+ ))}
32
+ </div>
33
+ </div>
34
+ );
35
+ }