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.
- package/README.md +23 -0
- package/package.json +1 -1
- package/template/client/package-lock.json +42 -0
- package/template/client/package.json +5 -0
- package/template/client/src/App.tsx +45 -12
- package/template/client/src/api.ts +174 -0
- package/template/client/src/components/AiSettingsModal.tsx +1041 -0
- package/template/client/src/components/AnnotationDialog.tsx +3 -9
- package/template/client/src/components/ChatMessage.tsx +110 -27
- package/template/client/src/components/ChatView.tsx +239 -137
- package/template/client/src/components/CodeContextPanel.tsx +297 -0
- package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
- package/template/client/src/components/CodeRunnerModal.tsx +1549 -0
- package/template/client/src/components/DocRefModal.tsx +502 -0
- package/template/client/src/components/FileAttachments.tsx +109 -9
- package/template/client/src/components/FilePickerModal.tsx +181 -0
- package/template/client/src/components/FileViewerModal.tsx +406 -28
- package/template/client/src/components/MarkdownRenderer.tsx +210 -2
- package/template/client/src/components/Sidebar.tsx +213 -125
- package/template/client/src/components/TextAnnotator.tsx +8 -15
- package/template/client/src/components/VizCraftEmbed.tsx +645 -0
- package/template/client/src/store.ts +275 -0
- package/template/client/src/types.ts +9 -0
- package/template/cockpit.json +1 -1
- package/template/data/ai-settings.json +49 -0
- package/template/server/src/google-drive.ts +109 -1
- package/template/server/src/index.ts +1187 -76
- 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
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
}
|