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
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "IM - 아이디어 매니저",
|
|
3
|
+
"short_name": "IM",
|
|
4
|
+
"description": "아이디어에서 실행 가능한 프롬프트까지, 멀티 프로젝트 워크플로우 매니저",
|
|
5
|
+
"start_url": "/",
|
|
6
|
+
"display": "standalone",
|
|
7
|
+
"background_color": "#0f1117",
|
|
8
|
+
"theme_color": "#6366f1",
|
|
9
|
+
"icons": [
|
|
10
|
+
{
|
|
11
|
+
"src": "/icon-192.png",
|
|
12
|
+
"sizes": "192x192",
|
|
13
|
+
"type": "image/png"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"src": "/icon-512.png",
|
|
17
|
+
"sizes": "512x512",
|
|
18
|
+
"type": "image/png"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"src": "/icon-512.png",
|
|
22
|
+
"sizes": "512x512",
|
|
23
|
+
"type": "image/png",
|
|
24
|
+
"purpose": "maskable"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
package/public/sw.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const CACHE_NAME = 'im-v1';
|
|
2
|
+
|
|
3
|
+
self.addEventListener('install', (event) => {
|
|
4
|
+
self.skipWaiting();
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
self.addEventListener('activate', (event) => {
|
|
8
|
+
event.waitUntil(clients.claim());
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
self.addEventListener('fetch', (event) => {
|
|
12
|
+
// Network-first strategy: always try network, fall back to cache
|
|
13
|
+
event.respondWith(
|
|
14
|
+
fetch(event.request)
|
|
15
|
+
.then((response) => {
|
|
16
|
+
// Cache successful GET responses
|
|
17
|
+
if (event.request.method === 'GET' && response.status === 200) {
|
|
18
|
+
const clone = response.clone();
|
|
19
|
+
caches.open(CACHE_NAME).then((cache) => {
|
|
20
|
+
cache.put(event.request, clone);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return response;
|
|
24
|
+
})
|
|
25
|
+
.catch(() => caches.match(event.request))
|
|
26
|
+
);
|
|
27
|
+
});
|
package/src/app/globals.css
CHANGED
|
@@ -854,6 +854,64 @@ textarea:focus {
|
|
|
854
854
|
animation: progressIndeterminate 1.5s ease-in-out infinite;
|
|
855
855
|
}
|
|
856
856
|
|
|
857
|
+
/* Tab bar */
|
|
858
|
+
.tab-bar {
|
|
859
|
+
display: flex;
|
|
860
|
+
align-items: center;
|
|
861
|
+
gap: 0;
|
|
862
|
+
background: hsl(var(--background));
|
|
863
|
+
border-bottom: 1px solid hsl(var(--border));
|
|
864
|
+
padding: 0 4px;
|
|
865
|
+
height: 36px;
|
|
866
|
+
flex-shrink: 0;
|
|
867
|
+
overflow-x: auto;
|
|
868
|
+
-webkit-overflow-scrolling: touch;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
.tab-bar::-webkit-scrollbar { height: 0; }
|
|
872
|
+
|
|
873
|
+
.tab-item {
|
|
874
|
+
display: flex;
|
|
875
|
+
align-items: center;
|
|
876
|
+
gap: 6px;
|
|
877
|
+
padding: 6px 12px;
|
|
878
|
+
font-size: 13px;
|
|
879
|
+
color: hsl(var(--muted-foreground));
|
|
880
|
+
cursor: pointer;
|
|
881
|
+
border-bottom: 2px solid transparent;
|
|
882
|
+
transition: all 0.15s;
|
|
883
|
+
white-space: nowrap;
|
|
884
|
+
flex-shrink: 0;
|
|
885
|
+
max-width: 180px;
|
|
886
|
+
user-select: none;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
.tab-item:hover {
|
|
890
|
+
color: hsl(var(--foreground));
|
|
891
|
+
background: hsl(var(--muted));
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
.tab-item-active {
|
|
895
|
+
color: hsl(var(--foreground));
|
|
896
|
+
border-bottom-color: hsl(var(--primary));
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
.tab-close {
|
|
900
|
+
font-size: 15px;
|
|
901
|
+
line-height: 1;
|
|
902
|
+
color: hsl(var(--muted-foreground));
|
|
903
|
+
opacity: 0;
|
|
904
|
+
transition: opacity 0.1s, color 0.1s;
|
|
905
|
+
padding: 0 2px;
|
|
906
|
+
border-radius: 3px;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
.tab-item:hover .tab-close { opacity: 1; }
|
|
910
|
+
.tab-close:hover {
|
|
911
|
+
color: hsl(var(--destructive));
|
|
912
|
+
background: hsl(var(--destructive) / 0.15);
|
|
913
|
+
}
|
|
914
|
+
|
|
857
915
|
/* Panel resize handle (vertical) */
|
|
858
916
|
.panel-resize-handle {
|
|
859
917
|
flex-shrink: 0;
|
package/src/app/layout.tsx
CHANGED
|
@@ -14,9 +14,17 @@ const geistMono = Geist_Mono({
|
|
|
14
14
|
|
|
15
15
|
export const metadata: Metadata = {
|
|
16
16
|
title: "IM - 아이디어 매니저",
|
|
17
|
-
description: "
|
|
17
|
+
description: "아이디어에서 실행 가능한 프롬프트까지, 멀티 프로젝트 워크플로우 매니저",
|
|
18
18
|
icons: {
|
|
19
19
|
icon: '/favicon.svg',
|
|
20
|
+
apple: '/icon-192.png',
|
|
21
|
+
},
|
|
22
|
+
manifest: '/manifest.json',
|
|
23
|
+
themeColor: '#6366f1',
|
|
24
|
+
appleWebApp: {
|
|
25
|
+
capable: true,
|
|
26
|
+
statusBarStyle: 'black-translucent',
|
|
27
|
+
title: 'IM',
|
|
20
28
|
},
|
|
21
29
|
};
|
|
22
30
|
|
|
@@ -39,6 +47,15 @@ export default function RootLayout({
|
|
|
39
47
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
40
48
|
>
|
|
41
49
|
{children}
|
|
50
|
+
<script
|
|
51
|
+
dangerouslySetInnerHTML={{
|
|
52
|
+
__html: `
|
|
53
|
+
if ('serviceWorker' in navigator) {
|
|
54
|
+
navigator.serviceWorker.register('/sw.js');
|
|
55
|
+
}
|
|
56
|
+
`,
|
|
57
|
+
}}
|
|
58
|
+
/>
|
|
42
59
|
</body>
|
|
43
60
|
</html>
|
|
44
61
|
);
|
package/src/app/page.tsx
CHANGED
|
@@ -1,347 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import DirectoryPicker from '@/components/DirectoryPicker';
|
|
6
|
-
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
|
7
|
-
import TabBar, { 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 Dashboard() {
|
|
25
|
-
const router = useRouter();
|
|
26
|
-
const [projects, setProjects] = useState<ProjectWithSubs[]>([]);
|
|
27
|
-
const [todayTasks, setTodayTasks] = useState<(ITask & { projectName: string; subProjectName: string })[]>([]);
|
|
28
|
-
const [showForm, setShowForm] = useState(false);
|
|
29
|
-
const [name, setName] = useState('');
|
|
30
|
-
const [description, setDescription] = useState('');
|
|
31
|
-
const [projectPath, setProjectPath] = useState('');
|
|
32
|
-
const [loading, setLoading] = useState(true);
|
|
33
|
-
const [showDirPicker, setShowDirPicker] = useState(false);
|
|
34
|
-
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
|
35
|
-
const [tab, setTab] = useState<DashboardTab>(() => {
|
|
36
|
-
if (typeof window !== 'undefined') {
|
|
37
|
-
return (localStorage.getItem('im-dashboard-tab') as DashboardTab) || 'active';
|
|
38
|
-
}
|
|
39
|
-
return 'active';
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
const fetchData = useCallback(async () => {
|
|
43
|
-
const res = await fetch('/api/projects');
|
|
44
|
-
const projectList: IProject[] = await res.json();
|
|
45
|
-
|
|
46
|
-
const withSubs = await Promise.all(
|
|
47
|
-
projectList.map(async (p) => {
|
|
48
|
-
const subRes = await fetch(`/api/projects/${p.id}/sub-projects`);
|
|
49
|
-
const subProjects: ISubProjectWithStats[] = await subRes.json();
|
|
50
|
-
return { ...p, subProjects };
|
|
51
|
-
})
|
|
52
|
-
);
|
|
53
|
-
|
|
54
|
-
setProjects(withSubs);
|
|
55
|
-
|
|
56
|
-
// Gather today tasks
|
|
57
|
-
const allToday: (ITask & { projectName: string; subProjectName: string })[] = [];
|
|
58
|
-
for (const p of withSubs) {
|
|
59
|
-
for (const sp of p.subProjects) {
|
|
60
|
-
for (const pt of sp.preview_tasks) {
|
|
61
|
-
// preview_tasks doesn't have full task data, so we need to check is_today from API
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
// Fetch today tasks from each project's tasks
|
|
66
|
-
for (const p of withSubs) {
|
|
67
|
-
for (const sp of p.subProjects) {
|
|
68
|
-
if (sp.task_count > 0) {
|
|
69
|
-
const tasksRes = await fetch(`/api/projects/${p.id}/sub-projects/${sp.id}/tasks`);
|
|
70
|
-
const tasks: ITask[] = await tasksRes.json();
|
|
71
|
-
for (const t of tasks) {
|
|
72
|
-
if (t.is_today) {
|
|
73
|
-
allToday.push({ ...t, projectName: p.name, subProjectName: sp.name });
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
setTodayTasks(allToday);
|
|
80
|
-
setLoading(false);
|
|
81
|
-
}, []);
|
|
82
|
-
|
|
83
|
-
useEffect(() => {
|
|
84
|
-
fetchData();
|
|
85
|
-
}, [fetchData]);
|
|
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
|
-
router.push(`/projects/${project.id}`);
|
|
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
|
-
setDeleteTarget(null);
|
|
121
|
-
fetchData();
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
// Filter sub-projects based on tab
|
|
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
|
-
// Sort: active cards first
|
|
139
|
-
cards.sort((a, b) => (b.sp.active_count + b.sp.problem_count) - (a.sp.active_count + a.sp.problem_count));
|
|
140
|
-
return cards;
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
const STATUS_ICONS: Record<string, string> = {
|
|
144
|
-
idea: '\u{1F4A1}', writing: '\u{270F}\u{FE0F}', submitted: '\u{1F680}',
|
|
145
|
-
testing: '\u{1F9EA}', done: '\u{2705}', problem: '\u{1F534}',
|
|
146
|
-
};
|
|
3
|
+
import { TabProvider } from '@/components/tabs/TabContext';
|
|
4
|
+
import TabShell from '@/components/tabs/TabShell';
|
|
147
5
|
|
|
6
|
+
export default function App() {
|
|
148
7
|
return (
|
|
149
|
-
<
|
|
150
|
-
<
|
|
151
|
-
|
|
152
|
-
<h1 className="text-2xl font-bold tracking-tight">
|
|
153
|
-
IM <span className="text-muted-foreground font-normal text-sm ml-2">Idea Manager v2</span>
|
|
154
|
-
</h1>
|
|
155
|
-
</div>
|
|
156
|
-
<div className="flex items-center gap-3">
|
|
157
|
-
<TabBar value={tab} onChange={handleTabChange} />
|
|
158
|
-
<button
|
|
159
|
-
onClick={() => setShowForm(!showForm)}
|
|
160
|
-
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg
|
|
161
|
-
transition-colors font-medium text-sm"
|
|
162
|
-
>
|
|
163
|
-
+ Project
|
|
164
|
-
</button>
|
|
165
|
-
</div>
|
|
166
|
-
</header>
|
|
167
|
-
|
|
168
|
-
{showForm && (
|
|
169
|
-
<form onSubmit={handleCreate} className="mb-6 p-5 bg-card rounded-lg border border-border">
|
|
170
|
-
<input
|
|
171
|
-
type="text"
|
|
172
|
-
placeholder="Project name"
|
|
173
|
-
value={name}
|
|
174
|
-
onChange={(e) => setName(e.target.value)}
|
|
175
|
-
className="w-full bg-input border border-border rounded-lg px-4 py-2.5 mb-3
|
|
176
|
-
focus:border-primary focus:outline-none text-foreground"
|
|
177
|
-
autoFocus
|
|
178
|
-
/>
|
|
179
|
-
<input
|
|
180
|
-
type="text"
|
|
181
|
-
placeholder="Description (optional)"
|
|
182
|
-
value={description}
|
|
183
|
-
onChange={(e) => setDescription(e.target.value)}
|
|
184
|
-
className="w-full bg-input border border-border rounded-lg px-4 py-2.5 mb-3
|
|
185
|
-
focus:border-primary focus:outline-none text-foreground"
|
|
186
|
-
/>
|
|
187
|
-
<div className="mb-4">
|
|
188
|
-
<button
|
|
189
|
-
type="button"
|
|
190
|
-
onClick={() => setShowDirPicker(true)}
|
|
191
|
-
className="w-full bg-input border border-border rounded-lg px-4 py-2.5
|
|
192
|
-
text-left text-sm hover:border-primary transition-colors"
|
|
193
|
-
>
|
|
194
|
-
{projectPath ? (
|
|
195
|
-
<span className="font-mono text-foreground">{projectPath}</span>
|
|
196
|
-
) : (
|
|
197
|
-
<span className="text-muted-foreground">Project folder (optional)</span>
|
|
198
|
-
)}
|
|
199
|
-
</button>
|
|
200
|
-
</div>
|
|
201
|
-
<div className="flex gap-2 justify-end">
|
|
202
|
-
<button type="button" onClick={() => setShowForm(false)}
|
|
203
|
-
className="px-4 py-2 text-muted-foreground hover:text-foreground transition-colors text-sm">
|
|
204
|
-
Cancel
|
|
205
|
-
</button>
|
|
206
|
-
<button type="submit"
|
|
207
|
-
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg transition-colors text-sm">
|
|
208
|
-
Create
|
|
209
|
-
</button>
|
|
210
|
-
</div>
|
|
211
|
-
</form>
|
|
212
|
-
)}
|
|
213
|
-
|
|
214
|
-
{loading ? (
|
|
215
|
-
<div className="text-center text-muted-foreground py-20">Loading...</div>
|
|
216
|
-
) : tab === 'today' ? (
|
|
217
|
-
/* Today tab */
|
|
218
|
-
todayTasks.length === 0 ? (
|
|
219
|
-
<div className="text-center py-20 text-muted-foreground">
|
|
220
|
-
<p className="text-lg mb-2">No tasks marked for today</p>
|
|
221
|
-
<p className="text-sm">Mark tasks with the Today button in task detail</p>
|
|
222
|
-
</div>
|
|
223
|
-
) : (
|
|
224
|
-
<div className="space-y-2">
|
|
225
|
-
{todayTasks.map((task) => (
|
|
226
|
-
<div
|
|
227
|
-
key={task.id}
|
|
228
|
-
onClick={() => router.push(`/projects/${task.project_id}?sub=${task.sub_project_id}&task=${task.id}`)}
|
|
229
|
-
className="flex items-center gap-3 p-3 bg-card hover:bg-card-hover border border-border
|
|
230
|
-
rounded-lg cursor-pointer transition-colors"
|
|
231
|
-
>
|
|
232
|
-
<span className="text-sm">{STATUS_ICONS[task.status]}</span>
|
|
233
|
-
<div className="flex-1 min-w-0">
|
|
234
|
-
<span className="text-sm font-medium">{task.title}</span>
|
|
235
|
-
<span className="text-xs text-muted-foreground ml-2">
|
|
236
|
-
{task.projectName} / {task.subProjectName}
|
|
237
|
-
</span>
|
|
238
|
-
</div>
|
|
239
|
-
</div>
|
|
240
|
-
))}
|
|
241
|
-
</div>
|
|
242
|
-
)
|
|
243
|
-
) : (
|
|
244
|
-
/* Active / All tabs */
|
|
245
|
-
<>
|
|
246
|
-
{/* Project headers for All tab */}
|
|
247
|
-
{tab === 'all' ? (
|
|
248
|
-
projects.length === 0 ? (
|
|
249
|
-
<div className="text-center py-20">
|
|
250
|
-
<p className="text-muted-foreground text-lg mb-2">No projects yet</p>
|
|
251
|
-
<p className="text-muted-foreground text-sm">Click + Project to get started</p>
|
|
252
|
-
</div>
|
|
253
|
-
) : (
|
|
254
|
-
<div className="space-y-6">
|
|
255
|
-
{projects.map((project) => (
|
|
256
|
-
<div key={project.id}>
|
|
257
|
-
<div className="flex items-center justify-between mb-3">
|
|
258
|
-
<div
|
|
259
|
-
className="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors"
|
|
260
|
-
onClick={() => router.push(`/projects/${project.id}`)}
|
|
261
|
-
>
|
|
262
|
-
<h2 className="text-sm font-semibold">{project.name}</h2>
|
|
263
|
-
{project.project_path && (
|
|
264
|
-
<span className="text-xs text-muted-foreground font-mono truncate max-w-48">
|
|
265
|
-
{project.project_path}
|
|
266
|
-
</span>
|
|
267
|
-
)}
|
|
268
|
-
</div>
|
|
269
|
-
<button
|
|
270
|
-
onClick={(e) => handleDeleteClick(project.id, e)}
|
|
271
|
-
className="text-xs text-muted-foreground hover:text-destructive transition-colors"
|
|
272
|
-
>
|
|
273
|
-
Delete
|
|
274
|
-
</button>
|
|
275
|
-
</div>
|
|
276
|
-
{project.subProjects.length === 0 ? (
|
|
277
|
-
<div className="text-xs text-muted-foreground py-4 text-center border border-dashed border-border rounded-lg">
|
|
278
|
-
No sub-projects.{' '}
|
|
279
|
-
<span
|
|
280
|
-
className="text-primary cursor-pointer hover:underline"
|
|
281
|
-
onClick={() => router.push(`/projects/${project.id}`)}
|
|
282
|
-
>
|
|
283
|
-
Open project
|
|
284
|
-
</span>{' '}
|
|
285
|
-
to add one.
|
|
286
|
-
</div>
|
|
287
|
-
) : (
|
|
288
|
-
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
289
|
-
{project.subProjects.map((sp) => (
|
|
290
|
-
<SubProjectCard
|
|
291
|
-
key={sp.id}
|
|
292
|
-
subProject={sp}
|
|
293
|
-
projectName={project.name}
|
|
294
|
-
onClick={() => router.push(`/projects/${project.id}?sub=${sp.id}`)}
|
|
295
|
-
/>
|
|
296
|
-
))}
|
|
297
|
-
</div>
|
|
298
|
-
)}
|
|
299
|
-
</div>
|
|
300
|
-
))}
|
|
301
|
-
</div>
|
|
302
|
-
)
|
|
303
|
-
) : (
|
|
304
|
-
/* Active tab */
|
|
305
|
-
(() => {
|
|
306
|
-
const cards = getVisibleCards();
|
|
307
|
-
return cards.length === 0 ? (
|
|
308
|
-
<div className="text-center py-20 text-muted-foreground">
|
|
309
|
-
<p className="text-lg mb-2">No active tasks</p>
|
|
310
|
-
<p className="text-sm">Submit tasks to see them here</p>
|
|
311
|
-
</div>
|
|
312
|
-
) : (
|
|
313
|
-
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
314
|
-
{cards.map(({ sp, projectName, projectId }) => (
|
|
315
|
-
<SubProjectCard
|
|
316
|
-
key={sp.id}
|
|
317
|
-
subProject={sp}
|
|
318
|
-
projectName={projectName}
|
|
319
|
-
onClick={() => router.push(`/projects/${projectId}?sub=${sp.id}`)}
|
|
320
|
-
/>
|
|
321
|
-
))}
|
|
322
|
-
</div>
|
|
323
|
-
);
|
|
324
|
-
})()
|
|
325
|
-
)}
|
|
326
|
-
</>
|
|
327
|
-
)}
|
|
328
|
-
|
|
329
|
-
{showDirPicker && (
|
|
330
|
-
<DirectoryPicker
|
|
331
|
-
onSelect={(path) => { setProjectPath(path); setShowDirPicker(false); }}
|
|
332
|
-
onCancel={() => setShowDirPicker(false)}
|
|
333
|
-
/>
|
|
334
|
-
)}
|
|
335
|
-
|
|
336
|
-
<ConfirmDialog
|
|
337
|
-
open={!!deleteTarget}
|
|
338
|
-
title="Delete project?"
|
|
339
|
-
description="This will permanently delete the project and all its data."
|
|
340
|
-
confirmLabel="Delete"
|
|
341
|
-
variant="danger"
|
|
342
|
-
onConfirm={handleDeleteConfirm}
|
|
343
|
-
onCancel={() => setDeleteTarget(null)}
|
|
344
|
-
/>
|
|
345
|
-
</div>
|
|
8
|
+
<TabProvider>
|
|
9
|
+
<TabShell />
|
|
10
|
+
</TabProvider>
|
|
346
11
|
);
|
|
347
12
|
}
|