create-interview-cockpit 0.1.1 → 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.
- package/package.json +1 -1
- package/template/client/src/App.tsx +7 -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 +188 -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
|
@@ -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
|
+
“{deleteTarget.name}”
|
|
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
|
+
}
|