create-interview-cockpit 0.1.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.
Files changed (39) hide show
  1. package/README.md +62 -0
  2. package/index.js +302 -0
  3. package/package.json +44 -0
  4. package/template/.env.example +14 -0
  5. package/template/client/index.html +12 -0
  6. package/template/client/package-lock.json +6012 -0
  7. package/template/client/package.json +34 -0
  8. package/template/client/postcss.config.cjs +6 -0
  9. package/template/client/src/App.tsx +120 -0
  10. package/template/client/src/api.ts +132 -0
  11. package/template/client/src/components/AnnotationDialog.tsx +307 -0
  12. package/template/client/src/components/ChatMessage.tsx +89 -0
  13. package/template/client/src/components/ChatView.tsx +763 -0
  14. package/template/client/src/components/CodeContextPanel.tsx +470 -0
  15. package/template/client/src/components/FileAttachments.tsx +107 -0
  16. package/template/client/src/components/FileViewerModal.tsx +470 -0
  17. package/template/client/src/components/MarkdownRenderer.tsx +333 -0
  18. package/template/client/src/components/MermaidDiagram.tsx +157 -0
  19. package/template/client/src/components/Sidebar.tsx +419 -0
  20. package/template/client/src/components/TextAnnotator.tsx +476 -0
  21. package/template/client/src/index.css +61 -0
  22. package/template/client/src/main.tsx +10 -0
  23. package/template/client/src/store.ts +321 -0
  24. package/template/client/src/types.ts +65 -0
  25. package/template/client/src/vite-env.d.ts +1 -0
  26. package/template/client/tailwind.config.cjs +8 -0
  27. package/template/client/tsconfig.json +16 -0
  28. package/template/client/tsconfig.tsbuildinfo +1 -0
  29. package/template/client/vite.config.ts +12 -0
  30. package/template/cockpit.json +3 -0
  31. package/template/data/context-files/.gitkeep +0 -0
  32. package/template/data/questions/.gitkeep +0 -0
  33. package/template/data/topics.json +1 -0
  34. package/template/package.json +14 -0
  35. package/template/server/package-lock.json +2266 -0
  36. package/template/server/package.json +31 -0
  37. package/template/server/src/index.ts +758 -0
  38. package/template/server/src/storage.ts +303 -0
  39. package/template/server/tsconfig.json +14 -0
@@ -0,0 +1,470 @@
1
+ import { useEffect, useState, useCallback } from "react";
2
+ import { useStore } from "../store";
3
+ import FileViewerModal from "./FileViewerModal";
4
+ import {
5
+ File,
6
+ Search,
7
+ FolderOpen,
8
+ Folder,
9
+ Check,
10
+ X,
11
+ ChevronRight,
12
+ ChevronDown,
13
+ Square,
14
+ CheckSquare,
15
+ MinusSquare,
16
+ Eye,
17
+ Scissors,
18
+ } from "lucide-react";
19
+
20
+ // ─── Tree data structure ─────────────────────────────────
21
+
22
+ interface TreeNode {
23
+ name: string;
24
+ path: string; // full relative path for folders (e.g. "client/src/app")
25
+ children: TreeNode[];
26
+ files: string[]; // full file paths that are direct children
27
+ }
28
+
29
+ function buildTree(paths: string[]): TreeNode {
30
+ const root: TreeNode = { name: "", path: "", children: [], files: [] };
31
+
32
+ for (const filePath of paths) {
33
+ const parts = filePath.split("/");
34
+ let node = root;
35
+
36
+ for (let i = 0; i < parts.length - 1; i++) {
37
+ const folderName = parts[i];
38
+ const folderPath = parts.slice(0, i + 1).join("/");
39
+ let child = node.children.find((c) => c.name === folderName);
40
+ if (!child) {
41
+ child = { name: folderName, path: folderPath, children: [], files: [] };
42
+ node.children.push(child);
43
+ }
44
+ node = child;
45
+ }
46
+ node.files.push(filePath);
47
+ }
48
+
49
+ return root;
50
+ }
51
+
52
+ /** Get all file paths under a tree node recursively */
53
+ function getAllFiles(node: TreeNode): string[] {
54
+ const result = [...node.files];
55
+ for (const child of node.children) {
56
+ result.push(...getAllFiles(child));
57
+ }
58
+ return result;
59
+ }
60
+
61
+ // ─── Folder node component ──────────────────────────────
62
+
63
+ interface FolderNodeProps {
64
+ node: TreeNode;
65
+ selectedFiles: string[];
66
+ onToggleFile: (path: string) => void;
67
+ onToggleFolder: (paths: string[]) => void;
68
+ expandedFolders: Set<string>;
69
+ onToggleExpand: (path: string) => void;
70
+ depth: number;
71
+ filter: string;
72
+ onOpenFile: (path: string) => void;
73
+ }
74
+
75
+ function FolderNode({
76
+ node,
77
+ selectedFiles,
78
+ onToggleFile,
79
+ onToggleFolder,
80
+ expandedFolders,
81
+ onToggleExpand,
82
+ depth,
83
+ filter,
84
+ onOpenFile,
85
+ }: FolderNodeProps) {
86
+ const allFiles = getAllFiles(node);
87
+ const selectedCount = allFiles.filter((f) =>
88
+ selectedFiles.includes(f),
89
+ ).length;
90
+ const isExpanded = expandedFolders.has(node.path);
91
+
92
+ // Filter: if searching, only show nodes that have matching files
93
+ const matchingFiles = filter
94
+ ? node.files.filter((f) => f.toLowerCase().includes(filter))
95
+ : node.files;
96
+ const hasMatchingDescendants = filter
97
+ ? allFiles.some((f) => f.toLowerCase().includes(filter))
98
+ : true;
99
+
100
+ if (filter && !hasMatchingDescendants) return null;
101
+
102
+ const checkState: "none" | "some" | "all" =
103
+ selectedCount === 0
104
+ ? "none"
105
+ : selectedCount === allFiles.length
106
+ ? "all"
107
+ : "some";
108
+
109
+ const CheckIcon =
110
+ checkState === "all"
111
+ ? CheckSquare
112
+ : checkState === "some"
113
+ ? MinusSquare
114
+ : Square;
115
+ const checkColor = checkState === "none" ? "text-slate-600" : "text-cyan-400";
116
+
117
+ return (
118
+ <div>
119
+ <div
120
+ className="flex items-center gap-0.5 py-0.5 cursor-pointer group"
121
+ style={{ paddingLeft: `${depth * 12 + 4}px` }}
122
+ >
123
+ <button
124
+ onClick={() => onToggleExpand(node.path)}
125
+ className="shrink-0 text-slate-500 hover:text-slate-300"
126
+ >
127
+ {isExpanded ? (
128
+ <ChevronDown className="w-3 h-3" />
129
+ ) : (
130
+ <ChevronRight className="w-3 h-3" />
131
+ )}
132
+ </button>
133
+ <button
134
+ onClick={() => onToggleFolder(allFiles)}
135
+ className={`shrink-0 ${checkColor} hover:text-cyan-300`}
136
+ >
137
+ <CheckIcon className="w-3.5 h-3.5" />
138
+ </button>
139
+ {isExpanded ? (
140
+ <FolderOpen className="w-3 h-3 text-amber-500/70 shrink-0" />
141
+ ) : (
142
+ <Folder className="w-3 h-3 text-amber-500/70 shrink-0" />
143
+ )}
144
+ <button
145
+ onClick={() => onToggleExpand(node.path)}
146
+ className="text-xs text-slate-400 hover:text-slate-200 truncate text-left flex-1"
147
+ >
148
+ {node.name}
149
+ </button>
150
+ {selectedCount > 0 && (
151
+ <span className="text-[10px] text-cyan-500/70 shrink-0 mr-1">
152
+ {selectedCount}
153
+ </span>
154
+ )}
155
+ </div>
156
+
157
+ {isExpanded && (
158
+ <div>
159
+ {node.children
160
+ .sort((a, b) => a.name.localeCompare(b.name))
161
+ .map((child) => (
162
+ <FolderNode
163
+ key={child.path}
164
+ node={child}
165
+ selectedFiles={selectedFiles}
166
+ onToggleFile={onToggleFile}
167
+ onToggleFolder={onToggleFolder}
168
+ expandedFolders={expandedFolders}
169
+ onToggleExpand={onToggleExpand}
170
+ depth={depth + 1}
171
+ filter={filter}
172
+ onOpenFile={onOpenFile}
173
+ />
174
+ ))}
175
+ {matchingFiles
176
+ .sort((a, b) => a.localeCompare(b))
177
+ .map((filePath) => {
178
+ const isSelected = selectedFiles.includes(filePath);
179
+ const fileName = filePath.split("/").pop()!;
180
+ return (
181
+ <div
182
+ key={filePath}
183
+ className={`flex items-center py-0.5 group ${
184
+ isSelected ? "text-cyan-400" : "text-slate-500"
185
+ }`}
186
+ style={{ paddingLeft: `${(depth + 1) * 12 + 4 + 14}px` }}
187
+ >
188
+ <button
189
+ onClick={() => onToggleFile(filePath)}
190
+ className="flex items-center gap-1 flex-1 min-w-0 hover:text-slate-300 transition-colors text-xs text-left"
191
+ >
192
+ <File className="w-3 h-3 shrink-0" />
193
+ <span className="truncate">{fileName}</span>
194
+ </button>
195
+ <button
196
+ onClick={() => onOpenFile(filePath)}
197
+ className="shrink-0 mr-1 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-cyan-400 transition-all"
198
+ title="View file"
199
+ >
200
+ <Eye className="w-3 h-3" />
201
+ </button>
202
+ </div>
203
+ );
204
+ })}
205
+ </div>
206
+ )}
207
+ </div>
208
+ );
209
+ }
210
+
211
+ // ─── Main panel ─────────────────────────────────────────
212
+
213
+ export default function CodeContextPanel() {
214
+ const {
215
+ availableFiles,
216
+ currentQuestion,
217
+ fetchAvailableFiles,
218
+ updateCodeContext,
219
+ codeSnippets,
220
+ removeSnippet,
221
+ clearSnippets,
222
+ } = useStore();
223
+
224
+ const [search, setSearch] = useState("");
225
+ const [selectedFiles, setSelectedFiles] = useState<string[]>(
226
+ currentQuestion?.codeContextFiles || [],
227
+ );
228
+ const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
229
+ new Set(),
230
+ );
231
+ const [viewingFile, setViewingFile] = useState<string | null>(null);
232
+
233
+ useEffect(() => {
234
+ fetchAvailableFiles();
235
+ }, []);
236
+
237
+ useEffect(() => {
238
+ setSelectedFiles(currentQuestion?.codeContextFiles || []);
239
+ }, [currentQuestion?.id]);
240
+
241
+ const tree = buildTree(availableFiles);
242
+
243
+ const toggleFile = useCallback(
244
+ (filePath: string) => {
245
+ setSelectedFiles((prev) => {
246
+ const next = prev.includes(filePath)
247
+ ? prev.filter((f) => f !== filePath)
248
+ : [...prev, filePath];
249
+ if (currentQuestion) updateCodeContext(currentQuestion.id, next);
250
+ return next;
251
+ });
252
+ },
253
+ [currentQuestion, updateCodeContext],
254
+ );
255
+
256
+ const toggleFolder = useCallback(
257
+ (folderFiles: string[]) => {
258
+ setSelectedFiles((prev) => {
259
+ const allSelected = folderFiles.every((f) => prev.includes(f));
260
+ const next = allSelected
261
+ ? prev.filter((f) => !folderFiles.includes(f))
262
+ : [...new Set([...prev, ...folderFiles])];
263
+ if (currentQuestion) updateCodeContext(currentQuestion.id, next);
264
+ return next;
265
+ });
266
+ },
267
+ [currentQuestion, updateCodeContext],
268
+ );
269
+
270
+ const toggleExpand = useCallback((path: string) => {
271
+ setExpandedFolders((prev) => {
272
+ const next = new Set(prev);
273
+ if (next.has(path)) {
274
+ next.delete(path);
275
+ } else {
276
+ next.add(path);
277
+ }
278
+ return next;
279
+ });
280
+ }, []);
281
+
282
+ const filter = search.toLowerCase();
283
+
284
+ return (
285
+ <div className="w-72 border-l border-slate-800 flex flex-col bg-slate-900/30 shrink-0">
286
+ {/* Header */}
287
+ <div className="border-b border-slate-800 px-3 py-2">
288
+ <div className="flex items-center justify-between mb-2">
289
+ <span className="text-xs font-bold uppercase tracking-wider text-slate-500">
290
+ Code Context
291
+ </span>
292
+ <FolderOpen className="w-3.5 h-3.5 text-slate-600" />
293
+ </div>
294
+ <div className="relative">
295
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-slate-600" />
296
+ <input
297
+ value={search}
298
+ onChange={(e) => setSearch(e.target.value)}
299
+ placeholder="Filter files..."
300
+ className="w-full bg-slate-800 border border-slate-700 rounded pl-7 pr-2 py-1 text-xs text-slate-300 placeholder-slate-600 focus:outline-none focus:border-cyan-500"
301
+ />
302
+ </div>
303
+ </div>
304
+
305
+ {/* Snippets section */}
306
+ {codeSnippets.length > 0 && (
307
+ <div className="border-b border-slate-800 px-3 py-2">
308
+ <div className="flex items-center justify-between mb-1">
309
+ <div className="flex items-center gap-1">
310
+ <Scissors className="w-3 h-3 text-amber-400/70" />
311
+ <span className="text-[10px] uppercase tracking-wider text-slate-600">
312
+ Snippets ({codeSnippets.length})
313
+ </span>
314
+ </div>
315
+ <button
316
+ onClick={clearSnippets}
317
+ className="text-[10px] text-red-400/60 hover:text-red-400"
318
+ >
319
+ Clear all
320
+ </button>
321
+ </div>
322
+ <div className="space-y-0.5 max-h-32 overflow-y-auto">
323
+ {codeSnippets.map((s) => (
324
+ <div
325
+ key={s.id}
326
+ className="flex items-start gap-1 text-xs bg-amber-500/10 border border-amber-500/20 rounded px-1.5 py-1 group"
327
+ >
328
+ <div className="flex-1 min-w-0">
329
+ <span className="text-amber-400 font-medium">
330
+ {s.fileName}
331
+ </span>
332
+ <span className="text-slate-500 mx-1">&rsaquo;</span>
333
+ <span className="text-slate-500">
334
+ line{s.startLine !== s.endLine ? "s" : ""}{" "}
335
+ {s.startLine === s.endLine
336
+ ? s.startLine
337
+ : `${s.startLine}–${s.endLine}`}
338
+ </span>
339
+ <p className="text-[10px] font-mono text-slate-600 mt-0.5 truncate">
340
+ {s.code.split("\n")[0]}
341
+ </p>
342
+ </div>
343
+ <button
344
+ onClick={() => removeSnippet(s.id)}
345
+ className="shrink-0 mt-0.5 text-slate-600 hover:text-red-400 transition-colors"
346
+ >
347
+ <X className="w-2.5 h-2.5" />
348
+ </button>
349
+ </div>
350
+ ))}
351
+ </div>
352
+ </div>
353
+ )}
354
+
355
+ {/* Selected summary */}
356
+ {selectedFiles.length > 0 && (
357
+ <div className="border-b border-slate-800 px-3 py-2">
358
+ <div className="flex items-center justify-between">
359
+ <span className="text-[10px] uppercase tracking-wider text-slate-600">
360
+ Selected ({selectedFiles.length})
361
+ </span>
362
+ <button
363
+ onClick={() => {
364
+ setSelectedFiles([]);
365
+ if (currentQuestion) updateCodeContext(currentQuestion.id, []);
366
+ }}
367
+ className="text-[10px] text-red-400/60 hover:text-red-400"
368
+ >
369
+ Clear all
370
+ </button>
371
+ </div>
372
+ <div className="mt-1 space-y-0.5 max-h-24 overflow-y-auto">
373
+ {selectedFiles.map((f) => (
374
+ <div
375
+ key={f}
376
+ className="flex items-center gap-1 text-xs text-cyan-400 bg-cyan-500/10 rounded px-1.5 py-0.5 group"
377
+ >
378
+ <Check className="w-2.5 h-2.5 shrink-0" />
379
+ <button
380
+ onClick={() => setViewingFile(f)}
381
+ className="truncate flex-1 text-left hover:underline"
382
+ title="View file"
383
+ >
384
+ {f.split("/").pop()}
385
+ </button>
386
+ <button
387
+ onClick={() => toggleFile(f)}
388
+ className="shrink-0 hover:text-red-400"
389
+ >
390
+ <X className="w-2.5 h-2.5" />
391
+ </button>
392
+ </div>
393
+ ))}
394
+ </div>
395
+ </div>
396
+ )}
397
+
398
+ {/* Tree browser */}
399
+ <div className="flex-1 overflow-y-auto py-1">
400
+ {!currentQuestion ? (
401
+ <div className="p-3 text-center">
402
+ <p className="text-xs text-slate-600">Select a question first</p>
403
+ </div>
404
+ ) : availableFiles.length === 0 ? (
405
+ <div className="p-3 text-center">
406
+ <p className="text-xs text-slate-600">
407
+ Set CODE_CONTEXT_DIR in .env
408
+ </p>
409
+ <p className="text-xs text-slate-700 mt-1">
410
+ to browse project files
411
+ </p>
412
+ </div>
413
+ ) : (
414
+ tree.children
415
+ .sort((a, b) => a.name.localeCompare(b.name))
416
+ .map((child) => (
417
+ <FolderNode
418
+ key={child.path}
419
+ node={child}
420
+ selectedFiles={selectedFiles}
421
+ onToggleFile={toggleFile}
422
+ onToggleFolder={toggleFolder}
423
+ expandedFolders={expandedFolders}
424
+ onToggleExpand={toggleExpand}
425
+ depth={0}
426
+ filter={filter}
427
+ onOpenFile={setViewingFile}
428
+ />
429
+ ))
430
+ )}
431
+ {/* Root-level files (if any) */}
432
+ {tree.files
433
+ .filter((f) => !filter || f.toLowerCase().includes(filter))
434
+ .map((filePath) => {
435
+ const isSelected = selectedFiles.includes(filePath);
436
+ return (
437
+ <div
438
+ key={filePath}
439
+ className={`flex items-center px-4 py-0.5 group ${
440
+ isSelected ? "text-cyan-400" : "text-slate-500"
441
+ }`}
442
+ >
443
+ <button
444
+ onClick={() => toggleFile(filePath)}
445
+ className="flex items-center gap-1.5 flex-1 min-w-0 hover:text-slate-300 transition-colors text-xs text-left"
446
+ >
447
+ <File className="w-3 h-3 shrink-0" />
448
+ <span className="truncate">{filePath}</span>
449
+ </button>
450
+ <button
451
+ onClick={() => setViewingFile(filePath)}
452
+ className="shrink-0 mr-1 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-cyan-400 transition-all"
453
+ title="View file"
454
+ >
455
+ <Eye className="w-3 h-3" />
456
+ </button>
457
+ </div>
458
+ );
459
+ })}
460
+ </div>
461
+
462
+ {viewingFile && (
463
+ <FileViewerModal
464
+ filePath={viewingFile}
465
+ onClose={() => setViewingFile(null)}
466
+ />
467
+ )}
468
+ </div>
469
+ );
470
+ }
@@ -0,0 +1,107 @@
1
+ import { useRef } from "react";
2
+ import type { ContextFile } from "../types";
3
+ import { Paperclip, X, FileText } from "lucide-react";
4
+
5
+ interface Props {
6
+ files: ContextFile[];
7
+ onUpload: (files: FileList) => Promise<void>;
8
+ onRemove: (fileId: string) => Promise<void>;
9
+ label: string;
10
+ compact?: boolean;
11
+ }
12
+
13
+ export default function FileAttachments({
14
+ files,
15
+ onUpload,
16
+ onRemove,
17
+ label,
18
+ compact,
19
+ }: Props) {
20
+ const inputRef = useRef<HTMLInputElement>(null);
21
+
22
+ const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
23
+ if (e.target.files?.length) {
24
+ await onUpload(e.target.files);
25
+ e.target.value = "";
26
+ }
27
+ };
28
+
29
+ if (compact) {
30
+ return (
31
+ <div className="flex items-center gap-1 flex-wrap">
32
+ <input
33
+ ref={inputRef}
34
+ type="file"
35
+ multiple
36
+ onChange={handleChange}
37
+ className="hidden"
38
+ accept=".txt,.md,.ts,.tsx,.js,.jsx,.json,.css,.scss,.html,.xml,.yaml,.yml,.csv,.py,.java,.cs,.go,.rs,.sql,.sh,.toml,.ini,.log,.cfg,.conf,.env,.pdf,.docx"
39
+ />
40
+ {files.map((f) => (
41
+ <span
42
+ key={f.id}
43
+ className="inline-flex items-center gap-1 bg-violet-500/10 text-violet-400 px-1.5 py-0.5 rounded text-[10px]"
44
+ >
45
+ <FileText className="w-2.5 h-2.5" />
46
+ {f.originalName}
47
+ <button
48
+ onClick={() => onRemove(f.id)}
49
+ className="hover:text-red-400 transition-colors"
50
+ >
51
+ <X className="w-2.5 h-2.5" />
52
+ </button>
53
+ </span>
54
+ ))}
55
+ <button
56
+ onClick={() => inputRef.current?.click()}
57
+ className="inline-flex items-center gap-0.5 text-[10px] text-slate-600 hover:text-violet-400 transition-colors"
58
+ title={`Attach files to ${label}`}
59
+ >
60
+ <Paperclip className="w-2.5 h-2.5" />
61
+ Attach
62
+ </button>
63
+ </div>
64
+ );
65
+ }
66
+
67
+ return (
68
+ <div>
69
+ <input
70
+ ref={inputRef}
71
+ type="file"
72
+ multiple
73
+ onChange={handleChange}
74
+ className="hidden"
75
+ accept=".txt,.md,.ts,.tsx,.js,.jsx,.json,.css,.scss,.html,.xml,.yaml,.yml,.csv,.py,.java,.cs,.go,.rs,.sql,.sh,.toml,.ini,.log,.cfg,.conf,.env,.pdf,.docx"
76
+ />
77
+
78
+ {files.length > 0 && (
79
+ <div className="space-y-0.5 mb-1.5">
80
+ {files.map((f) => (
81
+ <div
82
+ key={f.id}
83
+ className="flex items-center gap-1.5 bg-violet-500/10 rounded px-2 py-0.5 text-xs text-violet-400"
84
+ >
85
+ <FileText className="w-3 h-3 shrink-0" />
86
+ <span className="truncate flex-1">{f.originalName}</span>
87
+ <button
88
+ onClick={() => onRemove(f.id)}
89
+ className="shrink-0 hover:text-red-400 transition-colors"
90
+ >
91
+ <X className="w-3 h-3" />
92
+ </button>
93
+ </div>
94
+ ))}
95
+ </div>
96
+ )}
97
+
98
+ <button
99
+ onClick={() => inputRef.current?.click()}
100
+ className="flex items-center gap-1 text-xs text-slate-600 hover:text-violet-400 transition-colors w-full"
101
+ >
102
+ <Paperclip className="w-3 h-3" />
103
+ Attach files to {label}
104
+ </button>
105
+ </div>
106
+ );
107
+ }