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