create-interview-cockpit 0.1.0 → 0.2.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.
@@ -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
- {/* Header */}
191
- <div className="h-12 border-b border-slate-800 flex items-center justify-between px-3">
192
- <span className="text-xs font-bold uppercase tracking-wider text-slate-500">
193
- Topics
194
- </span>
195
- <button
196
- onClick={() => setShowTopicInput(true)}
197
- className="p-1 rounded hover:bg-slate-800 text-slate-500 hover:text-slate-300 transition-colors"
198
- title="Add topic"
199
- >
200
- <Plus className="w-4 h-4" />
201
- </button>
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
- {/* Topic list */}
236
- <div className="flex-1 overflow-y-auto">
237
- {topics.length === 0 && (
238
- <div className="p-4 text-center">
239
- <p className="text-sm text-slate-600">No topics yet</p>
240
- <p className="text-xs text-slate-700 mt-1">Click + to add one</p>
241
- </div>
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
- {topics.map((topic) => {
245
- const isExpanded = expandedTopics.includes(topic.id);
246
- const questions = questionsByTopic[topic.id] || [];
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
- return (
249
- <div key={topic.id}>
250
- {/* Topic header */}
251
- <div className="group flex items-center gap-1 px-2 py-1.5 hover:bg-slate-800/50 cursor-pointer">
252
- <button
253
- onClick={() => toggleTopic(topic.id)}
254
- className="flex items-center gap-1 flex-1 min-w-0"
255
- >
256
- {isExpanded ? (
257
- <ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" />
258
- ) : (
259
- <ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />
260
- )}
261
- {editingTopicId === topic.id ? (
262
- <input
263
- ref={editInputRef}
264
- value={editingTopicName}
265
- onChange={(e) => setEditingTopicName(e.target.value)}
266
- onKeyDown={(e) => {
267
- if (e.key === "Enter") commitTopicRename(topic.id);
268
- if (e.key === "Escape") setEditingTopicId(null);
269
- }}
270
- onBlur={() => commitTopicRename(topic.id)}
271
- onClick={(e) => e.stopPropagation()}
272
- 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"
273
- />
274
- ) : (
275
- <span
276
- className="text-sm font-medium text-slate-300 truncate"
277
- onDoubleClick={(e) => {
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
- setEditingTopicId(topic.id);
280
- setEditingTopicName(topic.name);
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
- {topic.name}
284
- </span>
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 grouped: root → children */}
335
- {(() => {
336
- const rootQs = questions.filter((q) => !q.parentQuestionId);
337
- return rootQs.map((q) => {
338
- const children = questions.filter(
339
- (c) => c.parentQuestionId === q.id,
340
- );
341
- return (
342
- <Fragment key={q.id}>
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
- onBlur={() => {
394
- if (!newQuestionTitle.trim()) {
395
- setAddingQuestionTo(null);
480
+ onRemove={(fileId) =>
481
+ removeTopicFile(topic.id, fileId)
396
482
  }
397
- }}
398
- placeholder="Question title..."
399
- 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"
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
- </div>
414
- );
415
- })}
416
- </div>
573
+ );
574
+ })}
575
+ </div>
576
+ )}
417
577
  </aside>
418
578
  );
419
579
  }