create-interview-cockpit 0.1.1 → 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/package.json +1 -1
- package/template/client/src/App.tsx +27 -2
- package/template/client/src/api.ts +190 -1
- package/template/client/src/components/ChatMessage.tsx +27 -1
- package/template/client/src/components/ChatView.tsx +110 -6
- package/template/client/src/components/Sidebar.tsx +342 -182
- package/template/client/src/components/WorkspaceSwitcher.tsx +891 -0
- package/template/client/src/store.ts +190 -1
- package/template/client/src/types.ts +20 -0
- package/template/cockpit.json +1 -1
- package/template/server/package-lock.json +286 -0
- package/template/server/package.json +1 -0
- package/template/server/src/google-drive.ts +714 -0
- package/template/server/src/index.ts +193 -4
- package/template/server/src/storage.ts +332 -32
|
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, Fragment } from "react";
|
|
|
2
2
|
import { useStore } from "../store";
|
|
3
3
|
import type { Question } from "../types";
|
|
4
4
|
import FileAttachments from "./FileAttachments";
|
|
5
|
+
import WorkspaceSwitcher from "./WorkspaceSwitcher";
|
|
5
6
|
import {
|
|
6
7
|
ChevronRight,
|
|
7
8
|
ChevronDown,
|
|
@@ -11,6 +12,10 @@ import {
|
|
|
11
12
|
X,
|
|
12
13
|
Pencil,
|
|
13
14
|
CornerDownRight,
|
|
15
|
+
FolderOpen,
|
|
16
|
+
Loader2,
|
|
17
|
+
ArrowLeft,
|
|
18
|
+
RefreshCw,
|
|
14
19
|
} from "lucide-react";
|
|
15
20
|
|
|
16
21
|
export default function Sidebar() {
|
|
@@ -31,6 +36,14 @@ export default function Sidebar() {
|
|
|
31
36
|
fetchQuestions,
|
|
32
37
|
uploadTopicFiles,
|
|
33
38
|
removeTopicFile,
|
|
39
|
+
workspaces,
|
|
40
|
+
activeWorkspaceId,
|
|
41
|
+
driveRootFolders,
|
|
42
|
+
loadingDriveFolders,
|
|
43
|
+
fetchDriveRootFolders,
|
|
44
|
+
selectDriveSubfolder,
|
|
45
|
+
clearDriveSubfolder,
|
|
46
|
+
syncWorkspace,
|
|
34
47
|
} = useStore();
|
|
35
48
|
|
|
36
49
|
const [newTopicName, setNewTopicName] = useState("");
|
|
@@ -47,6 +60,54 @@ export default function Sidebar() {
|
|
|
47
60
|
const [editingQuestionTitle, setEditingQuestionTitle] = useState("");
|
|
48
61
|
const editInputRef = useRef<HTMLInputElement>(null);
|
|
49
62
|
|
|
63
|
+
// Drive subfolder navigator
|
|
64
|
+
const activeWs = workspaces.find((w) => w.id === activeWorkspaceId);
|
|
65
|
+
const isDriveWs =
|
|
66
|
+
activeWs?.type === "google_drive" && !!activeWs.driveConfig?.folderId;
|
|
67
|
+
const currentSubFolder = activeWs?.driveConfig?.subFolderId
|
|
68
|
+
? {
|
|
69
|
+
id: activeWs.driveConfig.subFolderId,
|
|
70
|
+
name: activeWs.driveConfig.subFolderName ?? "",
|
|
71
|
+
}
|
|
72
|
+
: null;
|
|
73
|
+
const [navigating, setNavigating] = useState(false);
|
|
74
|
+
const [syncing, setSyncing] = useState(false);
|
|
75
|
+
|
|
76
|
+
// Load root folders whenever a Drive workspace becomes active with no subfolder selected
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (isDriveWs && !currentSubFolder && driveRootFolders.length === 0) {
|
|
79
|
+
fetchDriveRootFolders(activeWorkspaceId!);
|
|
80
|
+
}
|
|
81
|
+
}, [activeWorkspaceId, isDriveWs]);
|
|
82
|
+
|
|
83
|
+
const handlePickFolder = async (folderId: string, folderName: string) => {
|
|
84
|
+
setNavigating(true);
|
|
85
|
+
try {
|
|
86
|
+
await selectDriveSubfolder(activeWorkspaceId!, folderId, folderName);
|
|
87
|
+
} finally {
|
|
88
|
+
setNavigating(false);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const handleBack = async () => {
|
|
93
|
+
setNavigating(true);
|
|
94
|
+
try {
|
|
95
|
+
await clearDriveSubfolder(activeWorkspaceId!);
|
|
96
|
+
fetchDriveRootFolders(activeWorkspaceId!);
|
|
97
|
+
} finally {
|
|
98
|
+
setNavigating(false);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const handleResync = async () => {
|
|
103
|
+
setSyncing(true);
|
|
104
|
+
try {
|
|
105
|
+
await syncWorkspace(activeWorkspaceId!);
|
|
106
|
+
} finally {
|
|
107
|
+
setSyncing(false);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
50
111
|
useEffect(() => {
|
|
51
112
|
if (editingTopicId || editingQuestionId) {
|
|
52
113
|
editInputRef.current?.select();
|
|
@@ -187,18 +248,68 @@ export default function Sidebar() {
|
|
|
187
248
|
|
|
188
249
|
return (
|
|
189
250
|
<aside className="w-72 border-r border-slate-800 flex flex-col bg-slate-900/50 shrink-0">
|
|
190
|
-
{/*
|
|
191
|
-
<
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
251
|
+
{/* Workspace switcher */}
|
|
252
|
+
<WorkspaceSwitcher />
|
|
253
|
+
|
|
254
|
+
{/* Header — Drive breadcrumb when inside a subfolder, else normal Topics header */}
|
|
255
|
+
<div className="h-12 border-b border-slate-800 flex items-center justify-between px-3 shrink-0">
|
|
256
|
+
{isDriveWs && currentSubFolder ? (
|
|
257
|
+
<>
|
|
258
|
+
<div className="flex items-center gap-1.5 min-w-0">
|
|
259
|
+
<button
|
|
260
|
+
onClick={handleBack}
|
|
261
|
+
disabled={navigating}
|
|
262
|
+
className="p-0.5 rounded text-slate-500 hover:text-cyan-400 transition-colors disabled:opacity-40"
|
|
263
|
+
title="Back to folder list"
|
|
264
|
+
>
|
|
265
|
+
<ArrowLeft className="w-3.5 h-3.5" />
|
|
266
|
+
</button>
|
|
267
|
+
<FolderOpen className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
|
|
268
|
+
<span
|
|
269
|
+
className="text-xs font-semibold text-slate-300 truncate"
|
|
270
|
+
title={currentSubFolder.name}
|
|
271
|
+
>
|
|
272
|
+
{currentSubFolder.name}
|
|
273
|
+
</span>
|
|
274
|
+
</div>
|
|
275
|
+
<div className="flex items-center gap-1">
|
|
276
|
+
<button
|
|
277
|
+
onClick={handleResync}
|
|
278
|
+
disabled={syncing}
|
|
279
|
+
className="p-1 rounded text-slate-500 hover:text-cyan-400 transition-colors disabled:opacity-40"
|
|
280
|
+
title="Re-sync topics"
|
|
281
|
+
>
|
|
282
|
+
{syncing ? (
|
|
283
|
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
284
|
+
) : (
|
|
285
|
+
<RefreshCw className="w-3.5 h-3.5" />
|
|
286
|
+
)}
|
|
287
|
+
</button>
|
|
288
|
+
<button
|
|
289
|
+
onClick={() => setShowTopicInput(true)}
|
|
290
|
+
className="p-1 rounded hover:bg-slate-800 text-slate-500 hover:text-slate-300 transition-colors"
|
|
291
|
+
title="Add topic"
|
|
292
|
+
>
|
|
293
|
+
<Plus className="w-4 h-4" />
|
|
294
|
+
</button>
|
|
295
|
+
</div>
|
|
296
|
+
</>
|
|
297
|
+
) : (
|
|
298
|
+
<>
|
|
299
|
+
<span className="text-xs font-bold uppercase tracking-wider text-slate-500">
|
|
300
|
+
Topics
|
|
301
|
+
</span>
|
|
302
|
+
{(!isDriveWs || currentSubFolder) && (
|
|
303
|
+
<button
|
|
304
|
+
onClick={() => setShowTopicInput(true)}
|
|
305
|
+
className="p-1 rounded hover:bg-slate-800 text-slate-500 hover:text-slate-300 transition-colors"
|
|
306
|
+
title="Add topic"
|
|
307
|
+
>
|
|
308
|
+
<Plus className="w-4 h-4" />
|
|
309
|
+
</button>
|
|
310
|
+
)}
|
|
311
|
+
</>
|
|
312
|
+
)}
|
|
202
313
|
</div>
|
|
203
314
|
|
|
204
315
|
{/* Add topic input */}
|
|
@@ -232,188 +343,237 @@ export default function Sidebar() {
|
|
|
232
343
|
</div>
|
|
233
344
|
)}
|
|
234
345
|
|
|
235
|
-
{/*
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
<
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
346
|
+
{/* Drive subfolder navigator — shown when Drive workspace, no subfolder selected */}
|
|
347
|
+
{isDriveWs && !currentSubFolder ? (
|
|
348
|
+
<div className="flex-1 overflow-y-auto">
|
|
349
|
+
{loadingDriveFolders || navigating ? (
|
|
350
|
+
<div className="flex items-center justify-center gap-2 py-8 text-slate-500 text-xs">
|
|
351
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
352
|
+
Loading…
|
|
353
|
+
</div>
|
|
354
|
+
) : driveRootFolders.length === 0 ? (
|
|
355
|
+
<div className="p-4 text-center">
|
|
356
|
+
<p className="text-sm text-slate-600">No folders found</p>
|
|
357
|
+
<p className="text-xs text-slate-700 mt-1">
|
|
358
|
+
Make sure the Drive folder has subfolders
|
|
359
|
+
</p>
|
|
360
|
+
</div>
|
|
361
|
+
) : (
|
|
362
|
+
driveRootFolders.map((folder) => (
|
|
363
|
+
<button
|
|
364
|
+
key={folder.id}
|
|
365
|
+
onClick={() => handlePickFolder(folder.id, folder.name)}
|
|
366
|
+
className="w-full flex items-center gap-2.5 px-3 py-2.5 hover:bg-slate-800/60 transition-colors text-left group"
|
|
367
|
+
>
|
|
368
|
+
<FolderOpen className="w-4 h-4 text-cyan-400 shrink-0" />
|
|
369
|
+
<span className="text-sm text-slate-300 truncate flex-1">
|
|
370
|
+
{folder.name}
|
|
371
|
+
</span>
|
|
372
|
+
<ChevronRight className="w-3.5 h-3.5 text-slate-600 group-hover:text-slate-400 shrink-0" />
|
|
373
|
+
</button>
|
|
374
|
+
))
|
|
375
|
+
)}
|
|
376
|
+
</div>
|
|
377
|
+
) : (
|
|
378
|
+
/* Topic list */
|
|
379
|
+
<div className="flex-1 overflow-y-auto">
|
|
380
|
+
{topics.length === 0 && (
|
|
381
|
+
<div className="p-4 text-center">
|
|
382
|
+
<p className="text-sm text-slate-600">No topics yet</p>
|
|
383
|
+
<p className="text-xs text-slate-700 mt-1">Click + to add one</p>
|
|
384
|
+
</div>
|
|
385
|
+
)}
|
|
243
386
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
387
|
+
{[...topics]
|
|
388
|
+
.sort((a, b) =>
|
|
389
|
+
a.name.localeCompare(b.name, undefined, {
|
|
390
|
+
numeric: true,
|
|
391
|
+
sensitivity: "base",
|
|
392
|
+
}),
|
|
393
|
+
)
|
|
394
|
+
.map((topic) => {
|
|
395
|
+
const isExpanded = expandedTopics.includes(topic.id);
|
|
396
|
+
const questions = questionsByTopic[topic.id] || [];
|
|
247
397
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
398
|
+
return (
|
|
399
|
+
<div key={topic.id}>
|
|
400
|
+
{/* Topic header */}
|
|
401
|
+
<div className="group flex items-center gap-1 px-2 py-1.5 hover:bg-slate-800/50 cursor-pointer">
|
|
402
|
+
<button
|
|
403
|
+
onClick={() => toggleTopic(topic.id)}
|
|
404
|
+
className="flex items-center gap-1 flex-1 min-w-0"
|
|
405
|
+
>
|
|
406
|
+
{isExpanded ? (
|
|
407
|
+
<ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
|
408
|
+
) : (
|
|
409
|
+
<ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
|
410
|
+
)}
|
|
411
|
+
{editingTopicId === topic.id ? (
|
|
412
|
+
<input
|
|
413
|
+
ref={editInputRef}
|
|
414
|
+
value={editingTopicName}
|
|
415
|
+
onChange={(e) => setEditingTopicName(e.target.value)}
|
|
416
|
+
onKeyDown={(e) => {
|
|
417
|
+
if (e.key === "Enter") commitTopicRename(topic.id);
|
|
418
|
+
if (e.key === "Escape") setEditingTopicId(null);
|
|
419
|
+
}}
|
|
420
|
+
onBlur={() => commitTopicRename(topic.id)}
|
|
421
|
+
onClick={(e) => e.stopPropagation()}
|
|
422
|
+
className="flex-1 min-w-0 bg-slate-700 border border-cyan-500 rounded px-1 py-0 text-sm text-slate-200 focus:outline-none"
|
|
423
|
+
/>
|
|
424
|
+
) : (
|
|
425
|
+
<span
|
|
426
|
+
className="text-sm font-medium text-slate-300 truncate"
|
|
427
|
+
onDoubleClick={(e) => {
|
|
428
|
+
e.stopPropagation();
|
|
429
|
+
setEditingTopicId(topic.id);
|
|
430
|
+
setEditingTopicName(topic.name);
|
|
431
|
+
}}
|
|
432
|
+
>
|
|
433
|
+
{topic.name}
|
|
434
|
+
</span>
|
|
435
|
+
)}
|
|
436
|
+
<span className="text-xs text-slate-600 ml-auto shrink-0">
|
|
437
|
+
{questions.length}
|
|
438
|
+
</span>
|
|
439
|
+
</button>
|
|
440
|
+
{editingTopicId !== topic.id && (
|
|
441
|
+
<button
|
|
442
|
+
onClick={(e) => {
|
|
443
|
+
e.stopPropagation();
|
|
444
|
+
setEditingTopicId(topic.id);
|
|
445
|
+
setEditingTopicName(topic.name);
|
|
446
|
+
}}
|
|
447
|
+
className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-cyan-400 transition-all"
|
|
448
|
+
title="Rename"
|
|
449
|
+
>
|
|
450
|
+
<Pencil className="w-3 h-3" />
|
|
451
|
+
</button>
|
|
452
|
+
)}
|
|
453
|
+
<button
|
|
454
|
+
onClick={(e) => {
|
|
278
455
|
e.stopPropagation();
|
|
279
|
-
|
|
280
|
-
|
|
456
|
+
if (
|
|
457
|
+
confirm(
|
|
458
|
+
`Delete topic "${topic.name}" and all its questions?`,
|
|
459
|
+
)
|
|
460
|
+
) {
|
|
461
|
+
removeTopic(topic.id);
|
|
462
|
+
}
|
|
281
463
|
}}
|
|
464
|
+
className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-red-400 transition-all"
|
|
282
465
|
>
|
|
283
|
-
|
|
284
|
-
</
|
|
285
|
-
)}
|
|
286
|
-
<span className="text-xs text-slate-600 ml-auto shrink-0">
|
|
287
|
-
{questions.length}
|
|
288
|
-
</span>
|
|
289
|
-
</button>
|
|
290
|
-
{editingTopicId !== topic.id && (
|
|
291
|
-
<button
|
|
292
|
-
onClick={(e) => {
|
|
293
|
-
e.stopPropagation();
|
|
294
|
-
setEditingTopicId(topic.id);
|
|
295
|
-
setEditingTopicName(topic.name);
|
|
296
|
-
}}
|
|
297
|
-
className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-cyan-400 transition-all"
|
|
298
|
-
title="Rename"
|
|
299
|
-
>
|
|
300
|
-
<Pencil className="w-3 h-3" />
|
|
301
|
-
</button>
|
|
302
|
-
)}
|
|
303
|
-
<button
|
|
304
|
-
onClick={(e) => {
|
|
305
|
-
e.stopPropagation();
|
|
306
|
-
if (
|
|
307
|
-
confirm(
|
|
308
|
-
`Delete topic "${topic.name}" and all its questions?`,
|
|
309
|
-
)
|
|
310
|
-
) {
|
|
311
|
-
removeTopic(topic.id);
|
|
312
|
-
}
|
|
313
|
-
}}
|
|
314
|
-
className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-red-400 transition-all"
|
|
315
|
-
>
|
|
316
|
-
<Trash2 className="w-3 h-3" />
|
|
317
|
-
</button>
|
|
318
|
-
</div>
|
|
319
|
-
|
|
320
|
-
{/* Questions list */}
|
|
321
|
-
{isExpanded && (
|
|
322
|
-
<div className="ml-3 border-l border-slate-800">
|
|
323
|
-
{/* Topic-level file attachments */}
|
|
324
|
-
<div className="pl-3 pr-2 py-1.5 border-b border-slate-800/50">
|
|
325
|
-
<FileAttachments
|
|
326
|
-
files={topic.contextFiles || []}
|
|
327
|
-
onUpload={(files) => uploadTopicFiles(topic.id, files)}
|
|
328
|
-
onRemove={(fileId) => removeTopicFile(topic.id, fileId)}
|
|
329
|
-
label="topic"
|
|
330
|
-
compact
|
|
331
|
-
/>
|
|
466
|
+
<Trash2 className="w-3 h-3" />
|
|
467
|
+
</button>
|
|
332
468
|
</div>
|
|
333
469
|
|
|
334
|
-
{/* Questions
|
|
335
|
-
{
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
{renderQuestionRow(q, topic.id, false)}
|
|
344
|
-
{/* Add-child input */}
|
|
345
|
-
{addingChildTo === q.id && (
|
|
346
|
-
<div className="pl-7 pr-2 py-1 animate-fadeIn">
|
|
347
|
-
<input
|
|
348
|
-
autoFocus
|
|
349
|
-
value={newChildTitle}
|
|
350
|
-
onChange={(e) =>
|
|
351
|
-
setNewChildTitle(e.target.value)
|
|
352
|
-
}
|
|
353
|
-
onKeyDown={(e) => {
|
|
354
|
-
if (e.key === "Enter")
|
|
355
|
-
handleAddChildQuestion(topic.id, q.id);
|
|
356
|
-
if (e.key === "Escape") {
|
|
357
|
-
setAddingChildTo(null);
|
|
358
|
-
setNewChildTitle("");
|
|
359
|
-
}
|
|
360
|
-
}}
|
|
361
|
-
onBlur={() => {
|
|
362
|
-
if (!newChildTitle.trim())
|
|
363
|
-
setAddingChildTo(null);
|
|
364
|
-
}}
|
|
365
|
-
placeholder="Child question title..."
|
|
366
|
-
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-0.5 text-xs text-slate-300 placeholder-slate-600 focus:outline-none focus:border-cyan-500"
|
|
367
|
-
/>
|
|
368
|
-
</div>
|
|
369
|
-
)}
|
|
370
|
-
{/* Children */}
|
|
371
|
-
{children.map((c) =>
|
|
372
|
-
renderQuestionRow(c, topic.id, true),
|
|
373
|
-
)}
|
|
374
|
-
</Fragment>
|
|
375
|
-
);
|
|
376
|
-
});
|
|
377
|
-
})()}
|
|
378
|
-
|
|
379
|
-
{/* Add question input */}
|
|
380
|
-
{addingQuestionTo === topic.id ? (
|
|
381
|
-
<div className="pl-3 pr-2 py-1 animate-fadeIn">
|
|
382
|
-
<input
|
|
383
|
-
autoFocus
|
|
384
|
-
value={newQuestionTitle}
|
|
385
|
-
onChange={(e) => setNewQuestionTitle(e.target.value)}
|
|
386
|
-
onKeyDown={(e) => {
|
|
387
|
-
if (e.key === "Enter") handleAddQuestion(topic.id);
|
|
388
|
-
if (e.key === "Escape") {
|
|
389
|
-
setAddingQuestionTo(null);
|
|
390
|
-
setNewQuestionTitle("");
|
|
470
|
+
{/* Questions list */}
|
|
471
|
+
{isExpanded && (
|
|
472
|
+
<div className="ml-3 border-l border-slate-800">
|
|
473
|
+
{/* Topic-level file attachments */}
|
|
474
|
+
<div className="pl-3 pr-2 py-1.5 border-b border-slate-800/50">
|
|
475
|
+
<FileAttachments
|
|
476
|
+
files={topic.contextFiles || []}
|
|
477
|
+
onUpload={(files) =>
|
|
478
|
+
uploadTopicFiles(topic.id, files)
|
|
391
479
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
if (!newQuestionTitle.trim()) {
|
|
395
|
-
setAddingQuestionTo(null);
|
|
480
|
+
onRemove={(fileId) =>
|
|
481
|
+
removeTopicFile(topic.id, fileId)
|
|
396
482
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
483
|
+
label="topic"
|
|
484
|
+
compact
|
|
485
|
+
/>
|
|
486
|
+
</div>
|
|
487
|
+
|
|
488
|
+
{/* Questions grouped: root → children */}
|
|
489
|
+
{(() => {
|
|
490
|
+
const rootQs = questions.filter(
|
|
491
|
+
(q) => !q.parentQuestionId,
|
|
492
|
+
);
|
|
493
|
+
return rootQs.map((q) => {
|
|
494
|
+
const children = questions.filter(
|
|
495
|
+
(c) => c.parentQuestionId === q.id,
|
|
496
|
+
);
|
|
497
|
+
return (
|
|
498
|
+
<Fragment key={q.id}>
|
|
499
|
+
{renderQuestionRow(q, topic.id, false)}
|
|
500
|
+
{/* Add-child input */}
|
|
501
|
+
{addingChildTo === q.id && (
|
|
502
|
+
<div className="pl-7 pr-2 py-1 animate-fadeIn">
|
|
503
|
+
<input
|
|
504
|
+
autoFocus
|
|
505
|
+
value={newChildTitle}
|
|
506
|
+
onChange={(e) =>
|
|
507
|
+
setNewChildTitle(e.target.value)
|
|
508
|
+
}
|
|
509
|
+
onKeyDown={(e) => {
|
|
510
|
+
if (e.key === "Enter")
|
|
511
|
+
handleAddChildQuestion(topic.id, q.id);
|
|
512
|
+
if (e.key === "Escape") {
|
|
513
|
+
setAddingChildTo(null);
|
|
514
|
+
setNewChildTitle("");
|
|
515
|
+
}
|
|
516
|
+
}}
|
|
517
|
+
onBlur={() => {
|
|
518
|
+
if (!newChildTitle.trim())
|
|
519
|
+
setAddingChildTo(null);
|
|
520
|
+
}}
|
|
521
|
+
placeholder="Child question title..."
|
|
522
|
+
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-0.5 text-xs text-slate-300 placeholder-slate-600 focus:outline-none focus:border-cyan-500"
|
|
523
|
+
/>
|
|
524
|
+
</div>
|
|
525
|
+
)}
|
|
526
|
+
{/* Children */}
|
|
527
|
+
{children.map((c) =>
|
|
528
|
+
renderQuestionRow(c, topic.id, true),
|
|
529
|
+
)}
|
|
530
|
+
</Fragment>
|
|
531
|
+
);
|
|
532
|
+
});
|
|
533
|
+
})()}
|
|
534
|
+
|
|
535
|
+
{/* Add question input */}
|
|
536
|
+
{addingQuestionTo === topic.id ? (
|
|
537
|
+
<div className="pl-3 pr-2 py-1 animate-fadeIn">
|
|
538
|
+
<input
|
|
539
|
+
autoFocus
|
|
540
|
+
value={newQuestionTitle}
|
|
541
|
+
onChange={(e) =>
|
|
542
|
+
setNewQuestionTitle(e.target.value)
|
|
543
|
+
}
|
|
544
|
+
onKeyDown={(e) => {
|
|
545
|
+
if (e.key === "Enter")
|
|
546
|
+
handleAddQuestion(topic.id);
|
|
547
|
+
if (e.key === "Escape") {
|
|
548
|
+
setAddingQuestionTo(null);
|
|
549
|
+
setNewQuestionTitle("");
|
|
550
|
+
}
|
|
551
|
+
}}
|
|
552
|
+
onBlur={() => {
|
|
553
|
+
if (!newQuestionTitle.trim()) {
|
|
554
|
+
setAddingQuestionTo(null);
|
|
555
|
+
}
|
|
556
|
+
}}
|
|
557
|
+
placeholder="Question title..."
|
|
558
|
+
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-0.5 text-xs text-slate-300 placeholder-slate-600 focus:outline-none focus:border-cyan-500"
|
|
559
|
+
/>
|
|
560
|
+
</div>
|
|
561
|
+
) : (
|
|
562
|
+
<button
|
|
563
|
+
onClick={() => setAddingQuestionTo(topic.id)}
|
|
564
|
+
className="flex items-center gap-1 pl-3 pr-2 py-1 text-xs text-slate-600 hover:text-cyan-400 transition-colors w-full"
|
|
565
|
+
>
|
|
566
|
+
<Plus className="w-3 h-3" />
|
|
567
|
+
Add question
|
|
568
|
+
</button>
|
|
569
|
+
)}
|
|
401
570
|
</div>
|
|
402
|
-
) : (
|
|
403
|
-
<button
|
|
404
|
-
onClick={() => setAddingQuestionTo(topic.id)}
|
|
405
|
-
className="flex items-center gap-1 pl-3 pr-2 py-1 text-xs text-slate-600 hover:text-cyan-400 transition-colors w-full"
|
|
406
|
-
>
|
|
407
|
-
<Plus className="w-3 h-3" />
|
|
408
|
-
Add question
|
|
409
|
-
</button>
|
|
410
571
|
)}
|
|
411
572
|
</div>
|
|
412
|
-
)
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
</div>
|
|
573
|
+
);
|
|
574
|
+
})}
|
|
575
|
+
</div>
|
|
576
|
+
)}
|
|
417
577
|
</aside>
|
|
418
578
|
);
|
|
419
579
|
}
|