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.
@@ -0,0 +1,891 @@
1
+ import { useState, useRef, useEffect } from "react";
2
+ import {
3
+ ChevronDown,
4
+ Plus,
5
+ Check,
6
+ Trash2,
7
+ Pencil,
8
+ RefreshCw,
9
+ FolderOpen,
10
+ HardDrive,
11
+ Cloud,
12
+ X,
13
+ Link,
14
+ Upload,
15
+ Loader2,
16
+ } from "lucide-react";
17
+ import { useStore } from "../store";
18
+ import type { WorkspaceMeta } from "../types";
19
+
20
+ // ── Workspace Switcher ─────────────────────────────────────────────────────────
21
+
22
+ export default function WorkspaceSwitcher() {
23
+ const {
24
+ workspaces,
25
+ activeWorkspaceId,
26
+ activateWorkspace,
27
+ createWorkspace,
28
+ deleteWorkspace,
29
+ renameWorkspace,
30
+ syncWorkspace,
31
+ linkDriveFolder,
32
+ attachDriveFolder,
33
+ exportWorkspace,
34
+ fetchDriveSubfolders,
35
+ createDriveSubfolder,
36
+ } = useStore();
37
+
38
+ const [open, setOpen] = useState(false);
39
+ const [showNewForm, setShowNewForm] = useState(false);
40
+ const [newName, setNewName] = useState("");
41
+ const [newType, setNewType] = useState<"local" | "google_drive">("local");
42
+ const [creating, setCreating] = useState(false);
43
+
44
+ const [editingId, setEditingId] = useState<string | null>(null);
45
+ const [editingName, setEditingName] = useState("");
46
+
47
+ const [syncing, setSyncing] = useState<string | null>(null);
48
+ const [syncResult, setSyncResult] = useState<{
49
+ topicsUpserted: number;
50
+ filesImported: number;
51
+ filesSkipped: number;
52
+ errors: string[];
53
+ } | null>(null);
54
+
55
+ // Per-workspace URL input state for Drive linking
56
+ const [driveUrls, setDriveUrls] = useState<Record<string, string>>({});
57
+ const [linking, setLinking] = useState<string | null>(null);
58
+ const [attaching, setAttaching] = useState<string | null>(null);
59
+ // When set, show the URL paste form even if driveConfig is already linked
60
+ const [changingDriveId, setChangingDriveId] = useState<string | null>(null);
61
+ // Local workspaces where user clicked "Connect Drive"
62
+ const [showDriveAttach, setShowDriveAttach] = useState<
63
+ Record<string, boolean>
64
+ >({});
65
+
66
+ const [exporting, setExporting] = useState<string | null>(null);
67
+ const [exportResult, setExportResult] = useState<{
68
+ topicsExported: number;
69
+ questionsExported: number;
70
+ filesExported: number;
71
+ errors: string[];
72
+ } | null>(null);
73
+ const [exportAuthedToast, setExportAuthedToast] = useState(() =>
74
+ new URLSearchParams(window.location.search).has("export_authed"),
75
+ );
76
+
77
+ // Subfolder picker state (export destination)
78
+ const [folderPicker, setFolderPicker] = useState<{
79
+ ws: WorkspaceMeta;
80
+ folders: { id: string; name: string }[];
81
+ loading: boolean;
82
+ } | null>(null);
83
+ const [newFolderName, setNewFolderName] = useState("");
84
+ const [creatingFolder, setCreatingFolder] = useState(false);
85
+
86
+ // Delete confirmation modal
87
+ const [deleteTarget, setDeleteTarget] = useState<WorkspaceMeta | null>(null);
88
+ const [deleting, setDeleting] = useState(false);
89
+
90
+ const dropdownRef = useRef<HTMLDivElement>(null);
91
+
92
+ // Close dropdown when clicking outside
93
+ useEffect(() => {
94
+ const handler = (e: MouseEvent) => {
95
+ if (
96
+ dropdownRef.current &&
97
+ !dropdownRef.current.contains(e.target as Node)
98
+ ) {
99
+ setOpen(false);
100
+ }
101
+ };
102
+ document.addEventListener("mousedown", handler);
103
+ return () => document.removeEventListener("mousedown", handler);
104
+ }, []);
105
+
106
+ // Auto-open folder picker when returning from Drive OAuth
107
+ // (removed — now using URL paste flow)
108
+
109
+ const activeWs = workspaces.find((w) => w.id === activeWorkspaceId);
110
+
111
+ const handleCreate = async () => {
112
+ if (!newName.trim()) return;
113
+ setCreating(true);
114
+ try {
115
+ await createWorkspace(newName.trim(), newType);
116
+ setNewName("");
117
+ setNewType("local");
118
+ setShowNewForm(false);
119
+ } finally {
120
+ setCreating(false);
121
+ }
122
+ };
123
+
124
+ const handleActivate = async (ws: WorkspaceMeta) => {
125
+ if (ws.id === activeWorkspaceId) return;
126
+ setOpen(false);
127
+ await activateWorkspace(ws.id);
128
+ };
129
+
130
+ const handleDelete = async () => {
131
+ if (!deleteTarget) return;
132
+ setDeleting(true);
133
+ try {
134
+ await deleteWorkspace(deleteTarget.id);
135
+ } finally {
136
+ setDeleting(false);
137
+ setDeleteTarget(null);
138
+ }
139
+ };
140
+
141
+ const commitRename = async (id: string) => {
142
+ const trimmed = editingName.trim();
143
+ if (trimmed) await renameWorkspace(id, trimmed);
144
+ setEditingId(null);
145
+ };
146
+
147
+ const handleSync = async (ws: WorkspaceMeta, e: React.MouseEvent) => {
148
+ e.stopPropagation();
149
+ setSyncing(ws.id);
150
+ setSyncResult(null);
151
+ try {
152
+ const result = await syncWorkspace(ws.id);
153
+ setSyncResult(result);
154
+ } catch (err: any) {
155
+ setSyncResult({
156
+ topicsUpserted: 0,
157
+ filesImported: 0,
158
+ filesSkipped: 0,
159
+ errors: [err?.message || "Sync failed"],
160
+ });
161
+ } finally {
162
+ setSyncing(null);
163
+ }
164
+ };
165
+
166
+ const handleLinkDrive = async (ws: WorkspaceMeta, e: React.FormEvent) => {
167
+ e.preventDefault();
168
+ const url = driveUrls[ws.id]?.trim();
169
+ if (!url) return;
170
+ setLinking(ws.id);
171
+ setSyncResult(null);
172
+ try {
173
+ await linkDriveFolder(ws.id, url);
174
+ setDriveUrls((prev) => ({ ...prev, [ws.id]: "" }));
175
+ setChangingDriveId(null);
176
+ } catch (err: any) {
177
+ setSyncResult({
178
+ topicsUpserted: 0,
179
+ filesImported: 0,
180
+ filesSkipped: 0,
181
+ errors: [err?.message || "Failed to link folder"],
182
+ });
183
+ } finally {
184
+ setLinking(null);
185
+ }
186
+ };
187
+
188
+ const handleAttachDrive = async (ws: WorkspaceMeta, e: React.FormEvent) => {
189
+ e.preventDefault();
190
+ const url = driveUrls[ws.id]?.trim();
191
+ if (!url) return;
192
+ setAttaching(ws.id);
193
+ try {
194
+ await attachDriveFolder(ws.id, url);
195
+ setDriveUrls((prev) => ({ ...prev, [ws.id]: "" }));
196
+ setShowDriveAttach((prev) => ({ ...prev, [ws.id]: false }));
197
+ setChangingDriveId(null);
198
+ } catch (err: any) {
199
+ setSyncResult({
200
+ topicsUpserted: 0,
201
+ filesImported: 0,
202
+ filesSkipped: 0,
203
+ errors: [err?.message || "Failed to attach Drive folder"],
204
+ });
205
+ } finally {
206
+ setAttaching(null);
207
+ }
208
+ };
209
+
210
+ const handleExport = async (ws: WorkspaceMeta, e: React.MouseEvent) => {
211
+ e.stopPropagation();
212
+ setExportResult(null);
213
+
214
+ // If the workspace has subfolders, show the picker first
215
+ if (ws.driveConfig?.folderId) {
216
+ setFolderPicker({ ws, folders: [], loading: true });
217
+ try {
218
+ const folders = await fetchDriveSubfolders(ws.id);
219
+ setFolderPicker({ ws, folders, loading: false });
220
+ } catch {
221
+ setFolderPicker({ ws, folders: [], loading: false });
222
+ }
223
+ return;
224
+ }
225
+
226
+ await runExport(ws);
227
+ };
228
+
229
+ const runExport = async (ws: WorkspaceMeta, targetFolderId?: string) => {
230
+ setFolderPicker(null);
231
+ setExporting(ws.id);
232
+ try {
233
+ const result = await exportWorkspace(ws.id, targetFolderId);
234
+ if ("needsAuth" in result && result.needsAuth) {
235
+ window.location.href = result.authUrl;
236
+ return;
237
+ }
238
+ setExportResult(result);
239
+ } catch (err: any) {
240
+ setExportResult({
241
+ topicsExported: 0,
242
+ questionsExported: 0,
243
+ filesExported: 0,
244
+ errors: [err?.message || "Export failed"],
245
+ });
246
+ } finally {
247
+ setExporting(null);
248
+ }
249
+ };
250
+
251
+ const formatSyncTime = (iso: string) => {
252
+ const d = new Date(iso);
253
+ return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
254
+ };
255
+
256
+ return (
257
+ <>
258
+ {/* Delete workspace confirmation modal */}
259
+ {deleteTarget && (
260
+ <div
261
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
262
+ onClick={() => !deleting && setDeleteTarget(null)}
263
+ >
264
+ <div
265
+ className="bg-slate-900 border border-slate-700 rounded-xl shadow-2xl w-80 p-5"
266
+ onClick={(e) => e.stopPropagation()}
267
+ >
268
+ <h3 className="text-sm font-semibold text-slate-200 mb-1">
269
+ Delete workspace?
270
+ </h3>
271
+ <p className="text-xs text-slate-400 mb-4">
272
+ <span className="text-slate-200 font-medium">
273
+ &ldquo;{deleteTarget.name}&rdquo;
274
+ </span>{" "}
275
+ and all its topics, questions, and files will be permanently
276
+ deleted. This cannot be undone.
277
+ </p>
278
+ <div className="flex gap-2 justify-end">
279
+ <button
280
+ onClick={() => setDeleteTarget(null)}
281
+ disabled={deleting}
282
+ className="px-3 py-1.5 rounded-lg text-xs text-slate-400 hover:text-slate-200 disabled:opacity-40 transition-colors"
283
+ >
284
+ Cancel
285
+ </button>
286
+ <button
287
+ onClick={handleDelete}
288
+ disabled={deleting}
289
+ className="px-3 py-1.5 rounded-lg bg-red-700 hover:bg-red-600 disabled:opacity-40 text-xs text-white font-medium flex items-center gap-1.5 transition-colors"
290
+ >
291
+ {deleting ? (
292
+ <Loader2 size={11} className="animate-spin" />
293
+ ) : (
294
+ <Trash2 size={11} />
295
+ )}
296
+ Delete
297
+ </button>
298
+ </div>
299
+ </div>
300
+ </div>
301
+ )}
302
+
303
+ {/* Subfolder picker modal (export destination) */}
304
+ {folderPicker && (
305
+ <div
306
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
307
+ onClick={() => setFolderPicker(null)}
308
+ >
309
+ <div
310
+ className="bg-slate-900 border border-slate-700 rounded-xl shadow-2xl w-80 p-4"
311
+ onClick={(e) => e.stopPropagation()}
312
+ >
313
+ <div className="flex items-center justify-between mb-3">
314
+ <h3 className="text-sm font-semibold text-slate-200">
315
+ Export to subfolder
316
+ </h3>
317
+ <button
318
+ onClick={() => setFolderPicker(null)}
319
+ className="text-slate-500 hover:text-slate-300"
320
+ >
321
+ <X size={15} />
322
+ </button>
323
+ </div>
324
+ {folderPicker.loading ? (
325
+ <div className="flex items-center gap-2 text-slate-400 text-xs py-4 justify-center">
326
+ <Loader2 size={14} className="animate-spin" /> Loading folders…
327
+ </div>
328
+ ) : (
329
+ <>
330
+ <div className="space-y-1 max-h-48 overflow-y-auto">
331
+ <button
332
+ onClick={() => runExport(folderPicker.ws)}
333
+ className="w-full text-left px-3 py-2 rounded-lg text-xs text-slate-300 hover:bg-slate-800 flex items-center gap-2"
334
+ >
335
+ <FolderOpen size={13} className="text-slate-500 shrink-0" />
336
+ <span className="italic text-slate-400">
337
+ Root (no subfolder)
338
+ </span>
339
+ </button>
340
+ {folderPicker.folders.map((f) => (
341
+ <button
342
+ key={f.id}
343
+ onClick={() => runExport(folderPicker.ws, f.id)}
344
+ className="w-full text-left px-3 py-2 rounded-lg text-xs text-slate-200 hover:bg-slate-800 flex items-center gap-2"
345
+ >
346
+ <FolderOpen
347
+ size={13}
348
+ className="text-blue-400 shrink-0"
349
+ />
350
+ {f.name}
351
+ </button>
352
+ ))}
353
+ {folderPicker.folders.length === 0 && (
354
+ <p className="text-xs text-slate-500 px-3 py-2">
355
+ No subfolders yet.
356
+ </p>
357
+ )}
358
+ </div>
359
+ {/* New folder form */}
360
+ <form
361
+ className="mt-3 flex gap-2"
362
+ onSubmit={async (e) => {
363
+ e.preventDefault();
364
+ const name = newFolderName.trim();
365
+ if (!name) return;
366
+ setCreatingFolder(true);
367
+ try {
368
+ const created = await createDriveSubfolder(
369
+ folderPicker.ws.id,
370
+ name,
371
+ );
372
+ setFolderPicker((p) =>
373
+ p ? { ...p, folders: [...p.folders, created] } : p,
374
+ );
375
+ setNewFolderName("");
376
+ } catch (err: any) {
377
+ alert(err?.message || "Failed to create folder");
378
+ } finally {
379
+ setCreatingFolder(false);
380
+ }
381
+ }}
382
+ >
383
+ <input
384
+ value={newFolderName}
385
+ onChange={(e) => setNewFolderName(e.target.value)}
386
+ placeholder="New folder name…"
387
+ className="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-2 py-1.5 text-xs text-slate-200 placeholder-slate-500 focus:outline-none focus:border-blue-500"
388
+ />
389
+ <button
390
+ type="submit"
391
+ disabled={!newFolderName.trim() || creatingFolder}
392
+ className="px-2.5 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-xs text-white font-medium flex items-center gap-1"
393
+ >
394
+ {creatingFolder ? (
395
+ <Loader2 size={11} className="animate-spin" />
396
+ ) : (
397
+ <Plus size={11} />
398
+ )}
399
+ Create
400
+ </button>
401
+ </form>
402
+ </>
403
+ )}
404
+ </div>
405
+ </div>
406
+ )}
407
+ {/* Drive OAuth auth'd toast */}
408
+ {exportAuthedToast && (
409
+ <div className="fixed bottom-4 right-4 z-40 p-3 rounded-lg border bg-emerald-950 border-emerald-700 text-emerald-300 text-xs max-w-xs shadow-lg">
410
+ <div className="flex items-start justify-between gap-2">
411
+ <p className="font-medium">
412
+ Google Drive connected! Click Export again to upload.
413
+ </p>
414
+ <button
415
+ onClick={() => setExportAuthedToast(false)}
416
+ className="text-emerald-400 hover:text-emerald-200 shrink-0 mt-0.5"
417
+ >
418
+ <X size={14} />
419
+ </button>
420
+ </div>
421
+ </div>
422
+ )}
423
+ {/* Sync result toast */}
424
+ {syncResult && (
425
+ <div
426
+ className={`fixed bottom-4 right-4 z-40 p-3 rounded-lg border text-xs max-w-xs shadow-lg ${
427
+ syncResult.errors.length > 0
428
+ ? "bg-red-950 border-red-800 text-red-300"
429
+ : "bg-slate-800 border-slate-700 text-slate-300"
430
+ }`}
431
+ >
432
+ <div className="flex items-start justify-between gap-2">
433
+ <div>
434
+ {syncResult.errors.length === 0 ? (
435
+ <>
436
+ <p className="font-medium text-slate-200">Sync complete</p>
437
+ <p className="mt-0.5 text-slate-400">
438
+ {syncResult.filesImported} imported ·{" "}
439
+ {syncResult.topicsUpserted} topics ·{" "}
440
+ {syncResult.filesSkipped} skipped
441
+ </p>
442
+ </>
443
+ ) : (
444
+ <>
445
+ <p className="font-medium">Sync errors</p>
446
+ {syncResult.errors.slice(0, 3).map((e, i) => (
447
+ <p key={i} className="mt-0.5 text-red-400 truncate">
448
+ {e}
449
+ </p>
450
+ ))}
451
+ </>
452
+ )}
453
+ </div>
454
+ <button
455
+ onClick={() => setSyncResult(null)}
456
+ className="shrink-0 text-slate-500 hover:text-slate-300"
457
+ >
458
+ <X className="w-3.5 h-3.5" />
459
+ </button>
460
+ </div>
461
+ </div>
462
+ )}
463
+
464
+ {/* Export result toast */}
465
+ {exportResult && (
466
+ <div
467
+ className={`fixed bottom-4 right-4 z-40 p-3 rounded-lg border text-xs w-72 shadow-lg ${
468
+ exportResult.errors.length > 0
469
+ ? "bg-red-950 border-red-800 text-red-300"
470
+ : "bg-slate-800 border-slate-700 text-slate-300"
471
+ }`}
472
+ >
473
+ <div className="flex items-start justify-between gap-2">
474
+ <div className="min-w-0 flex-1">
475
+ {exportResult.errors.length === 0 ? (
476
+ <>
477
+ <p className="font-medium text-slate-200">Export complete</p>
478
+ <p className="mt-0.5 text-slate-400">
479
+ {exportResult.questionsExported} questions ·{" "}
480
+ {exportResult.filesExported} files ·{" "}
481
+ {exportResult.topicsExported} topics
482
+ </p>
483
+ </>
484
+ ) : (
485
+ <>
486
+ <p className="font-medium">Export errors</p>
487
+ {exportResult.errors.slice(0, 3).map((e, i) => (
488
+ <p key={i} className="mt-0.5 text-red-400 break-words">
489
+ {e}
490
+ </p>
491
+ ))}
492
+ </>
493
+ )}
494
+ </div>
495
+ <button
496
+ onClick={() => setExportResult(null)}
497
+ className="shrink-0 text-slate-500 hover:text-slate-300"
498
+ >
499
+ <X className="w-3.5 h-3.5" />
500
+ </button>
501
+ </div>
502
+ </div>
503
+ )}
504
+
505
+ {/* Switcher widget */}
506
+ <div ref={dropdownRef} className="relative">
507
+ <button
508
+ onClick={() => setOpen((v) => !v)}
509
+ className="w-full flex items-center gap-2 px-3 py-2 hover:bg-slate-800/60 transition-colors border-b border-slate-800"
510
+ >
511
+ {activeWs?.type === "google_drive" ? (
512
+ <Cloud className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
513
+ ) : (
514
+ <HardDrive className="w-3.5 h-3.5 text-slate-500 shrink-0" />
515
+ )}
516
+ <span className="text-xs font-medium text-slate-300 truncate flex-1 text-left">
517
+ {activeWs?.name ?? "Workspace"}
518
+ </span>
519
+ {activeWs?.type === "google_drive" && (
520
+ <span className="text-[10px] text-cyan-500 bg-cyan-500/10 px-1.5 py-0.5 rounded shrink-0">
521
+ Drive
522
+ </span>
523
+ )}
524
+ <ChevronDown
525
+ className={`w-3 h-3 text-slate-500 shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
526
+ />
527
+ </button>
528
+
529
+ {open && (
530
+ <div className="absolute top-full left-0 right-0 z-30 bg-slate-900 border border-slate-700 rounded-b-lg shadow-xl overflow-hidden">
531
+ {/* Workspace list */}
532
+ <div className="max-h-72 overflow-y-auto">
533
+ {workspaces.map((ws) => (
534
+ <div
535
+ key={ws.id}
536
+ onClick={() => handleActivate(ws)}
537
+ className={`group px-3 py-2 cursor-pointer transition-colors ${
538
+ ws.id === activeWorkspaceId
539
+ ? "bg-cyan-500/10"
540
+ : "hover:bg-slate-800/60"
541
+ }`}
542
+ >
543
+ {/* Name row */}
544
+ <div className="flex items-center gap-2">
545
+ {ws.type === "google_drive" ? (
546
+ <Cloud className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
547
+ ) : (
548
+ <HardDrive className="w-3.5 h-3.5 text-slate-500 shrink-0" />
549
+ )}
550
+
551
+ {editingId === ws.id ? (
552
+ <input
553
+ autoFocus
554
+ value={editingName}
555
+ onChange={(e) => setEditingName(e.target.value)}
556
+ onKeyDown={(e) => {
557
+ if (e.key === "Enter") commitRename(ws.id);
558
+ if (e.key === "Escape") setEditingId(null);
559
+ }}
560
+ onBlur={() => commitRename(ws.id)}
561
+ onClick={(e) => e.stopPropagation()}
562
+ className="flex-1 min-w-0 bg-slate-700 border border-cyan-500 rounded px-1 py-0 text-xs text-slate-200 focus:outline-none"
563
+ />
564
+ ) : (
565
+ <span
566
+ className="text-xs text-slate-300 truncate flex-1"
567
+ onDoubleClick={(e) => {
568
+ e.stopPropagation();
569
+ setEditingId(ws.id);
570
+ setEditingName(ws.name);
571
+ }}
572
+ >
573
+ {ws.name}
574
+ </span>
575
+ )}
576
+
577
+ {ws.id === activeWorkspaceId && (
578
+ <Check className="w-3 h-3 text-cyan-400 shrink-0" />
579
+ )}
580
+
581
+ {/* Context actions (visible on hover) */}
582
+ <span
583
+ className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
584
+ onClick={(e) => e.stopPropagation()}
585
+ >
586
+ <button
587
+ onClick={(e) => {
588
+ e.stopPropagation();
589
+ setEditingId(ws.id);
590
+ setEditingName(ws.name);
591
+ }}
592
+ className="p-0.5 rounded text-slate-600 hover:text-cyan-400 transition-colors"
593
+ title="Rename"
594
+ >
595
+ <Pencil className="w-2.5 h-2.5" />
596
+ </button>
597
+ {workspaces.length > 1 && (
598
+ <button
599
+ onClick={(e) => {
600
+ e.stopPropagation();
601
+ setDeleteTarget(ws);
602
+ }}
603
+ className="p-0.5 rounded text-slate-600 hover:text-red-400 transition-colors"
604
+ title="Delete workspace"
605
+ >
606
+ <Trash2 className="w-2.5 h-2.5" />
607
+ </button>
608
+ )}
609
+ </span>
610
+ </div>
611
+
612
+ {/* Drive row for google_drive workspaces */}
613
+ {ws.type === "google_drive" && (
614
+ <div
615
+ className="mt-1 ml-5"
616
+ onClick={(e) => e.stopPropagation()}
617
+ >
618
+ {!ws.driveConfig?.folderId ||
619
+ changingDriveId === ws.id ? (
620
+ // No folder linked yet (or user clicked "Change") — URL paste form
621
+ <form
622
+ onSubmit={(e) => handleLinkDrive(ws, e)}
623
+ className="flex items-center gap-1"
624
+ >
625
+ <input
626
+ value={driveUrls[ws.id] ?? ""}
627
+ onChange={(e) =>
628
+ setDriveUrls((prev) => ({
629
+ ...prev,
630
+ [ws.id]: e.target.value,
631
+ }))
632
+ }
633
+ placeholder="Paste Drive share link…"
634
+ className="flex-1 min-w-0 bg-slate-800 border border-slate-700 rounded px-1.5 py-0.5 text-[10px] text-slate-300 placeholder-slate-600 focus:outline-none focus:border-cyan-600"
635
+ />
636
+ <button
637
+ type="submit"
638
+ disabled={
639
+ linking === ws.id || !driveUrls[ws.id]?.trim()
640
+ }
641
+ className="flex items-center gap-0.5 text-[10px] text-cyan-500 hover:text-cyan-300 transition-colors disabled:opacity-40"
642
+ >
643
+ {linking === ws.id ? (
644
+ <Loader2 className="w-2.5 h-2.5 animate-spin" />
645
+ ) : (
646
+ <Link className="w-2.5 h-2.5" />
647
+ )}
648
+ Link
649
+ </button>
650
+ {ws.driveConfig?.folderId && (
651
+ <button
652
+ type="button"
653
+ onClick={() => setChangingDriveId(null)}
654
+ className="text-[10px] text-slate-600 hover:text-slate-400 transition-colors"
655
+ >
656
+ Cancel
657
+ </button>
658
+ )}
659
+ </form>
660
+ ) : (
661
+ // Folder linked — show name + sync/change/export actions
662
+ <div className="flex items-center gap-2">
663
+ <FolderOpen className="w-2.5 h-2.5 text-slate-500 shrink-0" />
664
+ <span
665
+ className="text-[10px] text-slate-400 truncate max-w-[110px]"
666
+ title={ws.driveConfig.folderName}
667
+ >
668
+ {ws.driveConfig.folderName}
669
+ </span>
670
+ <button
671
+ onClick={(e) => handleSync(ws, e)}
672
+ disabled={syncing === ws.id}
673
+ className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-cyan-400 transition-colors disabled:opacity-50"
674
+ title="Sync from Drive"
675
+ >
676
+ {syncing === ws.id ? (
677
+ <Loader2 className="w-2.5 h-2.5 animate-spin" />
678
+ ) : (
679
+ <RefreshCw className="w-2.5 h-2.5" />
680
+ )}
681
+ {syncing === ws.id
682
+ ? "Syncing…"
683
+ : ws.driveConfig.lastSyncedAt
684
+ ? `Synced ${formatSyncTime(ws.driveConfig.lastSyncedAt)}`
685
+ : "Sync"}
686
+ </button>
687
+ <button
688
+ onClick={() => setChangingDriveId(ws.id)}
689
+ className="text-[10px] text-slate-600 hover:text-slate-400 transition-colors"
690
+ title="Change linked folder"
691
+ >
692
+ Change
693
+ </button>
694
+ <button
695
+ onClick={(e) => handleExport(ws, e)}
696
+ disabled={exporting === ws.id}
697
+ className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-cyan-400 transition-colors disabled:opacity-50"
698
+ title="Export topics/questions to Drive"
699
+ >
700
+ {exporting === ws.id ? (
701
+ <Loader2 className="w-2.5 h-2.5 animate-spin" />
702
+ ) : (
703
+ <Upload className="w-2.5 h-2.5" />
704
+ )}
705
+ {exporting === ws.id ? "Exporting…" : "Export"}
706
+ </button>
707
+ </div>
708
+ )}
709
+ </div>
710
+ )}
711
+
712
+ {/* Drive row for local workspaces — attach-only, no sync */}
713
+ {ws.type === "local" && (
714
+ <div
715
+ className="mt-1 ml-5"
716
+ onClick={(e) => e.stopPropagation()}
717
+ >
718
+ {ws.driveConfig?.folderId && changingDriveId !== ws.id ? (
719
+ // Folder attached — show name + change + export
720
+ <div className="flex items-center gap-2">
721
+ <FolderOpen className="w-2.5 h-2.5 text-slate-500 shrink-0" />
722
+ <span
723
+ className="text-[10px] text-slate-400 truncate max-w-[110px]"
724
+ title={ws.driveConfig.folderName}
725
+ >
726
+ {ws.driveConfig.folderName}
727
+ </span>
728
+ <button
729
+ onClick={() => setChangingDriveId(ws.id)}
730
+ className="text-[10px] text-slate-600 hover:text-slate-400 transition-colors"
731
+ title="Change linked folder"
732
+ >
733
+ Change
734
+ </button>
735
+ <button
736
+ onClick={(e) => handleExport(ws, e)}
737
+ disabled={exporting === ws.id}
738
+ className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-cyan-400 transition-colors disabled:opacity-50"
739
+ title="Push local content to Drive"
740
+ >
741
+ {exporting === ws.id ? (
742
+ <Loader2 className="w-2.5 h-2.5 animate-spin" />
743
+ ) : (
744
+ <Upload className="w-2.5 h-2.5" />
745
+ )}
746
+ {exporting === ws.id ? "Pushing…" : "Push to Drive"}
747
+ </button>
748
+ </div>
749
+ ) : showDriveAttach[ws.id] ||
750
+ changingDriveId === ws.id ? (
751
+ // URL paste form
752
+ <form
753
+ onSubmit={(e) => handleAttachDrive(ws, e)}
754
+ className="flex items-center gap-1"
755
+ >
756
+ <input
757
+ autoFocus
758
+ value={driveUrls[ws.id] ?? ""}
759
+ onChange={(e) =>
760
+ setDriveUrls((prev) => ({
761
+ ...prev,
762
+ [ws.id]: e.target.value,
763
+ }))
764
+ }
765
+ placeholder="Paste Drive share link…"
766
+ className="flex-1 min-w-0 bg-slate-800 border border-slate-700 rounded px-1.5 py-0.5 text-[10px] text-slate-300 placeholder-slate-600 focus:outline-none focus:border-cyan-600"
767
+ />
768
+ <button
769
+ type="submit"
770
+ disabled={
771
+ attaching === ws.id || !driveUrls[ws.id]?.trim()
772
+ }
773
+ className="flex items-center gap-0.5 text-[10px] text-cyan-500 hover:text-cyan-300 transition-colors disabled:opacity-40"
774
+ >
775
+ {attaching === ws.id ? (
776
+ <Loader2 className="w-2.5 h-2.5 animate-spin" />
777
+ ) : (
778
+ <Link className="w-2.5 h-2.5" />
779
+ )}
780
+ Connect
781
+ </button>
782
+ <button
783
+ type="button"
784
+ onClick={() => {
785
+ setShowDriveAttach((prev) => ({
786
+ ...prev,
787
+ [ws.id]: false,
788
+ }));
789
+ setChangingDriveId(null);
790
+ }}
791
+ className="text-[10px] text-slate-600 hover:text-slate-400 transition-colors"
792
+ >
793
+ Cancel
794
+ </button>
795
+ </form>
796
+ ) : (
797
+ // Default — small "Connect Drive" button
798
+ <button
799
+ onClick={() =>
800
+ setShowDriveAttach((prev) => ({
801
+ ...prev,
802
+ [ws.id]: true,
803
+ }))
804
+ }
805
+ className="flex items-center gap-1 text-[10px] text-slate-600 hover:text-cyan-400 transition-colors"
806
+ >
807
+ <Cloud className="w-2.5 h-2.5" />
808
+ Connect Drive
809
+ </button>
810
+ )}
811
+ </div>
812
+ )}
813
+ </div>
814
+ ))}
815
+ </div>
816
+
817
+ {/* New workspace */}
818
+ <div className="border-t border-slate-800">
819
+ {showNewForm ? (
820
+ <div className="p-2 space-y-1.5">
821
+ <input
822
+ autoFocus
823
+ value={newName}
824
+ onChange={(e) => setNewName(e.target.value)}
825
+ onKeyDown={(e) => {
826
+ if (e.key === "Enter") handleCreate();
827
+ if (e.key === "Escape") setShowNewForm(false);
828
+ }}
829
+ placeholder="Workspace name…"
830
+ className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-200 placeholder-slate-500 focus:outline-none focus:border-cyan-500"
831
+ />
832
+ {/* Type selector */}
833
+ <div className="flex gap-1">
834
+ <button
835
+ onClick={() => setNewType("local")}
836
+ className={`flex-1 flex items-center justify-center gap-1 py-1 rounded text-[11px] border transition-colors ${
837
+ newType === "local"
838
+ ? "bg-slate-700 border-slate-600 text-slate-200"
839
+ : "border-slate-800 text-slate-500 hover:text-slate-300"
840
+ }`}
841
+ >
842
+ <HardDrive className="w-2.5 h-2.5" />
843
+ Local
844
+ </button>
845
+ <button
846
+ onClick={() => setNewType("google_drive")}
847
+ className={`flex-1 flex items-center justify-center gap-1 py-1 rounded text-[11px] border transition-colors ${
848
+ newType === "google_drive"
849
+ ? "bg-cyan-500/20 border-cyan-600 text-cyan-300"
850
+ : "border-slate-800 text-slate-500 hover:text-slate-300"
851
+ }`}
852
+ >
853
+ <Cloud className="w-2.5 h-2.5" />
854
+ Drive
855
+ </button>
856
+ </div>
857
+ <div className="flex gap-1">
858
+ <button
859
+ onClick={handleCreate}
860
+ disabled={!newName.trim() || creating}
861
+ className="flex-1 py-1 bg-cyan-600 hover:bg-cyan-500 disabled:opacity-50 text-white text-xs rounded transition-colors"
862
+ >
863
+ {creating ? "Creating…" : "Create"}
864
+ </button>
865
+ <button
866
+ onClick={() => setShowNewForm(false)}
867
+ className="px-2 py-1 text-slate-500 hover:text-slate-300 transition-colors"
868
+ >
869
+ <X className="w-3 h-3" />
870
+ </button>
871
+ </div>
872
+ </div>
873
+ ) : (
874
+ <button
875
+ onClick={(e) => {
876
+ e.stopPropagation();
877
+ setShowNewForm(true);
878
+ }}
879
+ className="w-full flex items-center gap-1.5 px-3 py-2 text-xs text-slate-500 hover:text-slate-300 hover:bg-slate-800/40 transition-colors"
880
+ >
881
+ <Plus className="w-3.5 h-3.5" />
882
+ New workspace
883
+ </button>
884
+ )}
885
+ </div>
886
+ </div>
887
+ )}
888
+ </div>
889
+ </>
890
+ );
891
+ }