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.
- package/package.json +1 -1
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/manifest.json +27 -0
- package/public/sw.js +27 -0
- package/src/app/globals.css +58 -0
- package/src/app/layout.tsx +18 -1
- package/src/app/page.tsx +6 -341
- package/src/app/projects/[id]/page.tsx +9 -475
- package/src/components/dashboard/DashboardPanel.tsx +291 -0
- package/src/components/tabs/TabBar.tsx +46 -0
- package/src/components/tabs/TabContext.tsx +201 -0
- package/src/components/tabs/TabShell.tsx +35 -0
- package/src/components/workspace/WorkspacePanel.tsx +440 -0
|
@@ -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
|
+
×
|
|
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
|
+
}
|