17.4 KB514 lines
Blame
1"use client";
2
3import { useState, useEffect, useCallback, useRef } from "react";
4import dynamic from "next/dynamic";
5import { collab as collabApi } from "@/lib/api";
6import { useAuth } from "@/lib/auth";
7import { CollabProvider, useCollab } from "./collab-provider";
8import { DiagramDrawer, type Diagram } from "./diagram-drawer";
9import { NotesSidebar } from "./notes-sidebar";
10import { useCollabNavActions } from "../collab-nav-actions";
11
12// Dynamically import heavy components (no SSR)
13const DiagramViewer = dynamic(
14 () => import("./diagram-viewer").then((m) => ({ default: m.DiagramViewer })),
15 { ssr: false, loading: () => <div className="flex-1" /> }
16);
17
18import type { CodeEditorHandle } from "./code-editor";
19
20const CodeEditor = dynamic(
21 () => import("./code-editor").then((m) => ({ default: m.CodeEditor })),
22 { ssr: false, loading: () => <div style={{ flex: 1 }} /> }
23);
24
25const fmtBtnStyle: React.CSSProperties = {
26 padding: "2px 8px",
27 fontSize: "10px",
28 fontFamily: "'JetBrains Mono', Menlo, monospace",
29 background: "none",
30 border: "1px solid var(--border)",
31 borderRadius: "3px",
32 color: "var(--text-muted)",
33 cursor: "pointer",
34 textTransform: "none",
35 letterSpacing: "0",
36};
37
38interface CollabWorkspaceInnerProps {
39 owner: string;
40 repo: string;
41 diagrams: Diagram[];
42 sections: string[];
43}
44
45function CollabWorkspaceInner({ owner, repo, diagrams, sections }: CollabWorkspaceInnerProps) {
46 const { user: authUser } = useAuth();
47 const { emit, connected, users } = useCollab();
48 const [activeId, setActiveId] = useState<string | null>(diagrams[0]?.id ?? null);
49 const [codeMap, setCodeMap] = useState<Record<string, string>>(() => {
50 const map: Record<string, string> = {};
51 for (const d of diagrams) map[d.id] = d.code;
52 return map;
53 });
54 const [renderedCode, setRenderedCode] = useState(diagrams[0]?.code ?? "");
55 const [codeOpen, setCodeOpen] = useState(true);
56 const [codeWidth, setCodeWidth] = useState(520);
57 const resizing = useRef(false);
58 const editorRef = useRef<CodeEditorHandle>(null);
59 const mermaidRef = useRef<any>(null);
60 const lintTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
61 const [validationStatus, setValidationStatus] = useState<
62 null | { ok: true } | { ok: false; message: string; line?: number; col?: number }
63 >(null);
64
65 // Load mermaid for validation
66 useEffect(() => {
67 import("mermaid").then((mod) => {
68 const m = mod.default;
69 m.initialize({ startOnLoad: false, securityLevel: "loose" });
70 mermaidRef.current = m;
71 });
72 }, []);
73
74 const activeDiagram = diagrams.find((d) => d.id === activeId) ?? null;
75 const currentCode = activeId ? (codeMap[activeId] ?? "") : "";
76
77 // Notify server of active tab
78 useEffect(() => {
79 if (activeId) emit("cursor-move", { x: 0, y: 0, activeTab: activeId });
80 }, [activeId, emit]);
81
82 const handleSelectDiagram = useCallback((id: string) => {
83 setActiveId(id);
84 const code = codeMap[id] ?? diagrams.find((d) => d.id === id)?.code ?? "";
85 setRenderedCode(code);
86 setValidationStatus(null);
87 }, [codeMap, diagrams]);
88
89 const validateMermaidCode = useCallback(async (code: string): Promise<
90 { ok: true } | { ok: false; message: string; line?: number; col?: number }
91 > => {
92 const m = mermaidRef.current;
93 if (!m) return { ok: true };
94 try {
95 await m.parse(code);
96 return { ok: true };
97 } catch (e: any) {
98 const raw = e.message || String(e);
99 let line: number | undefined;
100 let col: number | undefined;
101
102 const lineMatch = raw.match(/line\s*(\d+)/i) || raw.match(/at line (\d+)/i);
103 const colMatch = raw.match(/col(?:umn)?\s*(\d+)/i);
104 if (lineMatch) line = parseInt(lineMatch[1]);
105 if (colMatch) col = parseInt(colMatch[1]);
106
107 if (!line && e.hash) {
108 if (e.hash.line != null) line = e.hash.line + 1;
109 if (e.hash.loc) {
110 line = line || e.hash.loc.first_line;
111 col = col || e.hash.loc.first_column + 1;
112 }
113 }
114
115 let msg = "";
116 if (line) {
117 msg += "Line " + line;
118 if (col) msg += ":" + col;
119 msg += " \u2014 ";
120 }
121 const cleaned = raw.replace(/^Error:\s*/i, "").replace(/Syntax error in.*?:\s*/i, "");
122 const lines = cleaned.split("\n").filter((l: string) => l.trim());
123 msg += lines[0] || cleaned;
124
125 if (line && code) {
126 const srcLines = code.split("\n");
127 const srcLine = srcLines[line - 1];
128 if (srcLine != null) {
129 msg += "\n\n " + line + " \u2502 " + srcLine;
130 if (col) {
131 msg += "\n " + " ".repeat(String(line).length) + " ".repeat(col) + "\u2191";
132 }
133 }
134 }
135
136 return { ok: false, message: msg, line, col };
137 }
138 }, []);
139
140 const handleCodeChange = useCallback((newCode: string) => {
141 if (!activeId) return;
142 setCodeMap((prev) => ({ ...prev, [activeId]: newCode }));
143 // Debounced lint as-you-type
144 if (lintTimerRef.current) clearTimeout(lintTimerRef.current);
145 lintTimerRef.current = setTimeout(async () => {
146 const result = await validateMermaidCode(newCode);
147 setValidationStatus(result);
148 }, 500);
149 }, [activeId, validateMermaidCode]);
150
151 const handleRun = useCallback(async () => {
152 if (!activeId) return;
153 const code = codeMap[activeId] ?? "";
154 if (lintTimerRef.current) clearTimeout(lintTimerRef.current);
155 const result = await validateMermaidCode(code);
156 setValidationStatus(result);
157 if (!result.ok) {
158 if (result.line) editorRef.current?.jumpToLine(result.line, result.col);
159 return;
160 }
161 setRenderedCode(code);
162 emit("diagram-code", { diagramId: activeId, code });
163 }, [activeId, codeMap, emit, validateMermaidCode]);
164
165 // Listen for remote diagram code broadcasts
166 const { onDiagramCode } = useCollab();
167 useEffect(() => {
168 onDiagramCode.current = (data: { diagramId: string; code: string }) => {
169 setCodeMap((prev) => ({ ...prev, [data.diagramId]: data.code }));
170 if (data.diagramId === activeId) {
171 setRenderedCode(data.code);
172 }
173 };
174 return () => { onDiagramCode.current = null; };
175 }, [activeId, onDiagramCode]);
176
177 // Push actions into the nav bar
178 const { setActions } = useCollabNavActions();
179 const otherUsers = Object.values(users).filter(
180 (u) => !authUser || (u.name !== authUser.username && u.name !== authUser.display_name)
181 );
182 useEffect(() => {
183 setActions(
184 <>
185 {!connected && (
186 <span className="text-xs" style={{ color: "var(--text-faint)" }}>
187 Connecting...
188 </span>
189 )}
190 {otherUsers.length > 0 && (
191 <div className="flex items-center gap-1.5">
192 {otherUsers.map((u) => (
193 <span
194 key={u.id}
195 className="collab-avatar flex items-center justify-center text-xs font-medium"
196 style={{
197 width: "1.5rem",
198 height: "1.5rem",
199 borderRadius: "9999px",
200 backgroundColor: u.color,
201 color: "#fff",
202 fontSize: "11px",
203 position: "relative",
204 cursor: "default",
205 }}
206 data-tooltip={u.name}
207 >
208 {u.name.charAt(0).toUpperCase()}
209 </span>
210 ))}
211 </div>
212 )}
213 </>
214 );
215 return () => setActions(null);
216 }, [connected, otherUsers.length, setActions]);
217
218 // Code pane resize
219 const handleResizeStart = useCallback((e: React.MouseEvent) => {
220 e.preventDefault();
221 resizing.current = true;
222 const startX = e.clientX;
223 const startWidth = codeWidth;
224 const handleMove = (e: MouseEvent) => {
225 if (!resizing.current) return;
226 setCodeWidth(Math.max(200, Math.min(800, startWidth + (e.clientX - startX))));
227 };
228 const handleUp = () => {
229 resizing.current = false;
230 document.removeEventListener("mousemove", handleMove);
231 document.removeEventListener("mouseup", handleUp);
232 };
233 document.addEventListener("mousemove", handleMove);
234 document.addEventListener("mouseup", handleUp);
235 }, [codeWidth]);
236
237 return (
238 <div style={{ display: "flex", flex: 1, minHeight: 0, height: "100%" }}>
239 <DiagramDrawer
240 diagrams={diagrams}
241 sections={sections}
242 activeId={activeId}
243 onSelect={handleSelectDiagram}
244 />
245
246 {/* Code pane (collapsible) */}
247 <div
248 style={{
249 width: codeOpen ? codeWidth : 32,
250 minWidth: codeOpen ? 200 : 32,
251 background: "var(--bg-card)",
252 borderRight: "1px solid var(--border)",
253 flexShrink: 0,
254 overflow: "hidden",
255 display: "flex",
256 flexDirection: "column",
257 position: "relative",
258 }}
259 >
260 <div
261 onClick={() => setCodeOpen((v) => !v)}
262 style={{
263 display: "flex",
264 alignItems: "center",
265 gap: "6px",
266 padding: "8px",
267 fontSize: "11px",
268 fontWeight: 500,
269 color: "var(--text-muted)",
270 fontFamily: "'JetBrains Mono', Menlo, monospace",
271 cursor: "pointer",
272 userSelect: "none",
273 borderBottom: codeOpen ? "1px solid var(--border-subtle, var(--divide))" : "none",
274 textTransform: "uppercase",
275 letterSpacing: "0.5px",
276 whiteSpace: "nowrap",
277 flexShrink: 0,
278 ...(!codeOpen ? { writingMode: "vertical-lr" as const, textOrientation: "mixed" as const, padding: "12px 6px", flex: 1, justifyContent: "start" } : {}),
279 }}
280 >
281 <span style={{ fontSize: "10px", display: "inline-block", transform: codeOpen ? "rotate(90deg)" : "rotate(90deg)", transition: "transform 0.15s", ...(!codeOpen ? { marginBottom: "2px" } : {}) }}>&#9654;</span>
282 <span>Definition</span>
283 {codeOpen && <span style={{ flex: 1 }} />}
284 {codeOpen && validationStatus && (
285 <span
286 onClick={(e) => e.stopPropagation()}
287 style={{
288 fontSize: "10px",
289 fontFamily: "'JetBrains Mono', Menlo, monospace",
290 color: validationStatus.ok ? "var(--accent)" : "#a05050",
291 cursor: "default",
292 whiteSpace: "nowrap",
293 }}
294 >
295 {validationStatus.ok ? "\u2713 valid" : "\u2717 error"}
296 </span>
297 )}
298 {codeOpen && (
299 <>
300 <span style={{ display: "flex", gap: 2, alignItems: "center" }} onClick={(e) => e.stopPropagation()}>
301 <button
302 onClick={() => editorRef.current?.changeFontSize(-1)}
303 title="Decrease font size"
304 style={fmtBtnStyle}
305 >
306 A&minus;
307 </button>
308 <button
309 onClick={() => editorRef.current?.changeFontSize(1)}
310 title="Increase font size"
311 style={fmtBtnStyle}
312 >
313 A+
314 </button>
315 </span>
316 <button
317 onClick={(e) => { e.stopPropagation(); editorRef.current?.format(); }}
318 title="Format (Shift+Alt+F)"
319 style={fmtBtnStyle}
320 >
321 Format
322 </button>
323 <button
324 onClick={(e) => { e.stopPropagation(); handleRun(); }}
325 style={{
326 padding: "2px 10px",
327 fontSize: "10px",
328 fontFamily: "'JetBrains Mono', Menlo, monospace",
329 background: "var(--accent)",
330 border: "1px solid var(--accent)",
331 borderRadius: "3px",
332 color: "var(--accent-text)",
333 cursor: "pointer",
334 textTransform: "none",
335 letterSpacing: "0",
336 }}
337 >
338 &#9654; Run
339 </button>
340 </>
341 )}
342 </div>
343 {codeOpen && (
344 <>
345 <CodeEditor ref={editorRef} code={currentCode} onChange={handleCodeChange} onRun={handleRun} />
346 {validationStatus && !validationStatus.ok && (
347 <div
348 style={{
349 padding: "6px 10px",
350 fontSize: "11px",
351 fontFamily: "'JetBrains Mono', Menlo, monospace",
352 color: "#a05050",
353 background: "#a050500a",
354 borderTop: "1px solid #a0505030",
355 flexShrink: 0,
356 overflowX: "auto",
357 whiteSpace: "pre-wrap",
358 wordBreak: "break-word",
359 maxHeight: "120px",
360 overflowY: "auto",
361 }}
362 >
363 {validationStatus.message}
364 </div>
365 )}
366 {/* Resize handle */}
367 <div
368 onMouseDown={handleResizeStart}
369 style={{
370 position: "absolute",
371 top: 0,
372 right: -3,
373 width: 6,
374 height: "100%",
375 cursor: "col-resize",
376 zIndex: 10,
377 }}
378 />
379 </>
380 )}
381 </div>
382
383 {/* Diagram viewer */}
384 <DiagramViewer
385 code={renderedCode}
386 diagramId={activeId ?? ""}
387 />
388
389 {/* Notes sidebar */}
390 {activeDiagram && (
391 <NotesSidebar
392 diagramId={activeDiagram.id}
393 diagramTitle={activeDiagram.title}
394 />
395 )}
396 </div>
397 );
398}
399
400interface CollabWorkspaceProps {
401 owner: string;
402 repo: string;
403}
404
405export function CollabWorkspace({ owner, repo }: CollabWorkspaceProps) {
406 const { user, loading: authLoading } = useAuth();
407 const [diagrams, setDiagrams] = useState<Diagram[]>([]);
408 const [sections, setSections] = useState<string[]>([]);
409 const [loading, setLoading] = useState(true);
410
411 useEffect(() => {
412 collabApi
413 .listDiagrams(owner, repo)
414 .then((data) => {
415 // Sections from the API may be objects ({ label }) or strings
416 const rawSections: any[] = data.sections ?? [];
417 const sectionLabels = rawSections.map((s: any) =>
418 typeof s === "string" ? s : s.label ?? ""
419 );
420 // Diagram section may be a numeric index into sections — resolve to label
421 const diagrams = (data.diagrams ?? []).map((d: any) => ({
422 ...d,
423 section: typeof d.section === "number" ? sectionLabels[d.section] ?? "" : d.section ?? "",
424 }));
425 setDiagrams(diagrams);
426 setSections(sectionLabels);
427 })
428 .catch(() => {})
429 .finally(() => setLoading(false));
430 }, [owner, repo]);
431
432 if (authLoading || loading) {
433 return (
434 <div className="flex items-center justify-center" style={{ height: "100%" }}>
435 <span className="text-sm" style={{ color: "var(--text-faint)" }}>Loading...</span>
436 </div>
437 );
438 }
439
440 if (diagrams.length === 0) {
441 return (
442 <div className="flex items-center justify-center" style={{ height: "100%" }}>
443 <span className="text-sm" style={{ color: "var(--text-faint)" }}>No diagrams found for this repository.</span>
444 </div>
445 );
446 }
447
448 if (!user) {
449 // Read-only mode — show diagrams without Socket.IO
450 return (
451 <div style={{ display: "flex", flex: 1, minHeight: 0, height: "100%" }}>
452 <ReadOnlyWorkspace diagrams={diagrams} sections={sections} />
453 </div>
454 );
455 }
456
457 return (
458 <CollabProvider owner={owner} repo={repo}>
459 <CollabWorkspaceInner owner={owner} repo={repo} diagrams={diagrams} sections={sections} />
460 </CollabProvider>
461 );
462}
463
464function ReadOnlyWorkspace({ diagrams, sections }: { diagrams: Diagram[]; sections: string[] }) {
465 const [activeId, setActiveId] = useState(diagrams[0]?.id ?? null);
466 const activeDiagram = diagrams.find((d) => d.id === activeId);
467
468 return (
469 <div style={{ display: "flex", flex: 1, height: "100%" }}>
470 {/* Simple drawer without collab features */}
471 <div
472 style={{
473 width: 260,
474 borderRight: "1px solid var(--border)",
475 display: "flex",
476 flexDirection: "column",
477 flexShrink: 0,
478 overflow: "hidden",
479 }}
480 >
481 <div
482 className="px-3 py-2 text-xs font-medium uppercase tracking-wide"
483 style={{ color: "var(--text-faint)", borderBottom: "1px solid var(--divide)" }}
484 >
485 Diagrams
486 </div>
487 <div style={{ flex: 1, overflowY: "auto" }}>
488 {diagrams.map((d) => (
489 <button
490 key={d.id}
491 onClick={() => setActiveId(d.id)}
492 className="w-full text-left px-3 py-2 text-sm"
493 style={{
494 background: d.id === activeId ? "var(--bg-hover)" : "transparent",
495 border: "none",
496 color: d.id === activeId ? "var(--text-primary)" : "var(--text-muted)",
497 fontWeight: d.id === activeId ? 600 : 400,
498 cursor: "pointer",
499 font: "inherit",
500 }}
501 >
502 {d.title}
503 </button>
504 ))}
505 </div>
506 </div>
507 <DiagramViewer
508 code={activeDiagram?.code ?? ""}
509 diagramId={activeId ?? ""}
510 />
511 </div>
512 );
513}
514