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, useRef, useState, useCallback } from "react";
2
+ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
3
+ import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
4
+ import {
5
+ X,
6
+ GripVertical,
7
+ Maximize2,
8
+ Minimize2,
9
+ Loader2,
10
+ Plus,
11
+ Check,
12
+ } from "lucide-react";
13
+ import { useStore } from "../store";
14
+ import type { CodeSnippet } from "../types";
15
+
16
+ interface Props {
17
+ filePath: string;
18
+ onClose: () => void;
19
+ }
20
+
21
+ /** Derive a Prism language tag from the file extension. */
22
+ function getLang(filePath: string): string {
23
+ const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
24
+ const map: Record<string, string> = {
25
+ ts: "typescript",
26
+ tsx: "tsx",
27
+ js: "javascript",
28
+ jsx: "jsx",
29
+ py: "python",
30
+ rs: "rust",
31
+ go: "go",
32
+ java: "java",
33
+ kt: "kotlin",
34
+ cs: "csharp",
35
+ cpp: "cpp",
36
+ c: "c",
37
+ h: "c",
38
+ rb: "ruby",
39
+ php: "php",
40
+ swift: "swift",
41
+ sh: "bash",
42
+ bash: "bash",
43
+ zsh: "bash",
44
+ ps1: "powershell",
45
+ json: "json",
46
+ yaml: "yaml",
47
+ yml: "yaml",
48
+ toml: "toml",
49
+ xml: "xml",
50
+ html: "html",
51
+ css: "css",
52
+ scss: "scss",
53
+ sass: "scss",
54
+ less: "less",
55
+ sql: "sql",
56
+ md: "markdown",
57
+ mdx: "markdown",
58
+ graphql: "graphql",
59
+ gql: "graphql",
60
+ dockerfile: "docker",
61
+ };
62
+ return map[ext] ?? "text";
63
+ }
64
+
65
+ const MIN_W = 380;
66
+ const MIN_H = 260;
67
+ const DEFAULT_W = 720;
68
+ const DEFAULT_H = 520;
69
+
70
+ type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n" | null;
71
+
72
+ export default function FileViewerModal({ filePath, onClose }: Props) {
73
+ const { addSnippet } = useStore();
74
+ const [content, setContent] = useState<string | null>(null);
75
+ const [loading, setLoading] = useState(true);
76
+ const [error, setError] = useState<string | null>(null);
77
+ const [maximized, setMaximized] = useState(false);
78
+ const [selectedLines, setSelectedLines] = useState<Set<number>>(new Set());
79
+ const [addedFeedback, setAddedFeedback] = useState(false);
80
+ const lastClickedLineRef = useRef<number | null>(null);
81
+
82
+ // Position & size (pre-maximise)
83
+ const [pos, setPos] = useState(() => ({
84
+ x: Math.max(0, (window.innerWidth - DEFAULT_W) / 2),
85
+ y: Math.max(0, (window.innerHeight - DEFAULT_H) / 2),
86
+ }));
87
+ const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H });
88
+
89
+ const dragStart = useRef<{
90
+ mx: number;
91
+ my: number;
92
+ ox: number;
93
+ oy: number;
94
+ } | null>(null);
95
+ const resizeDir = useRef<ResizeDir>(null);
96
+ const resizeStart = useRef<{
97
+ mx: number;
98
+ my: number;
99
+ ox: number;
100
+ oy: number;
101
+ ow: number;
102
+ oh: number;
103
+ } | null>(null);
104
+ const savedPos = useRef(pos);
105
+ const savedSize = useRef(size);
106
+
107
+ const fileName = filePath.split("/").pop() ?? filePath;
108
+
109
+ // Fetch file content
110
+ useEffect(() => {
111
+ setLoading(true);
112
+ setError(null);
113
+ setSelectedLines(new Set());
114
+ lastClickedLineRef.current = null;
115
+ fetch(`/api/code-context/file?path=${encodeURIComponent(filePath)}`)
116
+ .then((r) => r.json())
117
+ .then((d) => {
118
+ if (d.error) setError(d.error);
119
+ else setContent(d.content as string);
120
+ })
121
+ .catch(() => setError("Failed to load file."))
122
+ .finally(() => setLoading(false));
123
+ }, [filePath]);
124
+
125
+ const handleLineClick = useCallback(
126
+ (e: React.MouseEvent, lineNumber: number) => {
127
+ // Don't toggle if user was drag-selecting text
128
+ if (window.getSelection()?.toString()) return;
129
+ setSelectedLines((prev) => {
130
+ const next = new Set(prev);
131
+ if (e.shiftKey && lastClickedLineRef.current !== null) {
132
+ const lo = Math.min(lastClickedLineRef.current, lineNumber);
133
+ const hi = Math.max(lastClickedLineRef.current, lineNumber);
134
+ for (let i = lo; i <= hi; i++) next.add(i);
135
+ } else {
136
+ if (next.has(lineNumber)) next.delete(lineNumber);
137
+ else next.add(lineNumber);
138
+ }
139
+ return next;
140
+ });
141
+ lastClickedLineRef.current = lineNumber;
142
+ },
143
+ [],
144
+ );
145
+
146
+ const handleAddSnippet = useCallback(() => {
147
+ if (!content || selectedLines.size === 0) return;
148
+ const lines = content.split("\n");
149
+ const sortedNums = [...selectedLines].sort((a, b) => a - b);
150
+ const code = sortedNums.map((n) => lines[n - 1] ?? "").join("\n");
151
+ const snippet: CodeSnippet = {
152
+ id: crypto.randomUUID(),
153
+ filePath,
154
+ fileName,
155
+ startLine: sortedNums[0],
156
+ endLine: sortedNums[sortedNums.length - 1],
157
+ code,
158
+ };
159
+ addSnippet(snippet);
160
+ setSelectedLines(new Set());
161
+ lastClickedLineRef.current = null;
162
+ setAddedFeedback(true);
163
+ setTimeout(() => setAddedFeedback(false), 1500);
164
+ }, [content, selectedLines, filePath, fileName, addSnippet]);
165
+
166
+ // ─── Drag ────────────────────────────────────────────
167
+
168
+ const onTitleMouseDown = useCallback(
169
+ (e: React.MouseEvent) => {
170
+ if (maximized) return;
171
+ e.preventDefault();
172
+ dragStart.current = {
173
+ mx: e.clientX,
174
+ my: e.clientY,
175
+ ox: pos.x,
176
+ oy: pos.y,
177
+ };
178
+ },
179
+ [maximized, pos],
180
+ );
181
+
182
+ useEffect(() => {
183
+ const onMove = (e: MouseEvent) => {
184
+ // Snapshot refs at the start — state setters run asynchronously and the
185
+ // refs may be nulled by onUp before the callback fires.
186
+ const drag = dragStart.current;
187
+ const resize = resizeStart.current;
188
+ const dir = resizeDir.current;
189
+
190
+ if (drag) {
191
+ const dx = e.clientX - drag.mx;
192
+ const dy = e.clientY - drag.my;
193
+ setPos({
194
+ x: Math.max(0, drag.ox + dx),
195
+ y: Math.max(0, drag.oy + dy),
196
+ });
197
+ }
198
+ if (resize && dir) {
199
+ const dx = e.clientX - resize.mx;
200
+ const dy = e.clientY - resize.my;
201
+
202
+ setSize((prev) => {
203
+ let w = prev.w;
204
+ let h = prev.h;
205
+ if (dir.includes("e")) w = Math.max(MIN_W, resize.ow + dx);
206
+ if (dir.includes("s")) h = Math.max(MIN_H, resize.oh + dy);
207
+ if (dir.includes("w")) w = Math.max(MIN_W, resize.ow - dx);
208
+ if (dir.includes("n")) h = Math.max(MIN_H, resize.oh - dy);
209
+ return { w, h };
210
+ });
211
+
212
+ if (dir.includes("w")) {
213
+ setPos((prev) => ({
214
+ ...prev,
215
+ x: Math.min(resize.ox + resize.ow - MIN_W, resize.ox + dx),
216
+ }));
217
+ }
218
+ if (dir.includes("n")) {
219
+ setPos((prev) => ({
220
+ ...prev,
221
+ y: Math.min(resize.oy + resize.oh - MIN_H, resize.oy + dy),
222
+ }));
223
+ }
224
+ }
225
+ };
226
+ const onUp = () => {
227
+ dragStart.current = null;
228
+ resizeStart.current = null;
229
+ resizeDir.current = null;
230
+ };
231
+ document.addEventListener("mousemove", onMove);
232
+ document.addEventListener("mouseup", onUp);
233
+ return () => {
234
+ document.removeEventListener("mousemove", onMove);
235
+ document.removeEventListener("mouseup", onUp);
236
+ };
237
+ }, []);
238
+
239
+ // ─── Resize handles ──────────────────────────────────
240
+
241
+ const startResize = useCallback(
242
+ (dir: ResizeDir) => (e: React.MouseEvent) => {
243
+ if (maximized) return;
244
+ e.preventDefault();
245
+ e.stopPropagation();
246
+ resizeDir.current = dir;
247
+ resizeStart.current = {
248
+ mx: e.clientX,
249
+ my: e.clientY,
250
+ ox: pos.x,
251
+ oy: pos.y,
252
+ ow: size.w,
253
+ oh: size.h,
254
+ };
255
+ },
256
+ [maximized, pos, size],
257
+ );
258
+
259
+ // ─── Maximise ────────────────────────────────────────
260
+
261
+ const toggleMax = useCallback(() => {
262
+ if (!maximized) {
263
+ savedPos.current = pos;
264
+ savedSize.current = size;
265
+ setMaximized(true);
266
+ } else {
267
+ setPos(savedPos.current);
268
+ setSize(savedSize.current);
269
+ setMaximized(false);
270
+ }
271
+ }, [maximized, pos, size]);
272
+
273
+ // ─── Keyboard close ──────────────────────────────────
274
+
275
+ useEffect(() => {
276
+ const handler = (e: KeyboardEvent) => {
277
+ if (e.key === "Escape") onClose();
278
+ };
279
+ document.addEventListener("keydown", handler);
280
+ return () => document.removeEventListener("keydown", handler);
281
+ }, [onClose]);
282
+
283
+ const lang = getLang(filePath);
284
+
285
+ const style: React.CSSProperties = maximized
286
+ ? {
287
+ position: "fixed",
288
+ inset: 0,
289
+ width: "100vw",
290
+ height: "100vh",
291
+ borderRadius: 0,
292
+ }
293
+ : {
294
+ position: "fixed",
295
+ left: pos.x,
296
+ top: pos.y,
297
+ width: size.w,
298
+ height: size.h,
299
+ minWidth: MIN_W,
300
+ minHeight: MIN_H,
301
+ };
302
+
303
+ return (
304
+ <>
305
+ {/* Modal */}
306
+ <div
307
+ className="fixed z-50 flex flex-col bg-slate-900 border border-slate-700 rounded-xl shadow-2xl overflow-hidden select-none"
308
+ style={style}
309
+ >
310
+ {/* ── Title bar ── */}
311
+ <div
312
+ className="flex items-center gap-2 px-3 py-2 bg-slate-800 border-b border-slate-700 shrink-0 cursor-default"
313
+ onMouseDown={onTitleMouseDown}
314
+ style={{ cursor: maximized ? "default" : "grab" }}
315
+ >
316
+ <GripVertical className="w-3.5 h-3.5 text-slate-600 shrink-0" />
317
+ <span
318
+ className="text-xs font-mono text-slate-300 truncate flex-1"
319
+ title={filePath}
320
+ >
321
+ {fileName}
322
+ </span>
323
+ <span className="text-[10px] text-slate-600 shrink-0 uppercase tracking-wider hidden sm:block">
324
+ {lang}
325
+ </span>
326
+ <button
327
+ onClick={toggleMax}
328
+ className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
329
+ title={maximized ? "Restore" : "Maximise"}
330
+ >
331
+ {maximized ? (
332
+ <Minimize2 className="w-3.5 h-3.5" />
333
+ ) : (
334
+ <Maximize2 className="w-3.5 h-3.5" />
335
+ )}
336
+ </button>
337
+ <button
338
+ onClick={onClose}
339
+ className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-red-400 transition-colors shrink-0"
340
+ title="Close (Esc)"
341
+ >
342
+ <X className="w-3.5 h-3.5" />
343
+ </button>
344
+ </div>
345
+
346
+ {/* ── Content ── */}
347
+ <div className="flex-1 overflow-auto">
348
+ {loading && (
349
+ <div className="flex items-center justify-center h-full">
350
+ <Loader2 className="w-5 h-5 text-cyan-400 animate-spin" />
351
+ </div>
352
+ )}
353
+ {error && (
354
+ <div className="flex items-center justify-center h-full p-4">
355
+ <p className="text-sm text-red-400 text-center">{error}</p>
356
+ </div>
357
+ )}
358
+ {!loading && !error && content !== null && (
359
+ <SyntaxHighlighter
360
+ language={lang}
361
+ style={oneDark}
362
+ showLineNumbers
363
+ wrapLines
364
+ wrapLongLines={false}
365
+ lineProps={(lineNumber) => ({
366
+ onClick: (e: React.MouseEvent) =>
367
+ lineNumber !== undefined && handleLineClick(e, lineNumber),
368
+ style: {
369
+ display: "block",
370
+ cursor: "pointer",
371
+ backgroundColor: selectedLines.has(lineNumber)
372
+ ? "rgba(6, 182, 212, 0.15)"
373
+ : undefined,
374
+ outline: selectedLines.has(lineNumber)
375
+ ? "1px solid rgba(6, 182, 212, 0.3)"
376
+ : undefined,
377
+ },
378
+ })}
379
+ customStyle={{
380
+ margin: 0,
381
+ borderRadius: 0,
382
+ background: "#0f172a",
383
+ fontSize: "0.75rem",
384
+ lineHeight: "1.6",
385
+ minHeight: "100%",
386
+ }}
387
+ lineNumberStyle={{ color: "#334155", minWidth: "2.8em" }}
388
+ >
389
+ {content}
390
+ </SyntaxHighlighter>
391
+ )}
392
+ </div>
393
+
394
+ {/* ── Snippet selection toolbar ── */}
395
+ {selectedLines.size > 0 && (
396
+ <div className="shrink-0 border-t border-cyan-900/50 bg-slate-800 px-3 py-2 flex items-center gap-2">
397
+ <span className="text-xs text-slate-400">
398
+ {selectedLines.size} line{selectedLines.size !== 1 ? "s" : ""}{" "}
399
+ selected
400
+ </span>
401
+ <span className="flex-1" />
402
+ <button
403
+ onClick={() => {
404
+ setSelectedLines(new Set());
405
+ lastClickedLineRef.current = null;
406
+ }}
407
+ className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
408
+ >
409
+ Clear
410
+ </button>
411
+ <button
412
+ onClick={handleAddSnippet}
413
+ className="flex items-center gap-1 text-xs bg-cyan-700 hover:bg-cyan-600 text-white px-2.5 py-1 rounded-md transition-colors"
414
+ >
415
+ {addedFeedback ? (
416
+ <>
417
+ <Check className="w-3 h-3" /> Added!
418
+ </>
419
+ ) : (
420
+ <>
421
+ <Plus className="w-3 h-3" /> Add to context
422
+ </>
423
+ )}
424
+ </button>
425
+ </div>
426
+ )}
427
+
428
+ {/* ── Resize handles (hidden when maximized) ── */}
429
+ {!maximized && (
430
+ <>
431
+ {/* edges */}
432
+ <div
433
+ onMouseDown={startResize("e")}
434
+ className="absolute top-0 right-0 w-1.5 h-full cursor-ew-resize z-10"
435
+ />
436
+ <div
437
+ onMouseDown={startResize("s")}
438
+ className="absolute bottom-0 left-0 w-full h-1.5 cursor-ns-resize z-10"
439
+ />
440
+ <div
441
+ onMouseDown={startResize("w")}
442
+ className="absolute top-0 left-0 w-1.5 h-full cursor-ew-resize z-10"
443
+ />
444
+ <div
445
+ onMouseDown={startResize("n")}
446
+ className="absolute top-0 left-0 w-full h-1.5 cursor-ns-resize z-10"
447
+ />
448
+ {/* corners */}
449
+ <div
450
+ onMouseDown={startResize("se")}
451
+ className="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize z-20"
452
+ />
453
+ <div
454
+ onMouseDown={startResize("sw")}
455
+ className="absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize z-20"
456
+ />
457
+ <div
458
+ onMouseDown={startResize("ne")}
459
+ className="absolute top-0 right-0 w-3 h-3 cursor-ne-resize z-20"
460
+ />
461
+ <div
462
+ onMouseDown={startResize("nw")}
463
+ className="absolute top-0 left-0 w-3 h-3 cursor-nw-resize z-20"
464
+ />
465
+ </>
466
+ )}
467
+ </div>
468
+ </>
469
+ );
470
+ }