create-interview-cockpit 0.27.0 → 0.28.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/src/codeowners.ts +792 -0
- package/template/client/src/components/CodeContextPanel.tsx +44 -0
- package/template/client/src/components/DiagramsModal.tsx +839 -0
- package/template/client/src/components/GithubActionsLabModal.tsx +291 -264
- package/template/client/src/components/LabsPanel.tsx +3 -3
- package/template/client/src/components/PullRequestPanel.tsx +1142 -0
- package/template/client/src/components/SettingsPanel.tsx +1395 -0
- package/template/client/src/githubActionsLab.ts +461 -3
- package/template/client/src/types.ts +219 -0
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +1 -1
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
ChevronLeft,
|
|
4
|
+
ChevronRight,
|
|
5
|
+
GripVertical,
|
|
6
|
+
Maximize2,
|
|
7
|
+
Minimize2,
|
|
8
|
+
Plus,
|
|
9
|
+
Save,
|
|
10
|
+
Trash2,
|
|
11
|
+
X,
|
|
12
|
+
Download,
|
|
13
|
+
RefreshCw,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
|
|
16
|
+
const MIN_W = 720;
|
|
17
|
+
const MIN_H = 560;
|
|
18
|
+
const DEFAULT_W = 1100;
|
|
19
|
+
const DEFAULT_H = 760;
|
|
20
|
+
type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n" | null;
|
|
21
|
+
|
|
22
|
+
// draw.io embed endpoint. Uses postMessage proto for save/load/export.
|
|
23
|
+
// Docs: https://www.drawio.com/doc/faq/embed-mode
|
|
24
|
+
const DRAWIO_SRC =
|
|
25
|
+
"https://embed.diagrams.net/?embed=1&ui=dark&spin=1&proto=json&saveAndExit=0&noSaveBtn=1&noExitBtn=1&libraries=1&configure=1";
|
|
26
|
+
|
|
27
|
+
// ── Data model ───────────────────────────────────────────────────
|
|
28
|
+
interface Diagram {
|
|
29
|
+
id: string;
|
|
30
|
+
name: string;
|
|
31
|
+
xml: string;
|
|
32
|
+
svgPreview?: string; // data URL for thumbnail
|
|
33
|
+
updatedAt: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function diagramsKey(questionId?: string | null): string {
|
|
37
|
+
return questionId ? `diagrams:q:${questionId}` : "diagrams:global";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function loadDiagrams(key: string): Diagram[] {
|
|
41
|
+
try {
|
|
42
|
+
const raw = localStorage.getItem(key);
|
|
43
|
+
if (!raw) return [];
|
|
44
|
+
const parsed = JSON.parse(raw);
|
|
45
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function persistDiagrams(key: string, diagrams: Diagram[]): void {
|
|
52
|
+
try {
|
|
53
|
+
if (diagrams.length === 0) {
|
|
54
|
+
localStorage.removeItem(key);
|
|
55
|
+
} else {
|
|
56
|
+
localStorage.setItem(key, JSON.stringify(diagrams));
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// storage full — ignore
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function genId(): string {
|
|
64
|
+
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Component ────────────────────────────────────────────────────
|
|
68
|
+
interface Props {
|
|
69
|
+
questionId?: string | null;
|
|
70
|
+
onClose: () => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export default function DiagramsModal({ questionId, onClose }: Props) {
|
|
74
|
+
const storageKey = diagramsKey(questionId);
|
|
75
|
+
|
|
76
|
+
const [diagrams, setDiagrams] = useState<Diagram[]>(() =>
|
|
77
|
+
loadDiagrams(storageKey),
|
|
78
|
+
);
|
|
79
|
+
const [activeId, setActiveId] = useState<string | null>(
|
|
80
|
+
() => loadDiagrams(storageKey)[0]?.id ?? null,
|
|
81
|
+
);
|
|
82
|
+
const [draftName, setDraftName] = useState<string>(
|
|
83
|
+
() => loadDiagrams(storageKey)[0]?.name ?? "",
|
|
84
|
+
);
|
|
85
|
+
const [nameDirty, setNameDirty] = useState(false);
|
|
86
|
+
const [saveAsValue, setSaveAsValue] = useState<string | null>(null);
|
|
87
|
+
const [iframeReady, setIframeReady] = useState(false);
|
|
88
|
+
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
|
|
89
|
+
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
90
|
+
|
|
91
|
+
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
92
|
+
// Track which diagram's XML the iframe currently holds so we don't
|
|
93
|
+
// overwrite a user's edits on unrelated state changes.
|
|
94
|
+
const loadedIdRef = useRef<string | null>(null);
|
|
95
|
+
|
|
96
|
+
const activeDiagram = useMemo(
|
|
97
|
+
() => diagrams.find((d) => d.id === activeId) ?? null,
|
|
98
|
+
[diagrams, activeId],
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Re-load when the question changes
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const loaded = loadDiagrams(storageKey);
|
|
104
|
+
setDiagrams(loaded);
|
|
105
|
+
const first = loaded[0] ?? null;
|
|
106
|
+
setActiveId(first?.id ?? null);
|
|
107
|
+
setDraftName(first?.name ?? "");
|
|
108
|
+
setNameDirty(false);
|
|
109
|
+
loadedIdRef.current = null;
|
|
110
|
+
}, [storageKey]);
|
|
111
|
+
|
|
112
|
+
// Sync draft name when active diagram changes
|
|
113
|
+
const prevActiveId = useRef<string | null>(null);
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (activeId === prevActiveId.current) return;
|
|
116
|
+
prevActiveId.current = activeId;
|
|
117
|
+
const d = diagrams.find((x) => x.id === activeId);
|
|
118
|
+
if (d) {
|
|
119
|
+
setDraftName(d.name);
|
|
120
|
+
setNameDirty(false);
|
|
121
|
+
}
|
|
122
|
+
}, [activeId, diagrams]);
|
|
123
|
+
|
|
124
|
+
// ── postMessage helpers ──────────────────────────────────────
|
|
125
|
+
const postToFrame = useCallback((msg: object) => {
|
|
126
|
+
const w = iframeRef.current?.contentWindow;
|
|
127
|
+
if (!w) return;
|
|
128
|
+
w.postMessage(JSON.stringify(msg), "*");
|
|
129
|
+
}, []);
|
|
130
|
+
|
|
131
|
+
const loadIntoFrame = useCallback(
|
|
132
|
+
(xml: string, id: string) => {
|
|
133
|
+
postToFrame({ action: "load", xml: xml || "", autosave: 1 });
|
|
134
|
+
loadedIdRef.current = id;
|
|
135
|
+
},
|
|
136
|
+
[postToFrame],
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Listen for drawio events
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
const onMsg = (e: MessageEvent) => {
|
|
142
|
+
if (e.source !== iframeRef.current?.contentWindow) return;
|
|
143
|
+
let msg: { event?: string; xml?: string; data?: string };
|
|
144
|
+
try {
|
|
145
|
+
msg =
|
|
146
|
+
typeof e.data === "string" ? JSON.parse(e.data) : (e.data as object);
|
|
147
|
+
} catch {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (!msg || typeof msg !== "object" || !msg.event) return;
|
|
151
|
+
|
|
152
|
+
switch (msg.event) {
|
|
153
|
+
case "configure":
|
|
154
|
+
// Optional: pass UI config if desired. We accept defaults.
|
|
155
|
+
postToFrame({ action: "configure", config: {} });
|
|
156
|
+
break;
|
|
157
|
+
case "init":
|
|
158
|
+
setIframeReady(true);
|
|
159
|
+
if (activeDiagram) {
|
|
160
|
+
loadIntoFrame(activeDiagram.xml, activeDiagram.id);
|
|
161
|
+
} else {
|
|
162
|
+
loadIntoFrame("", "__none__");
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
case "autosave":
|
|
166
|
+
case "save": {
|
|
167
|
+
const id = loadedIdRef.current;
|
|
168
|
+
if (!id || !msg.xml) break;
|
|
169
|
+
setDiagrams((prev) => {
|
|
170
|
+
const updated = prev.map((d) =>
|
|
171
|
+
d.id === id ? { ...d, xml: msg.xml!, updatedAt: Date.now() } : d,
|
|
172
|
+
);
|
|
173
|
+
persistDiagrams(storageKey, updated);
|
|
174
|
+
return updated;
|
|
175
|
+
});
|
|
176
|
+
setLastSavedAt(Date.now());
|
|
177
|
+
// Ask for an SVG snapshot for the thumbnail.
|
|
178
|
+
postToFrame({ action: "export", format: "xmlsvg" });
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case "export": {
|
|
182
|
+
const id = loadedIdRef.current;
|
|
183
|
+
if (!id || !msg.data) break;
|
|
184
|
+
setDiagrams((prev) => {
|
|
185
|
+
const updated = prev.map((d) =>
|
|
186
|
+
d.id === id ? { ...d, svgPreview: msg.data } : d,
|
|
187
|
+
);
|
|
188
|
+
persistDiagrams(storageKey, updated);
|
|
189
|
+
return updated;
|
|
190
|
+
});
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
case "exit":
|
|
194
|
+
onClose();
|
|
195
|
+
break;
|
|
196
|
+
default:
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
window.addEventListener("message", onMsg);
|
|
201
|
+
return () => window.removeEventListener("message", onMsg);
|
|
202
|
+
}, [activeDiagram, loadIntoFrame, onClose, postToFrame, storageKey]);
|
|
203
|
+
|
|
204
|
+
// When activeId changes after iframe is ready, swap XML in the embedded editor.
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
if (!iframeReady) return;
|
|
207
|
+
if (!activeDiagram) {
|
|
208
|
+
loadIntoFrame("", "__none__");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (loadedIdRef.current === activeDiagram.id) return;
|
|
212
|
+
loadIntoFrame(activeDiagram.xml, activeDiagram.id);
|
|
213
|
+
}, [iframeReady, activeDiagram, loadIntoFrame]);
|
|
214
|
+
|
|
215
|
+
// ── Actions ──────────────────────────────────────────────────
|
|
216
|
+
const handleAddDiagram = useCallback(() => {
|
|
217
|
+
setDiagrams((prev) => {
|
|
218
|
+
const count = prev.length + 1;
|
|
219
|
+
const d: Diagram = {
|
|
220
|
+
id: genId(),
|
|
221
|
+
name: `Diagram ${count}`,
|
|
222
|
+
xml: "",
|
|
223
|
+
updatedAt: Date.now(),
|
|
224
|
+
};
|
|
225
|
+
const updated = [...prev, d];
|
|
226
|
+
persistDiagrams(storageKey, updated);
|
|
227
|
+
setActiveId(d.id);
|
|
228
|
+
setDraftName(d.name);
|
|
229
|
+
setNameDirty(false);
|
|
230
|
+
return updated;
|
|
231
|
+
});
|
|
232
|
+
}, [storageKey]);
|
|
233
|
+
|
|
234
|
+
const handleSelectDiagram = useCallback(
|
|
235
|
+
(id: string) => {
|
|
236
|
+
if (id === activeId) return;
|
|
237
|
+
// Force the embedded editor to flush pending edits before switching.
|
|
238
|
+
postToFrame({ action: "save" });
|
|
239
|
+
setActiveId(id);
|
|
240
|
+
},
|
|
241
|
+
[activeId, postToFrame],
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const handleDeleteDiagram = useCallback(
|
|
245
|
+
(id: string) => {
|
|
246
|
+
setDiagrams((prev) => {
|
|
247
|
+
const updated = prev.filter((d) => d.id !== id);
|
|
248
|
+
persistDiagrams(storageKey, updated);
|
|
249
|
+
if (id === activeId) {
|
|
250
|
+
const next = updated[0] ?? null;
|
|
251
|
+
setActiveId(next?.id ?? null);
|
|
252
|
+
setDraftName(next?.name ?? "");
|
|
253
|
+
setNameDirty(false);
|
|
254
|
+
if (!next) loadedIdRef.current = null;
|
|
255
|
+
}
|
|
256
|
+
return updated;
|
|
257
|
+
});
|
|
258
|
+
},
|
|
259
|
+
[activeId, storageKey],
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const handleSaveName = useCallback(() => {
|
|
263
|
+
if (!activeId || !nameDirty) return;
|
|
264
|
+
setDiagrams((prev) => {
|
|
265
|
+
const updated = prev.map((d) =>
|
|
266
|
+
d.id === activeId
|
|
267
|
+
? {
|
|
268
|
+
...d,
|
|
269
|
+
name: draftName.trim() || "Untitled",
|
|
270
|
+
updatedAt: Date.now(),
|
|
271
|
+
}
|
|
272
|
+
: d,
|
|
273
|
+
);
|
|
274
|
+
persistDiagrams(storageKey, updated);
|
|
275
|
+
return updated;
|
|
276
|
+
});
|
|
277
|
+
setNameDirty(false);
|
|
278
|
+
}, [activeId, draftName, nameDirty, storageKey]);
|
|
279
|
+
|
|
280
|
+
const handleManualSave = useCallback(() => {
|
|
281
|
+
// Asks drawio to emit a `save` event with current XML, which our listener
|
|
282
|
+
// persists. Also commits any pending name change.
|
|
283
|
+
handleSaveName();
|
|
284
|
+
postToFrame({ action: "save" });
|
|
285
|
+
}, [handleSaveName, postToFrame]);
|
|
286
|
+
|
|
287
|
+
const handleSaveAs = useCallback(() => {
|
|
288
|
+
if (!activeDiagram) return;
|
|
289
|
+
setSaveAsValue((activeDiagram.name.trim() || "Untitled") + " copy");
|
|
290
|
+
}, [activeDiagram]);
|
|
291
|
+
|
|
292
|
+
const confirmSaveAs = useCallback(() => {
|
|
293
|
+
if (!activeDiagram) return;
|
|
294
|
+
const name = (saveAsValue ?? "").trim() || "Untitled";
|
|
295
|
+
const clone: Diagram = {
|
|
296
|
+
id: genId(),
|
|
297
|
+
name,
|
|
298
|
+
xml: activeDiagram.xml,
|
|
299
|
+
svgPreview: activeDiagram.svgPreview,
|
|
300
|
+
updatedAt: Date.now(),
|
|
301
|
+
};
|
|
302
|
+
setDiagrams((prev) => {
|
|
303
|
+
const updated = [...prev, clone];
|
|
304
|
+
persistDiagrams(storageKey, updated);
|
|
305
|
+
return updated;
|
|
306
|
+
});
|
|
307
|
+
setActiveId(clone.id);
|
|
308
|
+
setDraftName(name);
|
|
309
|
+
setNameDirty(false);
|
|
310
|
+
setSaveAsValue(null);
|
|
311
|
+
}, [activeDiagram, saveAsValue, storageKey]);
|
|
312
|
+
|
|
313
|
+
const handleExportSvg = useCallback(() => {
|
|
314
|
+
if (!activeDiagram?.svgPreview) {
|
|
315
|
+
// Trigger an export pass; the listener will store svgPreview and the
|
|
316
|
+
// user can click again. Cheap fallback.
|
|
317
|
+
postToFrame({ action: "export", format: "xmlsvg" });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const a = document.createElement("a");
|
|
321
|
+
a.href = activeDiagram.svgPreview;
|
|
322
|
+
a.download =
|
|
323
|
+
(activeDiagram.name || "diagram").replace(/[^a-z0-9-_]+/gi, "_") + ".svg";
|
|
324
|
+
document.body.appendChild(a);
|
|
325
|
+
a.click();
|
|
326
|
+
a.remove();
|
|
327
|
+
}, [activeDiagram, postToFrame]);
|
|
328
|
+
|
|
329
|
+
// ── Drag / resize ─────────────────────────────────────────────
|
|
330
|
+
const [pos, setPos] = useState(() => ({
|
|
331
|
+
x: Math.max(0, (window.innerWidth - DEFAULT_W) / 2),
|
|
332
|
+
y: Math.max(0, (window.innerHeight - DEFAULT_H) / 2),
|
|
333
|
+
}));
|
|
334
|
+
const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H });
|
|
335
|
+
const [maximized, setMaximized] = useState(false);
|
|
336
|
+
|
|
337
|
+
const dragStart = useRef<{
|
|
338
|
+
mx: number;
|
|
339
|
+
my: number;
|
|
340
|
+
ox: number;
|
|
341
|
+
oy: number;
|
|
342
|
+
} | null>(null);
|
|
343
|
+
const resizeDir = useRef<ResizeDir>(null);
|
|
344
|
+
const resizeStart = useRef<{
|
|
345
|
+
mx: number;
|
|
346
|
+
my: number;
|
|
347
|
+
ox: number;
|
|
348
|
+
oy: number;
|
|
349
|
+
ow: number;
|
|
350
|
+
oh: number;
|
|
351
|
+
} | null>(null);
|
|
352
|
+
const savedPos = useRef(pos);
|
|
353
|
+
const savedSize = useRef(size);
|
|
354
|
+
|
|
355
|
+
const onTitleMouseDown = useCallback(
|
|
356
|
+
(e: React.MouseEvent) => {
|
|
357
|
+
if (maximized) return;
|
|
358
|
+
e.preventDefault();
|
|
359
|
+
dragStart.current = {
|
|
360
|
+
mx: e.clientX,
|
|
361
|
+
my: e.clientY,
|
|
362
|
+
ox: pos.x,
|
|
363
|
+
oy: pos.y,
|
|
364
|
+
};
|
|
365
|
+
},
|
|
366
|
+
[maximized, pos],
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
const startResize = useCallback(
|
|
370
|
+
(dir: ResizeDir) => (e: React.MouseEvent) => {
|
|
371
|
+
if (maximized) return;
|
|
372
|
+
e.preventDefault();
|
|
373
|
+
e.stopPropagation();
|
|
374
|
+
resizeDir.current = dir;
|
|
375
|
+
resizeStart.current = {
|
|
376
|
+
mx: e.clientX,
|
|
377
|
+
my: e.clientY,
|
|
378
|
+
ox: pos.x,
|
|
379
|
+
oy: pos.y,
|
|
380
|
+
ow: size.w,
|
|
381
|
+
oh: size.h,
|
|
382
|
+
};
|
|
383
|
+
},
|
|
384
|
+
[maximized, pos, size],
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const toggleMax = useCallback(() => {
|
|
388
|
+
if (!maximized) {
|
|
389
|
+
savedPos.current = pos;
|
|
390
|
+
savedSize.current = size;
|
|
391
|
+
setMaximized(true);
|
|
392
|
+
} else {
|
|
393
|
+
setPos(savedPos.current);
|
|
394
|
+
setSize(savedSize.current);
|
|
395
|
+
setMaximized(false);
|
|
396
|
+
}
|
|
397
|
+
}, [maximized, pos, size]);
|
|
398
|
+
|
|
399
|
+
// While dragging/resizing we want to disable iframe pointer events,
|
|
400
|
+
// otherwise the embedded editor will swallow mousemove events.
|
|
401
|
+
const [interacting, setInteracting] = useState(false);
|
|
402
|
+
|
|
403
|
+
useEffect(() => {
|
|
404
|
+
const onMove = (e: MouseEvent) => {
|
|
405
|
+
const drag = dragStart.current;
|
|
406
|
+
const resize = resizeStart.current;
|
|
407
|
+
const dir = resizeDir.current;
|
|
408
|
+
if (drag) {
|
|
409
|
+
setPos({
|
|
410
|
+
x: Math.max(0, drag.ox + e.clientX - drag.mx),
|
|
411
|
+
y: Math.max(0, drag.oy + e.clientY - drag.my),
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
if (resize && dir) {
|
|
415
|
+
const dx = e.clientX - resize.mx;
|
|
416
|
+
const dy = e.clientY - resize.my;
|
|
417
|
+
setSize((prev) => {
|
|
418
|
+
let w = prev.w,
|
|
419
|
+
h = prev.h;
|
|
420
|
+
if (dir.includes("e")) w = Math.max(MIN_W, resize.ow + dx);
|
|
421
|
+
if (dir.includes("s")) h = Math.max(MIN_H, resize.oh + dy);
|
|
422
|
+
if (dir.includes("w")) w = Math.max(MIN_W, resize.ow - dx);
|
|
423
|
+
if (dir.includes("n")) h = Math.max(MIN_H, resize.oh - dy);
|
|
424
|
+
return { w, h };
|
|
425
|
+
});
|
|
426
|
+
if (dir.includes("w"))
|
|
427
|
+
setPos((p) => ({
|
|
428
|
+
...p,
|
|
429
|
+
x: Math.min(resize.ox + resize.ow - MIN_W, resize.ox + dx),
|
|
430
|
+
}));
|
|
431
|
+
if (dir.includes("n"))
|
|
432
|
+
setPos((p) => ({
|
|
433
|
+
...p,
|
|
434
|
+
y: Math.min(resize.oy + resize.oh - MIN_H, resize.oy + dy),
|
|
435
|
+
}));
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
const onUp = () => {
|
|
439
|
+
dragStart.current = null;
|
|
440
|
+
resizeStart.current = null;
|
|
441
|
+
resizeDir.current = null;
|
|
442
|
+
setInteracting(false);
|
|
443
|
+
};
|
|
444
|
+
const onDown = () => {
|
|
445
|
+
if (dragStart.current || resizeStart.current) setInteracting(true);
|
|
446
|
+
};
|
|
447
|
+
document.addEventListener("mousemove", onMove);
|
|
448
|
+
document.addEventListener("mouseup", onUp);
|
|
449
|
+
document.addEventListener("mousedown", onDown, true);
|
|
450
|
+
return () => {
|
|
451
|
+
document.removeEventListener("mousemove", onMove);
|
|
452
|
+
document.removeEventListener("mouseup", onUp);
|
|
453
|
+
document.removeEventListener("mousedown", onDown, true);
|
|
454
|
+
};
|
|
455
|
+
}, []);
|
|
456
|
+
|
|
457
|
+
useEffect(() => {
|
|
458
|
+
const onKey = (e: KeyboardEvent) => {
|
|
459
|
+
if (e.key === "Escape" && saveAsValue === null) onClose();
|
|
460
|
+
};
|
|
461
|
+
document.addEventListener("keydown", onKey);
|
|
462
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
463
|
+
}, [onClose, saveAsValue]);
|
|
464
|
+
|
|
465
|
+
const windowStyle: React.CSSProperties = maximized
|
|
466
|
+
? {
|
|
467
|
+
position: "fixed",
|
|
468
|
+
inset: 0,
|
|
469
|
+
width: "100vw",
|
|
470
|
+
height: "100vh",
|
|
471
|
+
borderRadius: 0,
|
|
472
|
+
}
|
|
473
|
+
: {
|
|
474
|
+
position: "fixed",
|
|
475
|
+
left: pos.x,
|
|
476
|
+
top: pos.y,
|
|
477
|
+
width: size.w,
|
|
478
|
+
height: size.h,
|
|
479
|
+
minWidth: MIN_W,
|
|
480
|
+
minHeight: MIN_H,
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
// ── Render ───────────────────────────────────────────────────
|
|
484
|
+
return (
|
|
485
|
+
<div
|
|
486
|
+
className="z-50 flex flex-col bg-slate-900 border border-slate-700 rounded-xl shadow-2xl overflow-hidden select-none"
|
|
487
|
+
style={windowStyle}
|
|
488
|
+
>
|
|
489
|
+
{/* Resize handles */}
|
|
490
|
+
{!maximized && (
|
|
491
|
+
<>
|
|
492
|
+
<div
|
|
493
|
+
className="absolute top-0 left-0 right-0 h-1 cursor-n-resize z-10"
|
|
494
|
+
onMouseDown={startResize("n")}
|
|
495
|
+
/>
|
|
496
|
+
<div
|
|
497
|
+
className="absolute bottom-0 left-0 right-0 h-1 cursor-s-resize z-10"
|
|
498
|
+
onMouseDown={startResize("s")}
|
|
499
|
+
/>
|
|
500
|
+
<div
|
|
501
|
+
className="absolute top-0 left-0 bottom-0 w-1 cursor-w-resize z-10"
|
|
502
|
+
onMouseDown={startResize("w")}
|
|
503
|
+
/>
|
|
504
|
+
<div
|
|
505
|
+
className="absolute top-0 right-0 bottom-0 w-1 cursor-e-resize z-10"
|
|
506
|
+
onMouseDown={startResize("e")}
|
|
507
|
+
/>
|
|
508
|
+
<div
|
|
509
|
+
className="absolute top-0 left-0 w-3 h-3 cursor-nw-resize z-20"
|
|
510
|
+
onMouseDown={startResize("nw")}
|
|
511
|
+
/>
|
|
512
|
+
<div
|
|
513
|
+
className="absolute top-0 right-0 w-3 h-3 cursor-ne-resize z-20"
|
|
514
|
+
onMouseDown={startResize("ne")}
|
|
515
|
+
/>
|
|
516
|
+
<div
|
|
517
|
+
className="absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize z-20"
|
|
518
|
+
onMouseDown={startResize("sw")}
|
|
519
|
+
/>
|
|
520
|
+
<div
|
|
521
|
+
className="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize z-20"
|
|
522
|
+
onMouseDown={startResize("se")}
|
|
523
|
+
/>
|
|
524
|
+
</>
|
|
525
|
+
)}
|
|
526
|
+
|
|
527
|
+
{/* Title bar */}
|
|
528
|
+
<div
|
|
529
|
+
className="flex items-center gap-2 px-3 py-2.5 bg-slate-800 border-b border-slate-700 shrink-0"
|
|
530
|
+
onMouseDown={onTitleMouseDown}
|
|
531
|
+
style={{ cursor: maximized ? "default" : "grab" }}
|
|
532
|
+
>
|
|
533
|
+
<GripVertical className="w-3.5 h-3.5 text-slate-600 shrink-0" />
|
|
534
|
+
<span className="text-sm font-semibold text-slate-100 flex-1">
|
|
535
|
+
Diagrams
|
|
536
|
+
{questionId && (
|
|
537
|
+
<span className="ml-2 text-xs font-normal text-slate-500">
|
|
538
|
+
— this question
|
|
539
|
+
</span>
|
|
540
|
+
)}
|
|
541
|
+
</span>
|
|
542
|
+
{lastSavedAt && (
|
|
543
|
+
<span className="text-[10px] text-emerald-500/70 mr-1">
|
|
544
|
+
saved {new Date(lastSavedAt).toLocaleTimeString()}
|
|
545
|
+
</span>
|
|
546
|
+
)}
|
|
547
|
+
<button
|
|
548
|
+
type="button"
|
|
549
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
550
|
+
onClick={toggleMax}
|
|
551
|
+
className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
|
|
552
|
+
title={maximized ? "Restore" : "Maximise"}
|
|
553
|
+
>
|
|
554
|
+
{maximized ? (
|
|
555
|
+
<Minimize2 className="w-3.5 h-3.5" />
|
|
556
|
+
) : (
|
|
557
|
+
<Maximize2 className="w-3.5 h-3.5" />
|
|
558
|
+
)}
|
|
559
|
+
</button>
|
|
560
|
+
<button
|
|
561
|
+
type="button"
|
|
562
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
563
|
+
onClick={onClose}
|
|
564
|
+
className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-red-400 transition-colors shrink-0"
|
|
565
|
+
title="Close (Esc)"
|
|
566
|
+
>
|
|
567
|
+
<X className="w-3.5 h-3.5" />
|
|
568
|
+
</button>
|
|
569
|
+
</div>
|
|
570
|
+
|
|
571
|
+
{/* Body: sidebar + drawio editor */}
|
|
572
|
+
<div className="flex-1 min-h-0 flex overflow-hidden">
|
|
573
|
+
{/* Diagrams list sidebar */}
|
|
574
|
+
<div
|
|
575
|
+
className={`${
|
|
576
|
+
sidebarCollapsed ? "w-12" : "w-52"
|
|
577
|
+
} shrink-0 flex flex-col border-r border-slate-700 bg-slate-900 transition-[width] duration-200 ease-out`}
|
|
578
|
+
>
|
|
579
|
+
<div
|
|
580
|
+
className={`${
|
|
581
|
+
sidebarCollapsed
|
|
582
|
+
? "flex-col justify-center gap-1 px-1.5"
|
|
583
|
+
: "justify-between px-2.5"
|
|
584
|
+
} flex items-center py-2 border-b border-slate-800 shrink-0`}
|
|
585
|
+
>
|
|
586
|
+
{sidebarCollapsed ? (
|
|
587
|
+
<>
|
|
588
|
+
<button
|
|
589
|
+
type="button"
|
|
590
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
591
|
+
onClick={() => setSidebarCollapsed(false)}
|
|
592
|
+
title="Expand diagram list"
|
|
593
|
+
className="p-1 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200 transition-colors"
|
|
594
|
+
>
|
|
595
|
+
<ChevronRight className="w-3.5 h-3.5" />
|
|
596
|
+
</button>
|
|
597
|
+
<button
|
|
598
|
+
type="button"
|
|
599
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
600
|
+
onClick={handleAddDiagram}
|
|
601
|
+
title="New diagram"
|
|
602
|
+
className="p-1 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200 transition-colors"
|
|
603
|
+
>
|
|
604
|
+
<Plus className="w-3.5 h-3.5" />
|
|
605
|
+
</button>
|
|
606
|
+
</>
|
|
607
|
+
) : (
|
|
608
|
+
<>
|
|
609
|
+
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
|
610
|
+
All diagrams
|
|
611
|
+
</span>
|
|
612
|
+
<div className="flex items-center gap-1">
|
|
613
|
+
<button
|
|
614
|
+
type="button"
|
|
615
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
616
|
+
onClick={handleAddDiagram}
|
|
617
|
+
title="New diagram"
|
|
618
|
+
className="p-1 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200 transition-colors"
|
|
619
|
+
>
|
|
620
|
+
<Plus className="w-3.5 h-3.5" />
|
|
621
|
+
</button>
|
|
622
|
+
<button
|
|
623
|
+
type="button"
|
|
624
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
625
|
+
onClick={() => setSidebarCollapsed(true)}
|
|
626
|
+
title="Collapse diagram list"
|
|
627
|
+
className="p-1 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200 transition-colors"
|
|
628
|
+
>
|
|
629
|
+
<ChevronLeft className="w-3.5 h-3.5" />
|
|
630
|
+
</button>
|
|
631
|
+
</div>
|
|
632
|
+
</>
|
|
633
|
+
)}
|
|
634
|
+
</div>
|
|
635
|
+
<div className="flex-1 overflow-y-auto">
|
|
636
|
+
{diagrams.length === 0 && (
|
|
637
|
+
<p
|
|
638
|
+
className={`${
|
|
639
|
+
sidebarCollapsed ? "px-1 text-center" : "px-3"
|
|
640
|
+
} py-3 text-xs text-slate-600 italic`}
|
|
641
|
+
>
|
|
642
|
+
No diagrams yet
|
|
643
|
+
</p>
|
|
644
|
+
)}
|
|
645
|
+
{diagrams.map((d) => (
|
|
646
|
+
<div
|
|
647
|
+
key={d.id}
|
|
648
|
+
onClick={() => handleSelectDiagram(d.id)}
|
|
649
|
+
title={sidebarCollapsed ? d.name || "Untitled" : undefined}
|
|
650
|
+
className={`group flex items-center cursor-pointer text-xs ${
|
|
651
|
+
sidebarCollapsed
|
|
652
|
+
? "justify-center px-1.5 py-2"
|
|
653
|
+
: "gap-2 px-2.5 py-2"
|
|
654
|
+
} ${
|
|
655
|
+
d.id === activeId
|
|
656
|
+
? "bg-slate-700 text-slate-100"
|
|
657
|
+
: "text-slate-400 hover:bg-slate-800 hover:text-slate-200"
|
|
658
|
+
}`}
|
|
659
|
+
>
|
|
660
|
+
{d.svgPreview ? (
|
|
661
|
+
<img
|
|
662
|
+
src={d.svgPreview}
|
|
663
|
+
alt=""
|
|
664
|
+
className="w-8 h-8 rounded bg-slate-950 object-contain shrink-0 border border-slate-800"
|
|
665
|
+
/>
|
|
666
|
+
) : (
|
|
667
|
+
<div className="w-8 h-8 rounded bg-slate-950 border border-slate-800 shrink-0" />
|
|
668
|
+
)}
|
|
669
|
+
{!sidebarCollapsed && (
|
|
670
|
+
<>
|
|
671
|
+
<span className="flex-1 truncate">
|
|
672
|
+
{d.name || "Untitled"}
|
|
673
|
+
</span>
|
|
674
|
+
<button
|
|
675
|
+
type="button"
|
|
676
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
677
|
+
onClick={(e) => {
|
|
678
|
+
e.stopPropagation();
|
|
679
|
+
handleDeleteDiagram(d.id);
|
|
680
|
+
}}
|
|
681
|
+
className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:text-red-400 transition-all shrink-0"
|
|
682
|
+
title="Delete diagram"
|
|
683
|
+
>
|
|
684
|
+
<Trash2 className="w-3 h-3" />
|
|
685
|
+
</button>
|
|
686
|
+
</>
|
|
687
|
+
)}
|
|
688
|
+
</div>
|
|
689
|
+
))}
|
|
690
|
+
</div>
|
|
691
|
+
</div>
|
|
692
|
+
|
|
693
|
+
{/* Editor pane */}
|
|
694
|
+
<div className="flex-1 flex flex-col min-w-0">
|
|
695
|
+
{activeDiagram ? (
|
|
696
|
+
<>
|
|
697
|
+
{/* Name + toolbar */}
|
|
698
|
+
<div className="flex items-center gap-2 px-3 py-2 border-b border-slate-800 shrink-0">
|
|
699
|
+
<input
|
|
700
|
+
value={draftName}
|
|
701
|
+
onChange={(e) => {
|
|
702
|
+
setDraftName(e.target.value);
|
|
703
|
+
setNameDirty(true);
|
|
704
|
+
}}
|
|
705
|
+
onBlur={handleSaveName}
|
|
706
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
707
|
+
placeholder="Diagram name…"
|
|
708
|
+
className="flex-1 bg-transparent text-sm font-semibold text-slate-100 placeholder-slate-600 outline-none"
|
|
709
|
+
/>
|
|
710
|
+
<button
|
|
711
|
+
type="button"
|
|
712
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
713
|
+
onClick={handleManualSave}
|
|
714
|
+
className="flex items-center gap-1.5 px-2 py-0.5 rounded text-xs bg-blue-600 hover:bg-blue-500 text-white transition-colors"
|
|
715
|
+
title="Save now (drawio also autosaves)"
|
|
716
|
+
>
|
|
717
|
+
<Save className="w-3 h-3" />
|
|
718
|
+
Save
|
|
719
|
+
</button>
|
|
720
|
+
<button
|
|
721
|
+
type="button"
|
|
722
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
723
|
+
onClick={handleSaveAs}
|
|
724
|
+
className="px-2 py-0.5 rounded text-xs bg-slate-800 hover:bg-slate-700 text-slate-300 transition-colors"
|
|
725
|
+
title="Save as a new named diagram"
|
|
726
|
+
>
|
|
727
|
+
Save As
|
|
728
|
+
</button>
|
|
729
|
+
<button
|
|
730
|
+
type="button"
|
|
731
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
732
|
+
onClick={handleExportSvg}
|
|
733
|
+
className="flex items-center gap-1 px-2 py-0.5 rounded text-xs text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors"
|
|
734
|
+
title="Download as SVG"
|
|
735
|
+
>
|
|
736
|
+
<Download className="w-3 h-3" />
|
|
737
|
+
SVG
|
|
738
|
+
</button>
|
|
739
|
+
<button
|
|
740
|
+
type="button"
|
|
741
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
742
|
+
onClick={() => {
|
|
743
|
+
if (!activeDiagram) return;
|
|
744
|
+
setIframeReady(false);
|
|
745
|
+
loadedIdRef.current = null;
|
|
746
|
+
// Force iframe reload by toggling src.
|
|
747
|
+
const f = iframeRef.current;
|
|
748
|
+
if (f) {
|
|
749
|
+
const src = f.src;
|
|
750
|
+
f.src = "about:blank";
|
|
751
|
+
requestAnimationFrame(() => {
|
|
752
|
+
f.src = src;
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
}}
|
|
756
|
+
className="p-1 rounded text-slate-500 hover:bg-slate-800 hover:text-slate-300 transition-colors"
|
|
757
|
+
title="Reload editor"
|
|
758
|
+
>
|
|
759
|
+
<RefreshCw className="w-3 h-3" />
|
|
760
|
+
</button>
|
|
761
|
+
</div>
|
|
762
|
+
|
|
763
|
+
{/* drawio iframe */}
|
|
764
|
+
<div className="relative flex-1 bg-slate-950">
|
|
765
|
+
{!iframeReady && (
|
|
766
|
+
<div className="absolute inset-0 flex items-center justify-center text-xs text-slate-500">
|
|
767
|
+
Loading drawio…
|
|
768
|
+
</div>
|
|
769
|
+
)}
|
|
770
|
+
<iframe
|
|
771
|
+
ref={iframeRef}
|
|
772
|
+
src={DRAWIO_SRC}
|
|
773
|
+
title="drawio"
|
|
774
|
+
className="w-full h-full border-0"
|
|
775
|
+
style={{
|
|
776
|
+
pointerEvents: interacting ? "none" : "auto",
|
|
777
|
+
}}
|
|
778
|
+
allow="clipboard-read; clipboard-write"
|
|
779
|
+
/>
|
|
780
|
+
</div>
|
|
781
|
+
</>
|
|
782
|
+
) : (
|
|
783
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-slate-600">
|
|
784
|
+
<p className="text-sm">No diagram selected</p>
|
|
785
|
+
<button
|
|
786
|
+
type="button"
|
|
787
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
788
|
+
onClick={handleAddDiagram}
|
|
789
|
+
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs transition-colors"
|
|
790
|
+
>
|
|
791
|
+
<Plus className="w-3.5 h-3.5" />
|
|
792
|
+
New diagram
|
|
793
|
+
</button>
|
|
794
|
+
</div>
|
|
795
|
+
)}
|
|
796
|
+
</div>
|
|
797
|
+
</div>
|
|
798
|
+
|
|
799
|
+
{/* Save As dialog */}
|
|
800
|
+
{saveAsValue !== null && (
|
|
801
|
+
<div className="absolute inset-0 bg-slate-900/80 backdrop-blur-sm flex items-center justify-center z-30">
|
|
802
|
+
<div
|
|
803
|
+
className="bg-slate-800 border border-slate-700 rounded-lg p-4 w-72 flex flex-col gap-3"
|
|
804
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
805
|
+
>
|
|
806
|
+
<p className="text-sm font-semibold text-slate-100">Save As</p>
|
|
807
|
+
<input
|
|
808
|
+
autoFocus
|
|
809
|
+
value={saveAsValue}
|
|
810
|
+
onChange={(e) => setSaveAsValue(e.target.value)}
|
|
811
|
+
onKeyDown={(e) => {
|
|
812
|
+
if (e.key === "Enter") confirmSaveAs();
|
|
813
|
+
if (e.key === "Escape") setSaveAsValue(null);
|
|
814
|
+
}}
|
|
815
|
+
placeholder="Diagram name…"
|
|
816
|
+
className="px-3 py-1.5 rounded bg-slate-950 border border-slate-700 text-sm text-slate-100 outline-none focus:border-blue-500 transition-colors"
|
|
817
|
+
/>
|
|
818
|
+
<div className="flex gap-2 justify-end">
|
|
819
|
+
<button
|
|
820
|
+
type="button"
|
|
821
|
+
onClick={() => setSaveAsValue(null)}
|
|
822
|
+
className="px-3 py-1 rounded text-xs text-slate-400 hover:bg-slate-700 transition-colors"
|
|
823
|
+
>
|
|
824
|
+
Cancel
|
|
825
|
+
</button>
|
|
826
|
+
<button
|
|
827
|
+
type="button"
|
|
828
|
+
onClick={confirmSaveAs}
|
|
829
|
+
className="px-3 py-1 rounded text-xs bg-blue-600 hover:bg-blue-500 text-white transition-colors"
|
|
830
|
+
>
|
|
831
|
+
Save
|
|
832
|
+
</button>
|
|
833
|
+
</div>
|
|
834
|
+
</div>
|
|
835
|
+
</div>
|
|
836
|
+
)}
|
|
837
|
+
</div>
|
|
838
|
+
);
|
|
839
|
+
}
|