create-interview-cockpit 0.26.1 → 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.
@@ -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
+ }