create-interview-cockpit 0.3.0 → 0.5.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 (28) hide show
  1. package/README.md +23 -0
  2. package/package.json +1 -1
  3. package/template/client/package-lock.json +42 -0
  4. package/template/client/package.json +5 -0
  5. package/template/client/src/App.tsx +45 -12
  6. package/template/client/src/api.ts +174 -0
  7. package/template/client/src/components/AiSettingsModal.tsx +1041 -0
  8. package/template/client/src/components/AnnotationDialog.tsx +3 -9
  9. package/template/client/src/components/ChatMessage.tsx +110 -27
  10. package/template/client/src/components/ChatView.tsx +239 -137
  11. package/template/client/src/components/CodeContextPanel.tsx +297 -0
  12. package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
  13. package/template/client/src/components/CodeRunnerModal.tsx +1549 -0
  14. package/template/client/src/components/DocRefModal.tsx +502 -0
  15. package/template/client/src/components/FileAttachments.tsx +109 -9
  16. package/template/client/src/components/FilePickerModal.tsx +181 -0
  17. package/template/client/src/components/FileViewerModal.tsx +406 -28
  18. package/template/client/src/components/MarkdownRenderer.tsx +210 -2
  19. package/template/client/src/components/Sidebar.tsx +213 -125
  20. package/template/client/src/components/TextAnnotator.tsx +8 -15
  21. package/template/client/src/components/VizCraftEmbed.tsx +645 -0
  22. package/template/client/src/store.ts +275 -0
  23. package/template/client/src/types.ts +9 -0
  24. package/template/cockpit.json +1 -1
  25. package/template/data/ai-settings.json +49 -0
  26. package/template/server/src/google-drive.ts +109 -1
  27. package/template/server/src/index.ts +1187 -76
  28. package/template/server/src/storage.ts +359 -2
@@ -0,0 +1,502 @@
1
+ import { useEffect, useRef, useState, useCallback, useMemo } from "react";
2
+ import {
3
+ X,
4
+ GripVertical,
5
+ Maximize2,
6
+ Minimize2,
7
+ Loader2,
8
+ FileText,
9
+ Search,
10
+ } from "lucide-react";
11
+
12
+ interface Props {
13
+ fileId: string;
14
+ quote: string;
15
+ fileName: string;
16
+ onClose: () => void;
17
+ }
18
+
19
+ const MIN_W = 480;
20
+ const MIN_H = 360;
21
+ const DEFAULT_W = 820;
22
+ const DEFAULT_H = 620;
23
+
24
+ type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n" | null;
25
+
26
+ const PDF_EXTS = new Set(["pdf"]);
27
+ const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg"]);
28
+
29
+ function getFileType(name: string): "pdf" | "image" | "text" {
30
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
31
+ if (PDF_EXTS.has(ext)) return "pdf";
32
+ if (IMAGE_EXTS.has(ext)) return "image";
33
+ return "text";
34
+ }
35
+
36
+ /** Split `text` around the first case-insensitive occurrence of `needle`. */
37
+ function splitOnQuote(text: string, needle: string): [string, string, string] {
38
+ if (!needle) return [text, "", ""];
39
+ const idx = text.toLowerCase().indexOf(needle.toLowerCase());
40
+ if (idx === -1) return [text, "", ""];
41
+ return [
42
+ text.slice(0, idx),
43
+ text.slice(idx, idx + needle.length),
44
+ text.slice(idx + needle.length),
45
+ ];
46
+ }
47
+
48
+ function viewUrl(fileId: string, fileName: string) {
49
+ return `/api/context-files/${encodeURIComponent(fileId)}/view?name=${encodeURIComponent(fileName)}`;
50
+ }
51
+
52
+ export default function DocRefModal({
53
+ fileId,
54
+ quote,
55
+ fileName,
56
+ onClose,
57
+ }: Props) {
58
+ const fileType = getFileType(fileName);
59
+ const baseViewUrl = viewUrl(fileId, fileName);
60
+
61
+ // Text mode state
62
+ const [content, setContent] = useState<string | null>(null);
63
+ const [loading, setLoading] = useState(fileType === "text");
64
+ const [fetchError, setFetchError] = useState<string | null>(null);
65
+ const highlightRef = useRef<HTMLElement | null>(null);
66
+
67
+ // For PDF files: toggle between native viewer and extracted-text view
68
+ // Chrome's native PDF viewer doesn't support #search= URL fragments
69
+ const [pdfViewMode, setPdfViewMode] = useState<"pdf" | "text">("pdf");
70
+ // What the user is typing in the search bar
71
+ const [searchInput, setSearchInput] = useState(() =>
72
+ quote.slice(0, 200).trim(),
73
+ );
74
+ // Search term applied to the text view (shared by text-file mode and PDF text mode)
75
+ const [textSearch, setTextSearch] = useState(() =>
76
+ quote.slice(0, 200).trim(),
77
+ );
78
+
79
+ const [maximized, setMaximized] = useState(false);
80
+ const [pos, setPos] = useState(() => ({
81
+ x: Math.max(0, (window.innerWidth - DEFAULT_W) / 2),
82
+ y: Math.max(0, (window.innerHeight - DEFAULT_H) / 2),
83
+ }));
84
+ const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H });
85
+
86
+ const dragStart = useRef<{
87
+ mx: number;
88
+ my: number;
89
+ ox: number;
90
+ oy: number;
91
+ } | null>(null);
92
+ const resizeDir = useRef<ResizeDir>(null);
93
+ const resizeStart = useRef<{
94
+ mx: number;
95
+ my: number;
96
+ ox: number;
97
+ oy: number;
98
+ ow: number;
99
+ oh: number;
100
+ } | null>(null);
101
+ const savedPos = useRef(pos);
102
+ const savedSize = useRef(size);
103
+
104
+ // Fetch extracted text — for text files on mount, or for PDFs when text view is activated
105
+ useEffect(() => {
106
+ const needsText =
107
+ fileType === "text" || (fileType === "pdf" && pdfViewMode === "text");
108
+ if (!needsText) return;
109
+ if (content !== null) return; // already loaded, skip re-fetch
110
+ setLoading(true);
111
+ setFetchError(null);
112
+ fetch(`/api/context-files/${encodeURIComponent(fileId)}/content`)
113
+ .then((r) => r.json())
114
+ .then((d) => {
115
+ if (d.error) setFetchError(d.error);
116
+ else setContent(d.content as string);
117
+ })
118
+ .catch(() => setFetchError("Failed to load document."))
119
+ .finally(() => setLoading(false));
120
+ // eslint-disable-next-line react-hooks/exhaustive-deps
121
+ }, [fileId, fileType, pdfViewMode]);
122
+
123
+ // Auto-scroll to highlighted passage once text loads
124
+ useEffect(() => {
125
+ if (!content || !highlightRef.current) return;
126
+ const id = setTimeout(() => {
127
+ highlightRef.current?.scrollIntoView({
128
+ behavior: "smooth",
129
+ block: "center",
130
+ });
131
+ }, 80);
132
+ return () => clearTimeout(id);
133
+ }, [content]);
134
+
135
+ // Text-mode rendered content with current search term highlighted
136
+ const renderedContent = useMemo(() => {
137
+ if (!content) return null;
138
+ const needle = textSearch.trim();
139
+ const [before, match, after] = splitOnQuote(content, needle);
140
+ if (!match) return <span>{content}</span>;
141
+ return (
142
+ <>
143
+ {before}
144
+ <mark
145
+ ref={(el) => {
146
+ highlightRef.current = el;
147
+ }}
148
+ className="bg-yellow-400/30 text-yellow-100 rounded-sm px-0.5 not-italic"
149
+ >
150
+ {match}
151
+ </mark>
152
+ {after}
153
+ </>
154
+ );
155
+ }, [content, textSearch]);
156
+
157
+ const handleSearchSubmit = (e: React.FormEvent) => {
158
+ e.preventDefault();
159
+ const term = searchInput.trim();
160
+ setTextSearch(term);
161
+ if (fileType === "pdf") {
162
+ // Switch to text view — Chrome's native PDF viewer doesn't support URL-fragment search
163
+ setPdfViewMode("text");
164
+ }
165
+ };
166
+
167
+ // ─── Drag ────────────────────────────────────────────────────
168
+
169
+ const onTitleMouseDown = useCallback(
170
+ (e: React.MouseEvent) => {
171
+ if (maximized) return;
172
+ e.preventDefault();
173
+ dragStart.current = {
174
+ mx: e.clientX,
175
+ my: e.clientY,
176
+ ox: pos.x,
177
+ oy: pos.y,
178
+ };
179
+ },
180
+ [maximized, pos],
181
+ );
182
+
183
+ useEffect(() => {
184
+ const onMove = (e: MouseEvent) => {
185
+ const drag = dragStart.current;
186
+ const resize = resizeStart.current;
187
+ const dir = resizeDir.current;
188
+ if (drag) {
189
+ setPos({
190
+ x: Math.max(0, drag.ox + e.clientX - drag.mx),
191
+ y: Math.max(0, drag.oy + e.clientY - drag.my),
192
+ });
193
+ }
194
+ if (resize && dir) {
195
+ const dx = e.clientX - resize.mx;
196
+ const dy = e.clientY - resize.my;
197
+ setSize((prev) => {
198
+ let w = prev.w;
199
+ let h = prev.h;
200
+ if (dir.includes("e")) w = Math.max(MIN_W, resize.ow + dx);
201
+ if (dir.includes("s")) h = Math.max(MIN_H, resize.oh + dy);
202
+ if (dir.includes("w")) w = Math.max(MIN_W, resize.ow - dx);
203
+ if (dir.includes("n")) h = Math.max(MIN_H, resize.oh - dy);
204
+ return { w, h };
205
+ });
206
+ if (dir.includes("w"))
207
+ setPos((prev) => ({
208
+ ...prev,
209
+ x: Math.min(resize.ox + resize.ow - MIN_W, resize.ox + dx),
210
+ }));
211
+ if (dir.includes("n"))
212
+ setPos((prev) => ({
213
+ ...prev,
214
+ y: Math.min(resize.oy + resize.oh - MIN_H, resize.oy + dy),
215
+ }));
216
+ }
217
+ };
218
+ const onUp = () => {
219
+ dragStart.current = null;
220
+ resizeStart.current = null;
221
+ resizeDir.current = null;
222
+ };
223
+ document.addEventListener("mousemove", onMove);
224
+ document.addEventListener("mouseup", onUp);
225
+ return () => {
226
+ document.removeEventListener("mousemove", onMove);
227
+ document.removeEventListener("mouseup", onUp);
228
+ };
229
+ }, []);
230
+
231
+ // ─── Resize handles ──────────────────────────────────────────
232
+
233
+ const startResize = useCallback(
234
+ (dir: ResizeDir) => (e: React.MouseEvent) => {
235
+ if (maximized) return;
236
+ e.preventDefault();
237
+ e.stopPropagation();
238
+ resizeDir.current = dir;
239
+ resizeStart.current = {
240
+ mx: e.clientX,
241
+ my: e.clientY,
242
+ ox: pos.x,
243
+ oy: pos.y,
244
+ ow: size.w,
245
+ oh: size.h,
246
+ };
247
+ },
248
+ [maximized, pos, size],
249
+ );
250
+
251
+ // ─── Maximise ────────────────────────────────────────────────
252
+
253
+ const toggleMax = useCallback(() => {
254
+ if (!maximized) {
255
+ savedPos.current = pos;
256
+ savedSize.current = size;
257
+ setMaximized(true);
258
+ } else {
259
+ setPos(savedPos.current);
260
+ setSize(savedSize.current);
261
+ setMaximized(false);
262
+ }
263
+ }, [maximized, pos, size]);
264
+
265
+ useEffect(() => {
266
+ const handler = (e: KeyboardEvent) => {
267
+ if (e.key === "Escape") onClose();
268
+ };
269
+ document.addEventListener("keydown", handler);
270
+ return () => document.removeEventListener("keydown", handler);
271
+ }, [onClose]);
272
+
273
+ const modalStyle: React.CSSProperties = maximized
274
+ ? {
275
+ position: "fixed",
276
+ inset: 0,
277
+ width: "100vw",
278
+ height: "100vh",
279
+ borderRadius: 0,
280
+ }
281
+ : {
282
+ position: "fixed",
283
+ left: pos.x,
284
+ top: pos.y,
285
+ width: size.w,
286
+ height: size.h,
287
+ minWidth: MIN_W,
288
+ minHeight: MIN_H,
289
+ };
290
+
291
+ return (
292
+ <>
293
+ <div
294
+ className="fixed z-50 flex flex-col bg-slate-900 border border-slate-700 rounded-xl shadow-2xl overflow-hidden select-none"
295
+ style={modalStyle}
296
+ >
297
+ {/* ── Title bar ── */}
298
+ <div
299
+ className="flex items-center gap-2 px-3 py-2 bg-slate-800 border-b border-slate-700 shrink-0"
300
+ onMouseDown={onTitleMouseDown}
301
+ style={{ cursor: maximized ? "default" : "grab" }}
302
+ >
303
+ <GripVertical className="w-3.5 h-3.5 text-slate-600 shrink-0" />
304
+ <FileText className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
305
+ <span
306
+ className="text-xs font-mono text-slate-300 truncate flex-1"
307
+ title={fileName}
308
+ >
309
+ {fileName}
310
+ </span>
311
+ <button
312
+ onClick={toggleMax}
313
+ className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
314
+ title={maximized ? "Restore" : "Maximise"}
315
+ >
316
+ {maximized ? (
317
+ <Minimize2 className="w-3.5 h-3.5" />
318
+ ) : (
319
+ <Maximize2 className="w-3.5 h-3.5" />
320
+ )}
321
+ </button>
322
+ <button
323
+ onClick={onClose}
324
+ className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-red-400 transition-colors shrink-0"
325
+ title="Close (Esc)"
326
+ >
327
+ <X className="w-3.5 h-3.5" />
328
+ </button>
329
+ </div>
330
+
331
+ {/* ── Search bar (PDF + text; hidden for plain images) ── */}
332
+ {fileType !== "image" && (
333
+ <form
334
+ onSubmit={handleSearchSubmit}
335
+ className="flex items-center gap-2 px-3 py-1.5 bg-slate-800/60 border-b border-slate-700/60 shrink-0"
336
+ onMouseDown={(e) => e.stopPropagation()}
337
+ >
338
+ <Search className="w-3.5 h-3.5 text-slate-500 shrink-0" />
339
+ <input
340
+ type="text"
341
+ value={searchInput}
342
+ onChange={(e) => {
343
+ setSearchInput(e.target.value);
344
+ // Text mode: update live so highlight follows typing
345
+ if (fileType === "text") setTextSearch(e.target.value);
346
+ }}
347
+ placeholder="Search in document…"
348
+ className="flex-1 bg-transparent text-xs text-slate-200 placeholder-slate-500 focus:outline-none min-w-0"
349
+ />
350
+ {fileType === "pdf" && (
351
+ <>
352
+ <button
353
+ type="submit"
354
+ className="px-2 py-0.5 text-[11px] rounded bg-slate-700 text-slate-300 hover:bg-slate-600 transition-colors shrink-0"
355
+ >
356
+ Find
357
+ </button>
358
+ {/* PDF / Text view toggle */}
359
+ <div className="flex items-center gap-px ml-1 shrink-0">
360
+ <button
361
+ type="button"
362
+ onClick={() => setPdfViewMode("pdf")}
363
+ className={`px-2 py-0.5 text-[11px] rounded-l transition-colors ${
364
+ pdfViewMode === "pdf"
365
+ ? "bg-emerald-700 text-emerald-100"
366
+ : "bg-slate-700 text-slate-400 hover:text-slate-200"
367
+ }`}
368
+ >
369
+ PDF
370
+ </button>
371
+ <button
372
+ type="button"
373
+ onClick={() => setPdfViewMode("text")}
374
+ className={`px-2 py-0.5 text-[11px] rounded-r transition-colors ${
375
+ pdfViewMode === "text"
376
+ ? "bg-emerald-700 text-emerald-100"
377
+ : "bg-slate-700 text-slate-400 hover:text-slate-200"
378
+ }`}
379
+ >
380
+ Text
381
+ </button>
382
+ </div>
383
+ </>
384
+ )}
385
+ </form>
386
+ )}
387
+
388
+ {/* ── Content area ── */}
389
+ <div className="flex-1 min-h-0 overflow-hidden">
390
+ {/* PDF — native viewer (default) */}
391
+ {fileType === "pdf" && pdfViewMode === "pdf" && (
392
+ <iframe
393
+ src={baseViewUrl}
394
+ className="w-full h-full border-0"
395
+ title={fileName}
396
+ />
397
+ )}
398
+
399
+ {/* PDF — extracted-text view with search highlight (Find button switches here) */}
400
+ {fileType === "pdf" && pdfViewMode === "text" && (
401
+ <>
402
+ {loading && (
403
+ <div className="flex items-center justify-center h-full">
404
+ <Loader2 className="w-5 h-5 text-emerald-400 animate-spin" />
405
+ </div>
406
+ )}
407
+ {fetchError && (
408
+ <div className="flex items-center justify-center h-full p-4">
409
+ <p className="text-sm text-red-400 text-center">
410
+ {fetchError}
411
+ </p>
412
+ </div>
413
+ )}
414
+ {!loading && !fetchError && content !== null && (
415
+ <div className="w-full h-full overflow-auto p-4">
416
+ <pre className="whitespace-pre-wrap text-slate-300 text-xs font-mono leading-relaxed">
417
+ {renderedContent}
418
+ </pre>
419
+ </div>
420
+ )}
421
+ </>
422
+ )}
423
+
424
+ {/* Image ── served directly from the view endpoint */}
425
+ {fileType === "image" && (
426
+ <div className="w-full h-full overflow-auto flex items-start justify-center p-4 bg-slate-950/40">
427
+ <img
428
+ src={baseViewUrl}
429
+ alt={fileName}
430
+ className="max-w-full object-contain rounded"
431
+ draggable={false}
432
+ />
433
+ </div>
434
+ )}
435
+
436
+ {/* Text / DOCX / other — extracted-text view with highlight */}
437
+ {fileType === "text" && (
438
+ <>
439
+ {loading && (
440
+ <div className="flex items-center justify-center h-full">
441
+ <Loader2 className="w-5 h-5 text-emerald-400 animate-spin" />
442
+ </div>
443
+ )}
444
+ {fetchError && (
445
+ <div className="flex items-center justify-center h-full p-4">
446
+ <p className="text-sm text-red-400 text-center">
447
+ {fetchError}
448
+ </p>
449
+ </div>
450
+ )}
451
+ {!loading && !fetchError && content !== null && (
452
+ <div className="w-full h-full overflow-auto p-4">
453
+ <pre className="whitespace-pre-wrap text-slate-300 text-xs font-mono leading-relaxed">
454
+ {renderedContent}
455
+ </pre>
456
+ </div>
457
+ )}
458
+ </>
459
+ )}
460
+ </div>
461
+
462
+ {/* ── Resize handles ── */}
463
+ {!maximized && (
464
+ <>
465
+ <div
466
+ onMouseDown={startResize("e")}
467
+ className="absolute top-0 right-0 w-1.5 h-full cursor-ew-resize z-10"
468
+ />
469
+ <div
470
+ onMouseDown={startResize("s")}
471
+ className="absolute bottom-0 left-0 w-full h-1.5 cursor-ns-resize z-10"
472
+ />
473
+ <div
474
+ onMouseDown={startResize("w")}
475
+ className="absolute top-0 left-0 w-1.5 h-full cursor-ew-resize z-10"
476
+ />
477
+ <div
478
+ onMouseDown={startResize("n")}
479
+ className="absolute top-0 left-0 w-full h-1.5 cursor-ns-resize z-10"
480
+ />
481
+ <div
482
+ onMouseDown={startResize("se")}
483
+ className="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize z-20"
484
+ />
485
+ <div
486
+ onMouseDown={startResize("sw")}
487
+ className="absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize z-20"
488
+ />
489
+ <div
490
+ onMouseDown={startResize("ne")}
491
+ className="absolute top-0 right-0 w-3 h-3 cursor-ne-resize z-20"
492
+ />
493
+ <div
494
+ onMouseDown={startResize("nw")}
495
+ className="absolute top-0 left-0 w-3 h-3 cursor-nw-resize z-20"
496
+ />
497
+ </>
498
+ )}
499
+ </div>
500
+ </>
501
+ );
502
+ }
@@ -1,23 +1,39 @@
1
- import { useRef } from "react";
1
+ import { useRef, useState } from "react";
2
2
  import type { ContextFile } from "../types";
3
- import { Paperclip, X, FileText } from "lucide-react";
3
+ import { Paperclip, X, FileText, Download, Link, Eye } from "lucide-react";
4
+ import FilePickerModal from "./FilePickerModal";
5
+ import { useStore } from "../store";
4
6
 
5
7
  interface Props {
6
8
  files: ContextFile[];
7
9
  onUpload: (files: FileList) => Promise<void>;
8
10
  onRemove: (fileId: string) => Promise<void>;
11
+ onLink?: (fileId: string, originalName: string) => Promise<void>;
12
+ /** URL prefix for downloads, e.g. "/api/topics/abc/context-files" */
13
+ downloadBase?: string;
9
14
  label: string;
10
15
  compact?: boolean;
11
16
  }
12
17
 
18
+ function downloadFile(url: string, filename: string) {
19
+ const a = document.createElement("a");
20
+ a.href = url;
21
+ a.download = filename;
22
+ a.click();
23
+ }
24
+
13
25
  export default function FileAttachments({
14
26
  files,
15
27
  onUpload,
16
28
  onRemove,
29
+ onLink,
30
+ downloadBase,
17
31
  label,
18
32
  compact,
19
33
  }: Props) {
20
34
  const inputRef = useRef<HTMLInputElement>(null);
35
+ const [showPicker, setShowPicker] = useState(false);
36
+ const openDocViewer = useStore((s) => s.openDocViewer);
21
37
 
22
38
  const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
23
39
  if (e.target.files?.length) {
@@ -29,6 +45,16 @@ export default function FileAttachments({
29
45
  if (compact) {
30
46
  return (
31
47
  <div className="flex items-center gap-1 flex-wrap">
48
+ {showPicker && onLink && (
49
+ <FilePickerModal
50
+ currentFiles={files}
51
+ onLink={async (fileId, originalName) => {
52
+ await onLink(fileId, originalName);
53
+ setShowPicker(false);
54
+ }}
55
+ onClose={() => setShowPicker(false)}
56
+ />
57
+ )}
32
58
  <input
33
59
  ref={inputRef}
34
60
  type="file"
@@ -44,6 +70,27 @@ export default function FileAttachments({
44
70
  >
45
71
  <FileText className="w-2.5 h-2.5" />
46
72
  {f.originalName}
73
+ <button
74
+ onClick={() => openDocViewer(f.id, "", f.originalName)}
75
+ className="hover:text-cyan-300 transition-colors"
76
+ title="View file"
77
+ >
78
+ <Eye className="w-2.5 h-2.5" />
79
+ </button>
80
+ {downloadBase && (
81
+ <button
82
+ onClick={() =>
83
+ downloadFile(
84
+ `${downloadBase}/${f.id}/download`,
85
+ f.originalName,
86
+ )
87
+ }
88
+ className="hover:text-cyan-300 transition-colors"
89
+ title="Download"
90
+ >
91
+ <Download className="w-2.5 h-2.5" />
92
+ </button>
93
+ )}
47
94
  <button
48
95
  onClick={() => onRemove(f.id)}
49
96
  className="hover:text-red-400 transition-colors"
@@ -60,12 +107,32 @@ export default function FileAttachments({
60
107
  <Paperclip className="w-2.5 h-2.5" />
61
108
  Attach
62
109
  </button>
110
+ {onLink && (
111
+ <button
112
+ onClick={() => setShowPicker(true)}
113
+ className="inline-flex items-center gap-0.5 text-[10px] text-slate-600 hover:text-cyan-400 transition-colors"
114
+ title="Link an already-uploaded file"
115
+ >
116
+ <Link className="w-2.5 h-2.5" />
117
+ Link
118
+ </button>
119
+ )}
63
120
  </div>
64
121
  );
65
122
  }
66
123
 
67
124
  return (
68
125
  <div>
126
+ {showPicker && onLink && (
127
+ <FilePickerModal
128
+ currentFiles={files}
129
+ onLink={async (fileId, originalName) => {
130
+ await onLink(fileId, originalName);
131
+ setShowPicker(false);
132
+ }}
133
+ onClose={() => setShowPicker(false)}
134
+ />
135
+ )}
69
136
  <input
70
137
  ref={inputRef}
71
138
  type="file"
@@ -84,6 +151,27 @@ export default function FileAttachments({
84
151
  >
85
152
  <FileText className="w-3 h-3 shrink-0" />
86
153
  <span className="truncate flex-1">{f.originalName}</span>
154
+ <button
155
+ onClick={() => openDocViewer(f.id, "", f.originalName)}
156
+ className="shrink-0 hover:text-cyan-300 transition-colors"
157
+ title="View file"
158
+ >
159
+ <Eye className="w-3 h-3" />
160
+ </button>
161
+ {downloadBase && (
162
+ <button
163
+ onClick={() =>
164
+ downloadFile(
165
+ `${downloadBase}/${f.id}/download`,
166
+ f.originalName,
167
+ )
168
+ }
169
+ className="shrink-0 hover:text-cyan-300 transition-colors"
170
+ title="Download"
171
+ >
172
+ <Download className="w-3 h-3" />
173
+ </button>
174
+ )}
87
175
  <button
88
176
  onClick={() => onRemove(f.id)}
89
177
  className="shrink-0 hover:text-red-400 transition-colors"
@@ -95,13 +183,25 @@ export default function FileAttachments({
95
183
  </div>
96
184
  )}
97
185
 
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>
186
+ <div className="flex items-center gap-3">
187
+ <button
188
+ onClick={() => inputRef.current?.click()}
189
+ className="flex items-center gap-1 text-xs text-slate-600 hover:text-violet-400 transition-colors"
190
+ >
191
+ <Paperclip className="w-3 h-3" />
192
+ Attach files to {label}
193
+ </button>
194
+ {onLink && (
195
+ <button
196
+ onClick={() => setShowPicker(true)}
197
+ className="flex items-center gap-1 text-xs text-slate-600 hover:text-cyan-400 transition-colors"
198
+ title="Link an already-uploaded file"
199
+ >
200
+ <Link className="w-3 h-3" />
201
+ Link existing
202
+ </button>
203
+ )}
204
+ </div>
105
205
  </div>
106
206
  );
107
207
  }