Skip to content

Commit 5f331ae

Browse files
committed
Merge branch 'FoPro_WS_UML-Assessment' of github.com:MEITREX/frontend into FoPro_WS_UML-Assessment
2 parents 36ff498 + dfabac5 commit 5f331ae

8 files changed

Lines changed: 261 additions & 51 deletions

File tree

app/courses/[courseId]/uml/[umlId]/student.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,8 @@ export default function StudentUMLAssignment() {
480480
showInfo={showInfo}
481481
setShowInfo={setShowInfo}
482482
invisible={autoFullscreenHackActive && fullscreen}
483+
sourceCode={diagramCode}
484+
fileName="diagram"
483485
infoContent={
484486
<Box bgcolor="#e3f2fd" p={2}>
485487
<ContentViewer htmlContent={exercise.description} />

components/Navbar.tsx

Lines changed: 47 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,15 @@ const NAVBAR_NOTIFICATION_ADDED_SUB = graphql`
116116
}
117117
`;
118118

119+
const XP_CACHE_TTL_MS = 30000;
120+
const XP_POLL_INTERVAL_MS = 30000;
121+
type XpLevelInfo = {
122+
level: number;
123+
xpInLevel: number;
124+
xpRequiredForLevelUp: number;
125+
};
126+
const xpLevelCache = new Map<string, { value: XpLevelInfo; timestamp: number }>();
127+
119128
/** ---------------- Utilities ---------------- */
120129
function useIsTutor(_frag: NavbarIsTutor$key) {
121130
const data = useFragment(
@@ -558,16 +567,27 @@ function UserInfo({ tutor, userId }: { tutor: boolean; userId: string }) {
558567
);
559568

560569
// XP/Level (keeps your HEAD logic)
561-
const [levelInfo, setLevelInfo] = useState<{
562-
level: number;
563-
xpInLevel: number;
564-
xpRequiredForLevelUp: number;
565-
} | null>(null);
570+
const [levelInfo, setLevelInfo] = useState<XpLevelInfo | null>(null);
571+
const xpFetchInFlightRef = useRef(false);
572+
const xpLastFetchAtRef = useRef(0);
566573

567574
// central XP fetcher (Relay)
568575
const relayEnv = useRelayEnvironment();
569-
const fetchXP = useCallback(async () => {
576+
const fetchXP = useCallback(async (force = false) => {
570577
if (!userId) return;
578+
579+
const now = Date.now();
580+
const cached = xpLevelCache.get(userId);
581+
if (!force && cached && now - cached.timestamp < XP_CACHE_TTL_MS) {
582+
setLevelInfo(cached.value);
583+
return;
584+
}
585+
if (!force && xpFetchInFlightRef.current) return;
586+
if (!force && now - xpLastFetchAtRef.current < 1500) return;
587+
588+
xpFetchInFlightRef.current = true;
589+
xpLastFetchAtRef.current = now;
590+
571591
try {
572592
const query = graphql`
573593
query NavbarGetUserXPQuery($userID: ID!) {
@@ -592,52 +612,48 @@ function UserInfo({ tutor, userId }: { tutor: boolean; userId: string }) {
592612
: rawUser ?? null;
593613

594614
if (!payload) {
595-
setLevelInfo({ level: 0, xpInLevel: 0, xpRequiredForLevelUp: 1 });
615+
const fallback = { level: 0, xpInLevel: 0, xpRequiredForLevelUp: 1 };
616+
setLevelInfo(fallback);
617+
xpLevelCache.set(userId, { value: fallback, timestamp: Date.now() });
596618
return;
597619
}
598620
const requiredXP = Number(payload.requiredXP ?? 0);
599621
const exceedingXP = Number(payload.exceedingXP ?? 0);
600622
const level = Number(payload.level ?? 0);
601-
setLevelInfo({
623+
const nextLevelInfo = {
602624
level: Number.isFinite(level) ? level : 0,
603625
xpInLevel: Number.isFinite(exceedingXP) ? exceedingXP : 0,
604626
xpRequiredForLevelUp:
605627
Number.isFinite(requiredXP) && requiredXP > 0 ? requiredXP : 1,
606-
});
628+
};
629+
setLevelInfo(nextLevelInfo);
630+
xpLevelCache.set(userId, { value: nextLevelInfo, timestamp: Date.now() });
607631
} catch (e) {
608632
console.error("[Navbar XP] fetch failed", e);
609633
setLevelInfo({ level: 0, xpInLevel: 0, xpRequiredForLevelUp: 1 });
634+
} finally {
635+
xpFetchInFlightRef.current = false;
610636
}
611637
}, [relayEnv, userId]);
612638

613639
// initial fetch and on identity changes
614640
useEffect(() => {
641+
if (!userId) return;
642+
const cached = xpLevelCache.get(userId);
643+
if (cached) {
644+
setLevelInfo(cached.value);
645+
}
615646
fetchXP();
616647
}, [fetchXP]);
617648

618-
// refresh when window regains focus / becomes visible / custom XP events fire
649+
// periodic refresh only (no focus/visibility/custom event triggers)
619650
useEffect(() => {
620-
const handleFocus = () => fetchXP();
621-
const handleVisible = () => {
622-
if (document.visibilityState === "visible") fetchXP();
623-
};
624-
const handleCustom = () => fetchXP(); // dispatch window.dispatchEvent(new Event('xp:updated')) elsewhere
625-
626-
window.addEventListener("focus", handleFocus);
627-
document.addEventListener("visibilitychange", handleVisible);
628-
window.addEventListener("xp:updated", handleCustom as EventListener);
629-
window.addEventListener(
630-
"meitrex:xp-updated",
631-
handleCustom as EventListener
632-
);
651+
const interval = window.setInterval(() => {
652+
fetchXP(true);
653+
}, XP_POLL_INTERVAL_MS);
654+
633655
return () => {
634-
window.removeEventListener("focus", handleFocus);
635-
document.removeEventListener("visibilitychange", handleVisible);
636-
window.removeEventListener("xp:updated", handleCustom as EventListener);
637-
window.removeEventListener(
638-
"meitrex:xp-updated",
639-
handleCustom as EventListener
640-
);
656+
window.clearInterval(interval);
641657
};
642658
}, [fetchXP]);
643659

@@ -653,7 +669,7 @@ function UserInfo({ tutor, userId }: { tutor: boolean; userId: string }) {
653669

654670
if ((remaining <= 0 || perc >= 100) && xpRetryRef.current < 3) {
655671
xpRetryRef.current += 1;
656-
const t = setTimeout(() => fetchXP(), 1200);
672+
const t = setTimeout(() => fetchXP(true), 1200);
657673
return () => clearTimeout(t);
658674
}
659675
// reset retries once things look normal
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"use client";
2+
3+
import type { Root } from "@hylimo/diagram-common";
4+
import { PDFRenderer } from "@hylimo/diagram-render-pdf";
5+
import { SVGRenderer } from "@hylimo/diagram-render-svg";
6+
import DownloadIcon from "@mui/icons-material/Download";
7+
import Button from "@mui/material/Button";
8+
import IconButton from "@mui/material/IconButton";
9+
import Menu from "@mui/material/Menu";
10+
import MenuItem from "@mui/material/MenuItem";
11+
import Tooltip from "@mui/material/Tooltip";
12+
import saveAs from "file-saver";
13+
import { useCallback, useMemo, useState } from "react";
14+
15+
interface DiagramDownloadProps {
16+
diagram: Root | undefined;
17+
fileName: string;
18+
sourceCode: string;
19+
variant?: "button" | "icon";
20+
}
21+
22+
export function DiagramDownload({ diagram, fileName, sourceCode, variant = "button" }: DiagramDownloadProps) {
23+
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
24+
const isOpen = Boolean(anchorEl);
25+
26+
// Memoize renderer instances to avoid recreating them on every render
27+
const svgRenderer = useMemo(() => new SVGRenderer(), []);
28+
const pdfRenderer = useMemo(() => new PDFRenderer(), []);
29+
30+
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
31+
setAnchorEl(event.currentTarget);
32+
};
33+
34+
const handleClose = () => {
35+
setAnchorEl(null);
36+
};
37+
38+
const downloadSVG = useCallback(
39+
async (textAsPath: boolean) => {
40+
if (!diagram) return;
41+
42+
try {
43+
const svgContent = await svgRenderer.render(diagram, textAsPath);
44+
const svgBlob = new Blob([svgContent], {
45+
type: "image/svg+xml;charset=utf-8"
46+
});
47+
saveAs(svgBlob, `${fileName}.svg`);
48+
handleClose();
49+
} catch (error) {
50+
console.error("Error downloading SVG:", error);
51+
}
52+
},
53+
[diagram, fileName, svgRenderer]
54+
);
55+
56+
const downloadPDF = useCallback(
57+
async () => {
58+
if (!diagram) return;
59+
60+
try {
61+
const pdf = await pdfRenderer.render(diagram, "#ffffff");
62+
saveAs(new Blob(pdf, { type: "application/pdf" }), `${fileName}.pdf`);
63+
handleClose();
64+
} catch (error) {
65+
console.error("Error downloading PDF:", error);
66+
}
67+
},
68+
[diagram, fileName, pdfRenderer]
69+
);
70+
71+
const downloadSource = useCallback(() => {
72+
if (!sourceCode) return;
73+
saveAs(new Blob([sourceCode]), `${fileName}.hyl`);
74+
handleClose();
75+
}, [fileName, sourceCode]);
76+
77+
return (
78+
<>
79+
{variant === "icon" ? (
80+
<Tooltip title="Download">
81+
<IconButton
82+
onClick={handleClick}
83+
disabled={!diagram && !sourceCode}
84+
size="small"
85+
>
86+
<DownloadIcon />
87+
</IconButton>
88+
</Tooltip>
89+
) : (
90+
<Button
91+
onClick={handleClick}
92+
disabled={!diagram && !sourceCode}
93+
startIcon={<DownloadIcon />}
94+
variant="contained"
95+
size="small"
96+
>
97+
Download
98+
</Button>
99+
)}
100+
101+
<Menu
102+
anchorEl={anchorEl}
103+
open={isOpen}
104+
onClose={handleClose}
105+
anchorOrigin={{
106+
vertical: "bottom",
107+
horizontal: "left"
108+
}}
109+
transformOrigin={{
110+
vertical: "top",
111+
horizontal: "left"
112+
}}
113+
>
114+
<MenuItem
115+
onClick={() => downloadSVG(false)}
116+
disabled={!diagram}
117+
>
118+
SVG
119+
</MenuItem>
120+
<Tooltip
121+
title="Powerpoint and many others do not support embedded fonts, so the text is converted to a path instead"
122+
placement="right"
123+
>
124+
<MenuItem
125+
onClick={() => downloadSVG(true)}
126+
disabled={!diagram}
127+
>
128+
SVG (text as path)
129+
</MenuItem>
130+
</Tooltip>
131+
<MenuItem
132+
onClick={downloadPDF}
133+
disabled={!diagram}
134+
>
135+
PDF
136+
</MenuItem>
137+
<MenuItem onClick={downloadSource} disabled={!sourceCode}>
138+
Source
139+
</MenuItem>
140+
</Menu>
141+
</>
142+
);
143+
}

components/hylimo/FullscreenEditorDialog.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ interface FullscreenEditorDialogProps {
2222
infoContent?: React.ReactNode;
2323
children: React.ReactNode;
2424
invisible?: boolean;
25+
sourceCode?: string;
26+
fileName?: string;
2527
}
2628

2729
export default function FullscreenEditorDialog({
@@ -32,7 +34,7 @@ export default function FullscreenEditorDialog({
3234
setShowInfo,
3335
infoContent,
3436
children,
35-
invisible = false
37+
invisible = false,
3638
}: FullscreenEditorDialogProps) {
3739
return (
3840
<Dialog

components/hylimo/HylimoEditor.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
"use client";
22

33
import { language, LanguageClientProxy, setupLanguageClient } from "@/components/hylimo/lspPlugin";
4+
import type { Root } from "@hylimo/diagram-common";
45
import { DiagramActionNotification, DiagramCloseNotification, DiagramOpenNotification } from "@hylimo/diagram-protocol";
56
import { createContainer, DiagramServerProxy, ResetCanvasBoundsAction, TYPES } from "@hylimo/diagram-ui";
67
import { Box } from "@mui/material";
78
import type * as monaco from "monaco-editor";
89
import { EditorApp, type EditorAppConfig } from "monaco-languageclient/editorApp";
9-
import { useEffect, useRef } from "react";
10+
import { useEffect, useRef, useState } from "react";
1011
import Split from "react-split";
1112
import type { ActionHandlerRegistry, IActionDispatcher, IActionHandler } from "sprotty";
1213
import { FitToScreenAction, RequestModelAction } from "sprotty-protocol";
1314
import type { Disposable } from "vscode-languageserver-protocol";
15+
import { DiagramDownload } from "./DownloadDigram";
1416

1517
import "@hylimo/diagram-ui/css/hylimo.css";
1618
import "@hylimo/diagram-ui/css/toolbox.css";
@@ -39,6 +41,8 @@ export default function HylimoEditor({
3941
onChange (value: string): void;
4042
readOnly?: boolean;
4143
}) {
44+
const [sourceCode, setSourceCode] = useState(initialValue);
45+
const [diagram, setDiagram] = useState<Root | undefined>();
4246
const editorElement = useRef<HTMLDivElement | null>(null);
4347
const sprottyWrapperRef = useRef<HTMLDivElement | null>(null);
4448
const disposablesRef = useRef<(Disposable)[]>([]);
@@ -147,7 +151,9 @@ export default function HylimoEditor({
147151

148152
const changeDisposable = monacoEditor.onDidChangeModelContent(() => {
149153
if (!readOnlyRef.current && !isUpdatingModelRef.current) {
150-
onChange(monacoEditor.getValue());
154+
const newValue = monacoEditor.getValue();
155+
onChange(newValue);
156+
setSourceCode(newValue);
151157

152158
if (transactionStatus.state === TransactionState.Committed) {
153159
transactionStatus.state = TransactionState.None;
@@ -170,6 +176,10 @@ export default function HylimoEditor({
170176
super.initialize(registry);
171177
registry.register('toolboxEditPredictionResponseAction', { handle: () => {} } as IActionHandler);
172178
const notificationDisposable = currentLanguageClient.onNotification(DiagramActionNotification.type, (msg: any) => {
179+
// Extract diagram from action if available (similar to Hylimo Vue version)
180+
if (msg.action?.newRoot !== undefined && msg.clientId === this.clientId) {
181+
setDiagram(msg.action.newRoot as Root);
182+
}
173183
if (msg.clientId === this.clientId) this.messageReceived(msg);
174184
});
175185
disposablesRef.current.push(notificationDisposable);
@@ -269,8 +279,8 @@ export default function HylimoEditor({
269279
return (
270280
<Box
271281
sx={{
272-
height: "100%", width: "100%", overflow: "hidden",
273-
"& .split": { display: "flex", height: "100%" },
282+
height: "100%", width: "100%", overflow: "hidden", display: "flex", flexDirection: "column",
283+
"& .split": { display: "flex", height: "100%", flex: 1 },
274284
"& .gutter": { backgroundColor: "action.hover", width: "10px !important", cursor: "col-resize" },
275285
"& .toolbox-wrapper, & .toolbox-root": {
276286
display: readOnly ? "none !important" : "block"
@@ -284,6 +294,14 @@ export default function HylimoEditor({
284294
}
285295
}}
286296
>
297+
<Box sx={{ p: 1, borderBottom: "1px solid", borderColor: "divider", display: "flex", justifyContent: "flex-end", gap: 1 }}>
298+
<DiagramDownload
299+
diagram={diagram}
300+
fileName="diagram"
301+
sourceCode={sourceCode}
302+
variant="icon"
303+
/>
304+
</Box>
287305
<Split className="split" sizes={[50, 50]} minSize={100} gutterSize={10}>
288306
<div
289307
ref={editorElement}

0 commit comments

Comments
 (0)